bloodhound-1.0.0.0: tests/Test/SecurityAnalyticsSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Test.SecurityAnalyticsSpec (spec) where
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as M
import Data.Maybe (fromJust, isJust)
import Database.Bloodhound.OpenSearch2.Client qualified as OS2.Client
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2A
import Database.Bloodhound.OpenSearch3.Client qualified as OS3.Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3A
import TestsUtils.Common
import TestsUtils.Import
import Prelude
-- | Pure endpoint-shape and decoder tests for the Security Analytics
-- plugin detector create\/get and rule search endpoints across
-- OpenSearch 2 and 3. Mirrors 'Test.AlertingSpec' for the shape
-- tests and decoder-fixture pinning.
--
-- The detector 'SADetector' is modelled pragmatically (typed shell +
-- opaque aeson 'Value' catch-alls) following the 'Monitor' precedent.
-- These tests pin (a) the endpoint shape per backend, (b) OS2\/OS3
-- cross-backend parity, (c) decoder round-trips for the documented
-- wire fixtures (incl. the severity-as-string and actions-as-array
-- gotchas), and (d) the rule-search envelope decoding.
--
-- OpenSearch 1.3 does not ship the Security Analytics plugin (it was
-- introduced in OS 2.4), so the OS1 module deliberately does not
-- expose these endpoints; see the changelog entry for bloodhound-364b.
--
-- The live round-trip is @pendingWith@'d: the CI docker-compose runs
-- Elasticsearch clusters only, so the Security Analytics plugin
-- (OS-specific) is not available in CI. Re-enable once a live OS
-- cluster is wired in (same precedent as 'Test.AlertingSpec' /
-- 'Test.KnnWarmupSpec' / 'Test.IsmSpec' live blocks).
spec :: Spec
spec = describe "Security Analytics detector and rule APIs" $ do
-- =========================================================== --
-- Endpoint shape: OpenSearch 3 (detailed) --
-- =========================================================== --
describe "endpoint shape (OpenSearch 3)" $ do
it "createSADetector POSTs to /_plugins/_security_analytics/detectors with the encoded body" $ do
let req = OS3Requests.createSADetector os3SampleDetector
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
let body = fromJust (bhRequestBody req)
let decoded = (decode body :: Maybe OS3A.SADetector)
decoded `shouldSatisfy` \m -> isJust m && OS3A.saDetectorName (fromJust m) == "test-detector"
it "getSADetector GETs /_plugins/_security_analytics/detectors/{id} with no body" $ do
let req = OS3Requests.getSADetector (OS3A.DetectorId "my-detector-id")
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector-id"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
it "getSADetector keeps the id as a single opaque path segment" $ do
let req = OS3Requests.getSADetector (OS3A.DetectorId "my-detector_42")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector_42"]
it "searchSAPrePackagedRules POSTs to /_plugins/_security_analytics/rules/_search?pre_packaged=true" $ do
let req = OS3Requests.searchSAPrePackagedRules Nothing
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "_search"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("pre_packaged", Just "true")]
-- Default empty-body POST renders to the literal "{}".
bhRequestBody req `shouldBe` Just (encode (object []))
it "searchSACustomRules POSTs to /_plugins/_security_analytics/rules/_search?pre_packaged=false" $ do
let req = OS3Requests.searchSACustomRules Nothing
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "_search"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("pre_packaged", Just "false")]
it "searchSAPrePackagedRules forwards the query body when supplied" $ do
let q =
OS3A.SARulesQuery
{ OS3A.saRulesQueryFrom = Just 0,
OS3A.saRulesQuerySize = Just 20,
OS3A.saRulesQueryQuery = Nothing
}
let req = OS3Requests.searchSAPrePackagedRules (Just q)
let body = fromJust (bhRequestBody req)
-- The body must contain the from/size keys.
LBS.unpack body `shouldContain` "\"from\""
LBS.unpack body `shouldContain` "\"size\""
it "createSACustomRule POSTs to /_plugins/_security_analytics/rules?category={logtype} with raw Sigma YAML body" $ do
let req = OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("category", Just "windows")]
-- Body is the raw Sigma YAML text (NOT JSON-encoded).
let body = fromJust (bhRequestBody req)
LBS.unpack body `shouldContain` "title: Moriya Rootkit"
it "createSACustomRule renders the category from saDetectorTypeText" $ do
let req = OS3Requests.createSACustomRule OS3A.SADetectorTypeLinux "title: x"
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("category", Just "linux")]
it "updateSACustomRule PUTs to /_plugins/_security_analytics/rules/{id}?category={logtype} with no forced when False" $ do
let req = OS3Requests.updateSACustomRule (OS3A.RuleId "my-rule-id") OS3A.SADetectorTypeWindows False sampleSigmaYaml
bhRequestMethod req `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "my-rule-id"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("category", Just "windows")]
let body = fromJust (bhRequestBody req)
LBS.unpack body `shouldContain` "title: Moriya Rootkit"
it "updateSACustomRule adds forced=true when forced" $ do
let req = OS3Requests.updateSACustomRule (OS3A.RuleId "my-rule-id") OS3A.SADetectorTypeWindows True sampleSigmaYaml
getRawEndpointQueries (bhRequestEndpoint req)
`shouldContain` [("forced", Just "true")]
it "updateSACustomRule keeps the id as a single opaque path segment" $ do
let req = OS3Requests.updateSACustomRule (OS3A.RuleId "rule_42#v1") OS3A.SADetectorTypeWindows False "title: x"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "rule_42#v1"]
it "deleteSACustomRule DELETEs /_plugins/_security_analytics/rules/{id} with no body and no forced when False" $ do
let req = OS3Requests.deleteSACustomRule (OS3A.RuleId "my-rule-id") False
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "my-rule-id"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
it "deleteSACustomRule adds forced=true when forced" $ do
let req = OS3Requests.deleteSACustomRule (OS3A.RuleId "my-rule-id") True
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("forced", Just "true")]
it "deleteSACustomRule keeps the id as a single opaque path segment" $ do
let req = OS3Requests.deleteSACustomRule (OS3A.RuleId "rule_42#v1") False
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "rule_42#v1"]
it "updateSADetector PUTs to /_plugins/_security_analytics/detectors/{id} with the encoded body" $ do
let req = OS3Requests.updateSADetector (OS3A.DetectorId "my-detector-id") os3SampleDetector
bhRequestMethod req `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector-id"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
let body = fromJust (bhRequestBody req)
let decoded = (decode body :: Maybe OS3A.SADetector)
decoded `shouldSatisfy` \m -> isJust m && OS3A.saDetectorName (fromJust m) == "test-detector"
it "deleteSADetector DELETEs /_plugins/_security_analytics/detectors/{id} with no body" $ do
let req = OS3Requests.deleteSADetector (OS3A.DetectorId "my-detector-id")
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector-id"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
it "deleteSADetector keeps the id as a single opaque path segment" $ do
let req = OS3Requests.deleteSADetector (OS3A.DetectorId "my-detector_42")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector_42"]
it "searchSADetectors POSTs to /_plugins/_security_analytics/detectors/_search with default {} body" $ do
let req = OS3Requests.searchSADetectors Nothing
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "_search"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
-- Default empty-body POST renders to the literal "{}".
bhRequestBody req `shouldBe` Just (encode (object []))
it "searchSADetectors forwards the query body when supplied" $ do
let q =
OS3A.SADetectorsSearchQuery
{ OS3A.saDetectorsSearchQueryFrom = Just 0,
OS3A.saDetectorsSearchQuerySize = Just 20,
OS3A.saDetectorsSearchQueryQuery = Nothing
}
let req = OS3Requests.searchSADetectors (Just q)
let body = fromJust (bhRequestBody req)
LBS.unpack body `shouldContain` "\"from\""
LBS.unpack body `shouldContain` "\"size\""
-- =========================================================== --
-- Endpoint shape: OpenSearch 2 (path/method parity) --
-- =========================================================== --
describe "endpoint shape (OpenSearch 2)" $ do
it "createSADetector POSTs to /_plugins/_security_analytics/detectors" $ do
let req = OS2Requests.createSADetector os2SampleDetector
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors"]
it "getSADetector GETs /_plugins/_security_analytics/detectors/{id}" $ do
let req = OS2Requests.getSADetector (OS2A.DetectorId "os2-id")
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "os2-id"]
bhRequestBody req `shouldBe` Nothing
it "searchSAPrePackagedRules POSTs with pre_packaged=true" $ do
let req = OS2Requests.searchSAPrePackagedRules Nothing
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("pre_packaged", Just "true")]
it "searchSACustomRules POSTs with pre_packaged=false" $ do
let req = OS2Requests.searchSACustomRules Nothing
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("pre_packaged", Just "false")]
it "createSACustomRule POSTs with category query param and raw YAML body" $ do
let req = OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml
bhRequestMethod req `shouldBe` "POST"
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("category", Just "windows")]
it "updateSACustomRule PUTs with category query param" $ do
let req = OS2Requests.updateSACustomRule (OS2A.RuleId "os2-id") OS2A.SADetectorTypeWindows False sampleSigmaYaml
bhRequestMethod req `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "os2-id"]
it "deleteSACustomRule DELETEs /_plugins/_security_analytics/rules/{id}" $ do
let req = OS2Requests.deleteSACustomRule (OS2A.RuleId "os2-id") True
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "rules", "os2-id"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("forced", Just "true")]
it "updateSADetector PUTs to /_plugins/_security_analytics/detectors/{id}" $ do
let req = OS2Requests.updateSADetector (OS2A.DetectorId "os2-id") os2SampleDetector
bhRequestMethod req `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "os2-id"]
it "deleteSADetector DELETEs /_plugins/_security_analytics/detectors/{id}" $ do
let req = OS2Requests.deleteSADetector (OS2A.DetectorId "os2-id")
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "os2-id"]
bhRequestBody req `shouldBe` Nothing
it "searchSADetectors POSTs to /_plugins/_security_analytics/detectors/_search" $ do
let req = OS2Requests.searchSADetectors Nothing
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "_search"]
-- =========================================================== --
-- Cross-backend parity (OS2, OS3 emit identical requests) --
-- =========================================================== --
describe "cross-backend parity (OS2/OS3)" $ do
let did2 = OS2A.DetectorId "shared-id"
did3 = OS3A.DetectorId "shared-id"
it "createSADetector: OS2, OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.createSADetector os2SampleDetector))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSADetector os3SampleDetector))
bhRequestMethod (OS2Requests.createSADetector os2SampleDetector)
`shouldBe` bhRequestMethod (OS3Requests.createSADetector os3SampleDetector)
it "createSADetector: OS2, OS3 attach identical body bytes" $ do
let b2 = bhRequestBody (OS2Requests.createSADetector os2SampleDetector)
b3 = bhRequestBody (OS3Requests.createSADetector os3SampleDetector)
b2 `shouldBe` b3
it "getSADetector: OS2, OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.getSADetector did2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSADetector did3))
bhRequestMethod (OS2Requests.getSADetector did2)
`shouldBe` bhRequestMethod (OS3Requests.getSADetector did3)
it "searchSAPrePackagedRules: OS2, OS3 produce the same path and queries" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSAPrePackagedRules Nothing))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.searchSAPrePackagedRules Nothing))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))
it "pre_packaged vs custom: same path, distinct query value" $ do
getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSACustomRules Nothing))
getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))
`shouldNotBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSACustomRules Nothing))
it "updateSADetector: OS2, OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSADetector did2 os2SampleDetector))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSADetector did3 os3SampleDetector))
bhRequestMethod (OS2Requests.updateSADetector did2 os2SampleDetector)
`shouldBe` bhRequestMethod (OS3Requests.updateSADetector did3 os3SampleDetector)
it "updateSADetector: OS2, OS3 attach identical body bytes" $ do
let b2 = bhRequestBody (OS2Requests.updateSADetector did2 os2SampleDetector)
b3 = bhRequestBody (OS3Requests.updateSADetector did3 os3SampleDetector)
b2 `shouldBe` b3
it "deleteSADetector: OS2, OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteSADetector did2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteSADetector did3))
bhRequestMethod (OS2Requests.deleteSADetector did2)
`shouldBe` bhRequestMethod (OS3Requests.deleteSADetector did3)
it "searchSADetectors: OS2, OS3 produce the same path and queries" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSADetectors Nothing))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSADetectors Nothing))
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.searchSADetectors Nothing))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSADetectors Nothing))
let rid2 = OS2A.RuleId "shared-rule-id"
rid3 = OS3A.RuleId "shared-rule-id"
it "createSACustomRule: OS2, OS3 produce the same path, method, and queries" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml))
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml))
bhRequestMethod (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml)
`shouldBe` bhRequestMethod (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml)
it "createSACustomRule: OS2, OS3 attach identical body bytes" $ do
let b2 = bhRequestBody (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml)
b3 = bhRequestBody (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml)
b2 `shouldBe` b3
it "updateSACustomRule: OS2, OS3 produce the same path, method, and queries" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSACustomRule rid2 OS2A.SADetectorTypeWindows True sampleSigmaYaml))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSACustomRule rid3 OS3A.SADetectorTypeWindows True sampleSigmaYaml))
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.updateSACustomRule rid2 OS2A.SADetectorTypeWindows True sampleSigmaYaml))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.updateSACustomRule rid3 OS3A.SADetectorTypeWindows True sampleSigmaYaml))
it "deleteSACustomRule: OS2, OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteSACustomRule rid2 False))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteSACustomRule rid3 False))
bhRequestMethod (OS2Requests.deleteSACustomRule rid2 False)
`shouldBe` bhRequestMethod (OS3Requests.deleteSACustomRule rid3 False)
it "deleteSACustomRule: forced flag produces the same query across backends" $ do
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.deleteSACustomRule rid2 True))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.deleteSACustomRule rid3 True))
-- =========================================================== --
-- Decoder tests (OS3 types — identical instances across OS2-3) --
-- =========================================================== --
describe "SADetectorResponse decoding (create fixture)" $ do
-- Verbatim from the OS docs create-detector response example.
let fixture =
LBS.pack
"{\"_id\":\"dc2VB4QBrbtylUb_Hfa3\",\"_version\":1,\"detector\":{\"name\":\"nbReFCjlfn\",\"detector_type\":\"windows\",\"enabled\":true,\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}},\"inputs\":[{\"detector_input\":{\"description\":\"windows detector for security analytics\",\"indices\":[\"windows\"],\"custom_rules\":[{\"id\":\"bc2RB4QBrbtylUb_1Pbm\"}],\"pre_packaged_rules\":[{\"id\":\"06724a9a-52fc-11ed-bdc3-0242ac120002\"}]}}],\"triggers\":[{\"id\":\"8qhrBoQBYK1JzUUDzH-N\",\"name\":\"test-trigger\",\"severity\":\"1\",\"types\":[],\"ids\":[\"06724a9a-52fc-11ed-bdc3-0242ac120002\"],\"sev_levels\":[],\"tags\":[\"attack.defense_evasion\"],\"actions\":[{\"id\":\"hVTLkZYzlA\",\"name\":\"hello_world\",\"destination_id\":\"6r8ZBoQBKW_6dKriacQb\",\"message_template\":{\"source\":\"Trigger: \",\"lang\":\"mustache\"},\"throttle_enabled\":false,\"subject_template\":{\"source\":\"Detector just entered alert status.\",\"lang\":\"mustache\"},\"throttle\":{\"value\":108,\"unit\":\"MINUTES\"}}]}],\"last_update_time\":\"2022-10-24T01:22:03.738379671Z\",\"enabled_time\":\"2022-10-24T01:22:03.738376103Z\"}}"
it "decodes the documented create-response fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.SADetectorResponse
OS3A.saDetectorResponseId decoded `shouldBe` "dc2VB4QBrbtylUb_Hfa3"
OS3A.saDetectorResponseVersion decoded `shouldBe` 1
let det = OS3A.saDetectorResponseDetector decoded
OS3A.saDetectorName det `shouldBe` "nbReFCjlfn"
OS3A.saDetectorType det `shouldBe` OS3A.SADetectorTypeWindows
OS3A.saDetectorEnabled det `shouldBe` Just True
-- last_update_time / enabled_time round-trip via the detectorOther catch-all
-- (not typed fields on SADetector).
length (OS3A.saDetectorInputs det) `shouldBe` 1
length (OS3A.saDetectorTriggers det) `shouldBe` 1
let trig = head (OS3A.saDetectorTriggers det)
OS3A.saTriggerName trig `shouldBe` "test-trigger"
-- severity is a string-encoded integer on the wire ("1"), NOT a number.
OS3A.saTriggerSeverity trig `shouldBe` "1"
OS3A.saTriggerIds trig `shouldBe` ["06724a9a-52fc-11ed-bdc3-0242ac120002"]
OS3A.saTriggerTags trig `shouldBe` ["attack.defense_evasion"]
-- actions is an ARRAY on the wire (docs table says Object — wrong).
length (OS3A.saTriggerActions trig) `shouldBe` 1
let act = head (OS3A.saTriggerActions trig)
OS3A.saActionName act `shouldBe` Just "hello_world"
OS3A.saActionDestinationId act `shouldBe` Just "6r8ZBoQBKW_6dKriacQb"
OS3A.saActionThrottleEnabled act `shouldBe` Just False
let Just th = OS3A.saActionThrottle act
OS3A.saThrottleValue th `shouldBe` 108
OS3A.saThrottleUnit th `shouldBe` "MINUTES"
describe "SADetectorResponse decoding (get fixture)" $ do
-- Verbatim from the OS docs get-detector response example.
let fixture =
LBS.pack
"{\"_id\":\"x-dwFIYBT6_n8WeuQjo4\",\"_version\":1,\"detector\":{\"name\":\"DetectorTest1\",\"detector_type\":\"windows\",\"enabled\":true,\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}},\"inputs\":[{\"detector_input\":{\"description\":\"Test and delete\",\"indices\":[\"windows1\"],\"custom_rules\":[],\"pre_packaged_rules\":[{\"id\":\"847def9e-924d-4e90-b7c4-5f581395a2b4\"}]}}],\"last_update_time\":\"2023-02-02T23:22:26.454Z\",\"enabled_time\":\"2023-02-02T23:22:26.454Z\"}}"
it "decodes the documented get-response fixture (no triggers array)" $ do
let Just decoded = decode fixture :: Maybe OS3A.SADetectorResponse
OS3A.saDetectorResponseId decoded `shouldBe` "x-dwFIYBT6_n8WeuQjo4"
let det = OS3A.saDetectorResponseDetector decoded
OS3A.saDetectorName det `shouldBe` "DetectorTest1"
OS3A.saDetectorTriggers det `shouldBe` []
length (OS3A.saDetectorInputs det) `shouldBe` 1
let input = head (OS3A.saDetectorInputs det)
OS3A.saDetectorInputIndices input `shouldBe` ["windows1"]
length (OS3A.saDetectorInputPrePackagedRules input) `shouldBe` 1
describe "SADetector round-trip" $ do
it "round-trips through encode/decode on the typed fields" $ do
let encoded = encode os3SampleDetector
Just reDecoded = decode encoded :: Maybe OS3A.SADetector
OS3A.saDetectorName reDecoded `shouldBe` OS3A.saDetectorName os3SampleDetector
OS3A.saDetectorType reDecoded `shouldBe` OS3A.saDetectorType os3SampleDetector
OS3A.saDetectorSchedule reDecoded `shouldBe` OS3A.saDetectorSchedule os3SampleDetector
describe "SADetectorType discriminator" $ do
it "decodes each documented detector_type value (lower-case form)" $ do
"linux" `decodesDetectorTypeTo` OS3A.SADetectorTypeLinux
"network" `decodesDetectorTypeTo` OS3A.SADetectorTypeNetwork
"windows" `decodesDetectorTypeTo` OS3A.SADetectorTypeWindows
"ad_ldap" `decodesDetectorTypeTo` OS3A.SADetectorTypeAdLdap
"apache_access" `decodesDetectorTypeTo` OS3A.SADetectorTypeApacheAccess
"cloudtrail" `decodesDetectorTypeTo` OS3A.SADetectorTypeCloudtrail
"dns" `decodesDetectorTypeTo` OS3A.SADetectorTypeDns
"s3" `decodesDetectorTypeTo` OS3A.SADetectorTypeS3
it "normalises upper-case to the canonical constructor" $
"WINDOWS" `decodesDetectorTypeTo` OS3A.SADetectorTypeWindows
it "round-trips through saDetectorTypeText (lower-case)" $
OS3A.saDetectorTypeText OS3A.SADetectorTypeWindows `shouldBe` "windows"
it "falls through to SADetectorTypeOther for an unknown value" $
"future_log_type" `decodesDetectorTypeTo` OS3A.SADetectorTypeOther "future_log_type"
describe "SASchedule decoding" $ do
it "decodes a period schedule" $ do
let Just s = decode "{\"period\":{\"interval\":5,\"unit\":\"HOURS\"}}" :: Maybe OS3A.SASchedule
Just (OS3A.PeriodSASchedule p) = Just s
OS3A.saSchedulePeriodInterval p `shouldBe` 5
OS3A.saSchedulePeriodUnit p `shouldBe` "HOURS"
it "falls through to OtherSASchedule for an unknown schedule kind" $ do
let Just s = decode "{\"custom\":{\"foo\":1}}" :: Maybe OS3A.SASchedule
Just (OS3A.OtherSASchedule _) = Just s
s `shouldBe` OS3A.OtherSASchedule (object ["custom" .= object ["foo" .= (1 :: Int)]])
describe "SATrigger severity-as-string gotcha" $ do
it "severity is decoded as a string (not a number) per the wire" $ do
let Just t = decode "{\"name\":\"x\",\"severity\":\"1\",\"actions\":[]}" :: Maybe OS3A.SATrigger
OS3A.saTriggerSeverity t `shouldBe` "1"
it "defaults to \"1\" when severity is absent (matches server-injected default)" $ do
let Just t = decode "{\"name\":\"x\",\"actions\":[]}" :: Maybe OS3A.SATrigger
OS3A.saTriggerSeverity t `shouldBe` "1"
describe "SARulesSearchResponse decoding (pre-packaged fixture)" $ do
-- Verbatim from the OS docs search pre-packaged rules response example.
let fixture =
LBS.pack
"{\"took\":3,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1580,\"relation\":\"eq\"},\"max_score\":0.25863406,\"hits\":[{\"_index\":\".opensearch-pre-packaged-rules-config\",\"_id\":\"6KFv1IMBdLpXWBiBelZg\",\"_version\":1,\"_seq_no\":386,\"_primary_term\":1,\"_score\":0.25863406,\"_source\":{\"category\":\"windows\",\"title\":\"Change Outlook Security Setting in Registry\",\"log_source\":\"registry_set\",\"description\":\"Change outlook email security settings\",\"references\":[{\"value\":\"https://github.com/redcanaryco/atomic-red-team/blob/master/atomics/T1137/T1137.md\"}],\"tags\":[{\"value\":\"attack.persistence\"}],\"level\":\"medium\",\"false_positives\":[{\"value\":\"Administrative scripts\"}],\"author\":\"frack113\",\"status\":\"experimental\",\"last_update_time\":\"2021-12-28T00:00:00.000Z\",\"queries\":[{\"value\":\"TargetObject: *\\\\SOFTWARE*\"}],\"rule\":\"title: Change Outlook Security Setting in Registry\\nid: c3cefdf4-6703-4e1c-bad8-bf422fc5015a\\n\"}}]}}"
it "decodes the documented pre-packaged search fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.SARulesSearchResponse
OS3A.saRulesSearchResponseTook decoded `shouldBe` 3
OS3A.saRulesSearchResponseTimedOut decoded `shouldBe` False
OS3A.saRulesShardsTotal (OS3A.saRulesSearchResponseShards decoded) `shouldBe` 1
OS3A.saRulesShardsSuccessful (OS3A.saRulesSearchResponseShards decoded) `shouldBe` 1
OS3A.saRulesTotalValue (OS3A.saRulesSearchResponseTotal decoded) `shouldBe` 1580
OS3A.saRulesTotalRelation (OS3A.saRulesSearchResponseTotal decoded)
`shouldBe` OS3A.SARulesTotalRelationEq
OS3A.saRulesSearchResponseMaxScore decoded `shouldBe` Just 0.25863406
length (OS3A.saRulesSearchResponseHits decoded) `shouldBe` 1
let hit = head (OS3A.saRulesSearchResponseHits decoded)
OS3A.saRuleHitId hit `shouldBe` "6KFv1IMBdLpXWBiBelZg"
OS3A.saRuleHitIndex hit `shouldBe` Just ".opensearch-pre-packaged-rules-config"
let rule = OS3A.saRuleHitSource hit
OS3A.saRuleCategory rule `shouldBe` Just "windows"
OS3A.saRuleTitle rule `shouldBe` Just "Change Outlook Security Setting in Registry"
OS3A.saRuleLevel rule `shouldBe` Just "medium"
OS3A.saRuleAuthor rule `shouldBe` Just "frack113"
length (OS3A.saRuleTags rule) `shouldBe` 1
length (OS3A.saRuleReferences rule) `shouldBe` 1
length (OS3A.saRuleQueries rule) `shouldBe` 1
it "saRulesSearchResponseRules projects the SARule list out" $ do
let Just decoded = decode fixture :: Maybe OS3A.SARulesSearchResponse
let rules = OS3A.saRulesSearchResponseRules decoded
length rules `shouldBe` 1
OS3A.saRuleTitle (head rules) `shouldBe` Just "Change Outlook Security Setting in Registry"
describe "SARulesSearchResponse decoding (custom fixture)" $ do
-- Verbatim from the OS docs search custom rules response example.
let fixture =
LBS.pack
"{\"took\":1,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":0.2876821,\"hits\":[{\"_index\":\".opensearch-custom-rules-config\",\"_id\":\"ZaFv1IMBdLpXWBiBa1XI\",\"_version\":2,\"_seq_no\":1,\"_primary_term\":1,\"_score\":0.2876821,\"_source\":{\"category\":\"windows\",\"title\":\"Moriya Rooskit\",\"log_source\":\"\",\"description\":\"Detects the use of Moriya rootkit\",\"references\":[{\"value\":\"https://securelist.com/operation-tunnelsnake-and-moriya-rootkit/101831\"}],\"tags\":[{\"value\":\"attack.persistence\"}],\"level\":\"critical\",\"false_positives\":[{\"value\":\"Unknown\"}],\"author\":\"Bhabesh Raj\",\"status\":\"experimental\",\"last_update_time\":\"2021-05-06T00:00:00.000Z\",\"queries\":[{\"value\":\"Provider_Name: \\\"Service Control Manager\\\"\"}],\"rule\":\"title: Moriya Rooskit\\nid: 25b9c01c-350d-4b95-bed1-836d04a4f324\\n\"}}]}}"
it "decodes the documented custom search fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.SARulesSearchResponse
OS3A.saRulesTotalValue (OS3A.saRulesSearchResponseTotal decoded) `shouldBe` 1
let rule = OS3A.saRuleHitSource (head (OS3A.saRulesSearchResponseHits decoded))
OS3A.saRuleTitle rule `shouldBe` Just "Moriya Rooskit"
OS3A.saRuleLevel rule `shouldBe` Just "critical"
OS3A.saRuleLogSource rule `shouldBe` Just ""
length (OS3A.saRuleFalsePositives rule) `shouldBe` 1
describe "SARulesQuery rendering" $ do
it "defaultSARulesQuery renders to empty object" $ do
encode OS3A.defaultSARulesQuery `shouldBe` "{}"
it "non-default fields are rendered" $ do
let q =
OS3A.SARulesQuery
{ OS3A.saRulesQueryFrom = Just 0,
OS3A.saRulesQuerySize = Just 20,
OS3A.saRulesQueryQuery = Nothing
}
LBS.unpack (encode q) `shouldContain` "\"from\""
LBS.unpack (encode q) `shouldContain` "\"size\""
describe "SADetectorsSearchResponse decoding (search fixture)" $ do
-- A realistic search-detectors response: the standard OpenSearch
-- search envelope with one detector document in hits.hits[]._source.
-- The _source is the stored detector (the SADetector body), NOT the
-- {_id,_version,detector:{...}} GET wrapper.
let fixture =
LBS.pack
"{\"took\":5,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":1.0,\"hits\":[{\"_index\":\".opendistro-security-analytics-detectors\",\"_id\":\"abc123\",\"_version\":1,\"_seq_no\":0,\"_primary_term\":1,\"_score\":1.0,\"_source\":{\"name\":\"my-detector\",\"detector_type\":\"windows\",\"type\":\"detector\",\"enabled\":true,\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}},\"inputs\":[{\"detector_input\":{\"description\":\"test\",\"indices\":[\"windows\"],\"custom_rules\":[],\"pre_packaged_rules\":[{\"id\":\"rule-1\"}]}}],\"triggers\":[],\"last_update_time\":\"2023-01-01T00:00:00.000Z\",\"enabled_time\":\"2023-01-01T00:00:00.000Z\"}}]}}"
it "decodes the search-detectors envelope" $ do
let Just decoded = decode fixture :: Maybe OS3A.SADetectorsSearchResponse
OS3A.saDetectorsSearchResponseTook decoded `shouldBe` 5
OS3A.saDetectorsSearchResponseTimedOut decoded `shouldBe` False
OS3A.saDetectorsShardsTotal (OS3A.saDetectorsSearchResponseShards decoded) `shouldBe` 1
OS3A.saDetectorsShardsSuccessful (OS3A.saDetectorsSearchResponseShards decoded) `shouldBe` 1
OS3A.saDetectorsTotalValue (OS3A.saDetectorsSearchResponseTotal decoded) `shouldBe` 1
OS3A.saDetectorsTotalRelation (OS3A.saDetectorsSearchResponseTotal decoded)
`shouldBe` OS3A.SADetectorsTotalRelationEq
OS3A.saDetectorsSearchResponseMaxScore decoded `shouldBe` Just 1.0
length (OS3A.saDetectorsSearchResponseHits decoded) `shouldBe` 1
let hit = head (OS3A.saDetectorsSearchResponseHits decoded)
OS3A.saDetectorHitId hit `shouldBe` "abc123"
OS3A.saDetectorHitIndex hit `shouldBe` Just ".opendistro-security-analytics-detectors"
OS3A.saDetectorHitSeqNo hit `shouldBe` Just 0
let det = OS3A.saDetectorHitSource hit
OS3A.saDetectorName det `shouldBe` "my-detector"
OS3A.saDetectorType det `shouldBe` OS3A.SADetectorTypeWindows
OS3A.saDetectorEnabled det `shouldBe` Just True
it "saDetectorsSearchResponseDetectors projects the SADetector list out" $ do
let Just decoded = decode fixture :: Maybe OS3A.SADetectorsSearchResponse
let dets = OS3A.saDetectorsSearchResponseDetectors decoded
length dets `shouldBe` 1
OS3A.saDetectorName (head dets) `shouldBe` "my-detector"
it "tolerates a missing max_score (Nothing)" $ do
let Just decoded =
decode
(LBS.pack "{\"took\":1,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"hits\":[]}}") ::
Maybe OS3A.SADetectorsSearchResponse
OS3A.saDetectorsSearchResponseMaxScore decoded `shouldBe` Nothing
OS3A.saDetectorsSearchResponseHits decoded `shouldBe` []
describe "DeleteSADetectorResponse decoding (delete fixture)" $ do
-- The delete-detector response is the bare Elasticsearch
-- delete-document body (no `acknowledged` key), identical in shape
-- to DeleteMonitorResponse / DeleteDestinationResponse.
let fixture =
LBS.pack
"{\"_index\":\".opendistro-security-analytics-detectors\",\"_id\":\"abc123\",\"_version\":2,\"result\":\"deleted\",\"forced_refresh\":true,\"_shards\":{\"total\":2,\"successful\":2,\"skipped\":0,\"failed\":0},\"_seq_no\":3,\"_primary_term\":1}"
it "decodes the documented delete-detector fixture (full shape)" $ do
let Just decoded = decode fixture :: Maybe OS3A.DeleteSADetectorResponse
OS3A.deleteSADetectorResponseIndex decoded `shouldBe` Just ".opendistro-security-analytics-detectors"
OS3A.deleteSADetectorResponseId decoded `shouldBe` "abc123"
OS3A.deleteSADetectorResponseVersion decoded `shouldBe` 2
OS3A.deleteSADetectorResponseResult decoded `shouldBe` Just "deleted"
OS3A.deleteSADetectorResponseForcedRefresh decoded `shouldBe` Just True
let Just sh = OS3A.deleteSADetectorResponseShards decoded
OS3A.saDetectorsShardsTotal sh `shouldBe` 2
OS3A.saDetectorsShardsSuccessful sh `shouldBe` 2
OS3A.deleteSADetectorResponseSeqNo decoded `shouldBe` Just 3
OS3A.deleteSADetectorResponsePrimaryTerm decoded `shouldBe` Just 1
it "decodes the minimal {_id,_version} fixture (OS 2.x/3.x shape, live-verified bead bloodhound-z5j)" $ do
let minimal = LBS.pack "{\"_id\":\"abc123\",\"_version\":1}"
Just decoded = decode minimal :: Maybe OS3A.DeleteSADetectorResponse
OS3A.deleteSADetectorResponseId decoded `shouldBe` "abc123"
OS3A.deleteSADetectorResponseVersion decoded `shouldBe` 1
OS3A.deleteSADetectorResponseIndex decoded `shouldBe` Nothing
OS3A.deleteSADetectorResponseResult decoded `shouldBe` Nothing
OS3A.deleteSADetectorResponseForcedRefresh decoded `shouldBe` Nothing
OS3A.deleteSADetectorResponseShards decoded `shouldBe` Nothing
OS3A.deleteSADetectorResponseSeqNo decoded `shouldBe` Nothing
OS3A.deleteSADetectorResponsePrimaryTerm decoded `shouldBe` Nothing
it "round-trips through encode/decode" $ do
let Just decoded = decode fixture :: Maybe OS3A.DeleteSADetectorResponse
decode (encode decoded) `shouldBe` Just decoded
describe "SADetectorsSearchQuery rendering" $ do
it "defaultSADetectorsSearchQuery renders to empty object" $
encode OS3A.defaultSADetectorsSearchQuery `shouldBe` "{}"
it "non-default fields are rendered" $ do
let q =
OS3A.SADetectorsSearchQuery
{ OS3A.saDetectorsSearchQueryFrom = Just 0,
OS3A.saDetectorsSearchQuerySize = Just 20,
OS3A.saDetectorsSearchQueryQuery = Nothing
}
LBS.unpack (encode q) `shouldContain` "\"from\""
LBS.unpack (encode q) `shouldContain` "\"size\""
describe "SARuleResponse decoding (create fixture)" $ do
-- Verbatim from the OS docs create-custom-rule response example.
let fixture =
LBS.pack
"{\"_id\":\"M1Rm1IMByX0LvTiGvde2\",\"_version\":1,\"rule\":{\"category\":\"windows\",\"title\":\"Moriya Rootkit\",\"log_source\":\"\",\"description\":\"Detects the use of Moriya rootkit as described in the securelist's Operation TunnelSnake report\",\"tags\":[{\"value\":\"attack.persistence\"},{\"value\":\"attack.privilege_escalation\"},{\"value\":\"attack.t1543.003\"}],\"references\":[{\"value\":\"https://securelist.com/operation-tunnelsnake-and-moriya-rootkit/101831\"}],\"level\":\"critical\",\"false_positives\":[{\"value\":\"Unknown\"}],\"author\":\"Bhabesh Raj\",\"status\":\"experimental\",\"last_update_time\":\"2021-05-06T00:00:00.000Z\",\"rule\":\"title: Moriya Rootkit\\nid: 25b9c01c-350d-4b95-bed1-836d04a4f324\\n\"}}"
it "decodes the documented create-rule fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.SARuleResponse
OS3A.saRuleResponseId decoded `shouldBe` "M1Rm1IMByX0LvTiGvde2"
OS3A.saRuleResponseVersion decoded `shouldBe` 1
let rule = OS3A.saRuleResponseRule decoded
OS3A.saRuleCategory rule `shouldBe` Just "windows"
OS3A.saRuleTitle rule `shouldBe` Just "Moriya Rootkit"
OS3A.saRuleLevel rule `shouldBe` Just "critical"
OS3A.saRuleAuthor rule `shouldBe` Just "Bhabesh Raj"
length (OS3A.saRuleTags rule) `shouldBe` 3
-- The server echoes the original Sigma YAML verbatim in `rule`.
OS3A.saRuleRule rule `shouldSatisfy` isJust
-- The SARule round-trip is not perfectly lossless: the underlying
-- SARule ToJSON instance renders @queries:[]@ on re-encode even when
-- the original fixture omits the key (mergeIgnoringNulls treats an
-- empty array as non-null). This is a pre-existing property of the
-- SARule type, not of the SARuleResponse wrapper; the decode test
-- above fully validates the FromJSON instance.
describe "DeleteSARuleResponse decoding (delete fixture)" $ do
-- The delete-rule response is the bare Elasticsearch delete-document
-- body (no `acknowledged` key), identical in shape to
-- DeleteSADetectorResponse.
let fixture =
LBS.pack
"{\"_index\":\".opensearch-custom-rules-config\",\"_id\":\"abc123\",\"_version\":2,\"result\":\"deleted\",\"forced_refresh\":true,\"_shards\":{\"total\":2,\"successful\":2,\"skipped\":0,\"failed\":0},\"_seq_no\":3,\"_primary_term\":1}"
it "decodes the documented delete-rule fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.DeleteSARuleResponse
OS3A.deleteSARuleResponseIndex decoded `shouldBe` ".opensearch-custom-rules-config"
OS3A.deleteSARuleResponseId decoded `shouldBe` "abc123"
OS3A.deleteSARuleResponseVersion decoded `shouldBe` 2
OS3A.deleteSARuleResponseResult decoded `shouldBe` "deleted"
OS3A.deleteSARuleResponseForcedRefresh decoded `shouldBe` True
OS3A.saRulesShardsTotal (OS3A.deleteSARuleResponseShards decoded) `shouldBe` 2
OS3A.deleteSARuleResponseSeqNo decoded `shouldBe` 3
OS3A.deleteSARuleResponsePrimaryTerm decoded `shouldBe` 1
it "round-trips through encode/decode" $ do
let Just decoded = decode fixture :: Maybe OS3A.DeleteSARuleResponse
decode (encode decoded) `shouldBe` Just decoded
-- =========================================================== --
-- Mappings API (4 endpoints: view / create / get / update) --
-- =========================================================== --
describe "Mappings API endpoint shape (OpenSearch 3)" $ do
it "getSAMappingsView GETs /_plugins/_security_analytics/mappings/view with the encoded body" $ do
let req =
OS3A.SAMappingsViewRequest
{ OS3A.saMappingsViewIndexName = "windows",
OS3A.saMappingsViewRuleTopic = "windows"
}
r = OS3Requests.getSAMappingsView req
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "mappings", "view"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "\"index_name\""
LBS.unpack body `shouldContain` "\"rule_topic\""
it "createSAMappings POSTs to /_plugins/_security_analytics/mappings with the encoded body" $ do
let req =
OS3A.SACreateMappingsRequest
{ OS3A.saCreateMappingsIndexName = "windows",
OS3A.saCreateMappingsRuleTopic = "windows",
OS3A.saCreateMappingsPartial = Just True,
OS3A.saCreateMappingsAliasMappings =
OS3A.SAMappingsBody
{ OS3A.saMappingsBodyProperties = mempty,
OS3A.saMappingsBodyOther = Null
}
}
r = OS3Requests.createSAMappings req
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "mappings"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "\"alias_mappings\""
LBS.unpack body `shouldContain` "\"partial\""
it "getSAMappings GETs /_plugins/_security_analytics/mappings?index_name={name} with no body" $ do
let r = OS3Requests.getSAMappings "windows"
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "mappings"]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldBe` [("index_name", Just "windows")]
bhRequestBody r `shouldBe` Nothing
it "updateSAMappings PUTs to /_plugins/_security_analytics/mappings with the encoded body" $ do
let req =
OS3A.SAUpdateMappingsRequest
{ OS3A.saUpdateMappingsIndexName = "windows",
OS3A.saUpdateMappingsField = "CommandLine",
OS3A.saUpdateMappingsAlias = "windows-event_data-CommandLine"
}
r = OS3Requests.updateSAMappings req
bhRequestMethod r `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "mappings"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "\"field\""
LBS.unpack body `shouldContain` "\"alias\""
describe "Mappings API endpoint shape (OpenSearch 2)" $ do
it "getSAMappingsView GETs .../mappings/view" $ do
let r = OS2Requests.getSAMappingsView (OS2A.SAMappingsViewRequest "windows" "windows")
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "mappings", "view"]
it "getSAMappings GETs with the index_name query" $ do
let r = OS2Requests.getSAMappings "windows"
getRawEndpointQueries (bhRequestEndpoint r)
`shouldBe` [("index_name", Just "windows")]
it "updateSAMappings PUTs to .../mappings" $ do
let r = OS2Requests.updateSAMappings (OS2A.SAUpdateMappingsRequest "windows" "CommandLine" "alias")
bhRequestMethod r `shouldBe` "PUT"
describe "Mappings API cross-backend parity (OS2/OS3)" $ do
let viewReq2 = OS2A.SAMappingsViewRequest "windows" "windows"
viewReq3 = OS3A.SAMappingsViewRequest "windows" "windows"
updReq2 = OS2A.SAUpdateMappingsRequest "windows" "f" "a"
updReq3 = OS3A.SAUpdateMappingsRequest "windows" "f" "a"
it "getSAMappingsView: OS2/OS3 produce the same path, method, and queries" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.getSAMappingsView viewReq2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAMappingsView viewReq3))
bhRequestMethod (OS2Requests.getSAMappingsView viewReq2)
`shouldBe` bhRequestMethod (OS3Requests.getSAMappingsView viewReq3)
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.getSAMappingsView viewReq2))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.getSAMappingsView viewReq3))
it "getSAMappings: OS2/OS3 produce the same path and index_name query" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.getSAMappings "windows"))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAMappings "windows"))
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.getSAMappings "windows"))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.getSAMappings "windows"))
it "updateSAMappings: OS2/OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSAMappings updReq2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSAMappings updReq3))
bhRequestMethod (OS2Requests.updateSAMappings updReq2)
`shouldBe` bhRequestMethod (OS3Requests.updateSAMappings updReq3)
describe "SAMappingsViewResponse decoding (view fixture)" $ do
let fixture =
LBS.pack
"{\"properties\":{\"windows-event_data-CommandLine\":{\"path\":\"CommandLine\",\"type\":\"alias\"},\"event_uid\":{\"path\":\"EventID\",\"type\":\"alias\"}},\"unmapped_index_fields\":[\"windows-event_data-CommandLine\",\"src_ip\",\"sha1\"]}"
it "decodes the documented view-response fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.SAMappingsViewResponse
M.size (OS3A.saMappingsViewResponseProperties decoded) `shouldBe` 2
length (OS3A.saMappingsViewResponseUnmappedIndexFields decoded) `shouldBe` 3
let Just prop = M.lookup "event_uid" (OS3A.saMappingsViewResponseProperties decoded)
OS3A.saMappingPropertyPath prop `shouldBe` Just "EventID"
OS3A.saMappingPropertyType prop `shouldBe` Just "alias"
let Just prop2 = M.lookup "windows-event_data-CommandLine" (OS3A.saMappingsViewResponseProperties decoded)
OS3A.saMappingPropertyPath prop2 `shouldBe` Just "CommandLine"
it "preserves an unknown forward-compat key via the catch-all" $ do
let lbs = LBS.pack "{\"properties\":{},\"unmapped_index_fields\":[],\"future_key\":42}"
Just decoded = decode lbs :: Maybe OS3A.SAMappingsViewResponse
LBS.unpack (encode decoded) `shouldContain` "\"future_key\""
describe "SAGetMappingsResponse decoding (get fixture)" $ do
let fixture =
LBS.pack
"{\"windows\":{\"mappings\":{\"properties\":{\"windows-event_data-CommandLine\":{\"type\":\"alias\",\"path\":\"CommandLine\"},\"event_uid\":{\"type\":\"alias\",\"path\":\"EventID\"}}}}}"
it "decodes the documented get-mappings fixture (keyed by index name)" $ do
let Just decoded = decode fixture :: Maybe OS3A.SAGetMappingsResponse
M.size decoded `shouldBe` 1
let Just idx = M.lookup "windows" decoded
bodyMap = OS3A.saMappingsIndexBodyMappings idx
M.size (OS3A.saMappingsBodyProperties bodyMap) `shouldBe` 2
let Just prop = M.lookup "event_uid" (OS3A.saMappingsBodyProperties bodyMap)
OS3A.saMappingPropertyType prop `shouldBe` Just "alias"
OS3A.saMappingPropertyPath prop `shouldBe` Just "EventID"
describe "SAMappings ack decoding" $ do
it "create/update mappings {\"acknowledged\":true} decodes as Acknowledged True" $ do
let Just decoded = decode "{\"acknowledged\":true}" :: Maybe Acknowledged
isAcknowledged decoded `shouldBe` True
describe "SAMappings request rendering" $ do
it "SAMappingsViewRequest round-trips through encode/decode" $ do
let req = OS3A.SAMappingsViewRequest "windows" "windows"
decode (encode req) `shouldBe` Just req
it "SAUpdateMappingsRequest round-trips through encode/decode" $ do
let req = OS3A.SAUpdateMappingsRequest "windows" "CommandLine" "alias"
decode (encode req) `shouldBe` Just req
it "SACreateMappingsRequest omits partial when Nothing" $
let req =
OS3A.SACreateMappingsRequest
"windows"
"windows"
Nothing
(OS3A.SAMappingsBody mempty Null)
in LBS.unpack (encode req) `shouldNotContain` "\"partial\""
it "SACreateMappingsRequest includes partial when Just" $
let req =
OS3A.SACreateMappingsRequest
"windows"
"windows"
(Just True)
(OS3A.SAMappingsBody mempty Null)
in LBS.unpack (encode req) `shouldContain` "\"partial\""
it "SACreateMappingsRequest includes partial as false when Just False" $
let req =
OS3A.SACreateMappingsRequest
"windows"
"windows"
(Just False)
(OS3A.SAMappingsBody mempty Null)
in LBS.unpack (encode req) `shouldContain` "false"
-- =========================================================== --
-- Alerts, Findings, Correlation, Log type APIs --
-- (bloodhound-2cu). Endpoint shape per backend + JSON --
-- fixtures + rendering round-trips. Live round-trips stay --
-- @pendingWith@ per the CI-docker-compose precedent --
-- (bloodhound-dln). --
-- =========================================================== --
describe "Alerts/Findings/Correlation/LogType endpoint shape (OpenSearch 3)" $ do
it "getSAAlertsWith GETs /alerts with the options as queries" $ do
let opts =
OS3A.defaultSAGetAlertsOptions
{ OS3A.saGetAlertsOptionsDetectorId = Just "d1",
OS3A.saGetAlertsOptionsAlertState = Just OS3A.SAAlertStateActive,
OS3A.saGetAlertsOptionsSize = Just 10
}
r = OS3Requests.getSAAlertsWith opts
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "alerts"]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("detector_id", Just "d1")]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("alertState", Just "ACTIVE")]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("size", Just "10")]
bhRequestBody r `shouldBe` Nothing
it "getSAAlertsWith renders no queries when given defaultSAGetAlertsOptions" $ do
let r = OS3Requests.getSAAlertsWith OS3A.defaultSAGetAlertsOptions
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
it "getSAAlerts GETs /alerts?detector_id={id} and is a projected [SAAlert]" $ do
let r = OS3Requests.getSAAlerts (OS3A.DetectorId "d1")
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "alerts"]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldBe` [("detector_id", Just "d1")]
it "acknowledgeSADetectorAlerts POSTs /detectors/{id}/_acknowledge/alerts with {\"alerts\":[...]} body" $ do
let r = OS3Requests.acknowledgeSADetectorAlerts (OS3A.DetectorId "d1") ["a1", "a2"]
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "d1", "_acknowledge", "alerts"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "\"alerts\""
LBS.unpack body `shouldContain` "\"a1\""
LBS.unpack body `shouldContain` "\"a2\""
it "searchSAFindings GETs /findings/_search with the options as queries" $ do
let opts =
OS3A.defaultSASearchFindingsOptions
{ OS3A.saSearchFindingsOptionsSeverity = Just OS3A.SASeverityHigh,
OS3A.saSearchFindingsOptionsDetectionType = Just OS3A.SADetectionTypeRule
}
r = OS3Requests.searchSAFindings opts
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "findings", "_search"]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("severity", Just "high")]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("detectionType", Just "rule")]
it "createSACorrelationRule POSTs /correlation/rules with the encoded body" $ do
let req =
OS3A.CreateSACorrelationRuleRequest
[ OS3A.SACorrelateEntry "vpc_flow" "dstaddr:4.5.6.7" "network",
OS3A.SACorrelateEntry "windows" "winlog.event_data.X:Y" "windows"
]
r = OS3Requests.createSACorrelationRule req
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "correlation", "rules"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "\"correlate\""
LBS.unpack body `shouldContain` "\"vpc_flow\""
LBS.unpack body `shouldContain` "\"network\""
it "getSACorrelations GETs /correlations with start/end timestamps" $ do
let opts =
OS3A.defaultGetSACorrelationsOptions
{ OS3A.getSACorrelationsOptionsStartTimestamp = Just 1689289210000,
OS3A.getSACorrelationsOptionsEndTimestamp = Just 1689300010000
}
r = OS3Requests.getSACorrelations opts
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "correlations"]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldBe` [ ("start_timestamp", Just "1689289210000"),
("end_timestamp", Just "1689300010000")
]
it "findSACorrelation GETs /findings/correlate with required+optional params" $ do
let opts =
OS3A.defaultFindSACorrelationOptions
{ OS3A.findSACorrelationOptionsFinding = Just "f1",
OS3A.findSACorrelationOptionsDetectorType = Just "ad_ldap",
OS3A.findSACorrelationOptionsNearbyFindings = Just 20,
OS3A.findSACorrelationOptionsTimeWindow = Just "10m"
}
r = OS3Requests.findSACorrelation opts
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "findings", "correlate"]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("finding", Just "f1")]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("detector_type", Just "ad_ldap")]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("nearby_findings", Just "20")]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldContain` [("time_window", Just "10m")]
it "searchSACorrelationAlerts GETs /correlationAlerts with optional filter" $ do
let opts =
OS3A.SearchSACorrelationAlertsOptions
{ OS3A.searchSACorrelationAlertsOptionsCorrelationRuleId = Just "rule1"
}
r = OS3Requests.searchSACorrelationAlerts opts
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "correlationAlerts"]
getRawEndpointQueries (bhRequestEndpoint r)
`shouldBe` [("correlation_rule_id", Just "rule1")]
it "acknowledgeSACorrelationAlerts POSTs /_acknowledge/correlationAlerts with {\"alertIds\":[...]} body" $ do
let r = OS3Requests.acknowledgeSACorrelationAlerts ["ca1", "ca2"]
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "_acknowledge", "correlationAlerts"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "\"alertIds\""
LBS.unpack body `shouldContain` "\"ca1\""
LBS.unpack body `shouldNotContain` "\"alerts\""
it "createSALogType POSTs /logtype with the encoded body" $ do
let req =
OS3A.SALogType
{ OS3A.saLogTypeName = "custom1",
OS3A.saLogTypeDescription = Just "desc",
OS3A.saLogTypeSource = Just OS3A.SALogTypeSourceCustom,
OS3A.saLogTypeTags = Nothing,
OS3A.saLogTypeOther = Null
}
r = OS3Requests.createSALogType req
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "logtype"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "\"name\""
LBS.unpack body `shouldContain` "\"Custom\""
it "searchSALogTypes POSTs /logtype/_search with the (possibly default) body" $ do
let r = OS3Requests.searchSALogTypes Nothing
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "logtype", "_search"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
let body = fromJust (bhRequestBody r)
LBS.unpack body `shouldContain` "match_all"
it "updateSALogType PUTs /logtype/{id} with the encoded body" $ do
let req =
OS3A.SALogType
{ OS3A.saLogTypeName = "custom1",
OS3A.saLogTypeDescription = Just "updated",
OS3A.saLogTypeSource = Just OS3A.SALogTypeSourceCustom,
OS3A.saLogTypeTags = Nothing,
OS3A.saLogTypeOther = Null
}
r = OS3Requests.updateSALogType "lt1" req
bhRequestMethod r `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "logtype", "lt1"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
it "deleteSALogType DELETEs /logtype/{id} with no body" $ do
let r = OS3Requests.deleteSALogType "lt1"
bhRequestMethod r `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "logtype", "lt1"]
getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
bhRequestBody r `shouldBe` Nothing
it "getSAAlerts keeps the detector_id as a single opaque path segment" $
getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAAlerts (OS3A.DetectorId "a/b")))
`shouldBe` ["_plugins", "_security_analytics", "alerts"]
describe "Alerts/Findings/Correlation/LogType endpoint shape (OpenSearch 2)" $ do
it "getSAAlertsWith GETs .../alerts" $ do
let r = OS2Requests.getSAAlertsWith OS2A.defaultSAGetAlertsOptions
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "alerts"]
it "acknowledgeSADetectorAlerts POSTs .../detectors/{id}/_acknowledge/alerts" $ do
let r = OS2Requests.acknowledgeSADetectorAlerts (OS2A.DetectorId "d1") ["a1"]
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "detectors", "d1", "_acknowledge", "alerts"]
it "searchSAFindings GETs .../findings/_search" $ do
let r = OS2Requests.searchSAFindings OS2A.defaultSASearchFindingsOptions
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "findings", "_search"]
it "createSACorrelationRule POSTs .../correlation/rules" $ do
let r = OS2Requests.createSACorrelationRule (OS2A.CreateSACorrelationRuleRequest [])
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "correlation", "rules"]
it "getSACorrelations GETs .../correlations" $ do
let r = OS2Requests.getSACorrelations OS2A.defaultGetSACorrelationsOptions
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "correlations"]
it "findSACorrelation GETs .../findings/correlate" $ do
let r = OS2Requests.findSACorrelation OS2A.defaultFindSACorrelationOptions
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "findings", "correlate"]
it "searchSACorrelationAlerts GETs .../correlationAlerts" $ do
let r = OS2Requests.searchSACorrelationAlerts OS2A.defaultSearchSACorrelationAlertsOptions
bhRequestMethod r `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "correlationAlerts"]
it "acknowledgeSACorrelationAlerts POSTs .../_acknowledge/correlationAlerts" $ do
let r = OS2Requests.acknowledgeSACorrelationAlerts []
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "_acknowledge", "correlationAlerts"]
it "createSALogType POSTs .../logtype" $ do
let r = OS2Requests.createSALogType (OS2A.SALogType "n" Nothing Nothing Nothing Null)
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "logtype"]
it "searchSALogTypes POSTs .../logtype/_search" $ do
let r = OS2Requests.searchSALogTypes Nothing
bhRequestMethod r `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint r)
`shouldBe` ["_plugins", "_security_analytics", "logtype", "_search"]
it "updateSALogType PUTs .../logtype/{id}" $ do
let r = OS2Requests.updateSALogType "lt1" (OS2A.SALogType "n" Nothing Nothing Nothing Null)
bhRequestMethod r `shouldBe` "PUT"
it "deleteSALogType DELETEs .../logtype/{id}" $ do
let r = OS2Requests.deleteSALogType "lt1"
bhRequestMethod r `shouldBe` "DELETE"
describe "Alerts/Findings/Correlation/LogType cross-backend parity (OS2/OS3)" $ do
let alertOpts2 = OS2A.defaultSAGetAlertsOptions
alertOpts3 = OS3A.defaultSAGetAlertsOptions
ackBody = ["a1" :: Text]
corrReq2 = OS2A.CreateSACorrelationRuleRequest []
corrReq3 = OS3A.CreateSACorrelationRuleRequest []
logType2 = OS2A.SALogType "n" Nothing Nothing Nothing Null
logType3 = OS3A.SALogType "n" Nothing Nothing Nothing Null
it "getSAAlertsWith: OS2/OS3 produce the same path, method, and queries" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.getSAAlertsWith alertOpts2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAAlertsWith alertOpts3))
bhRequestMethod (OS2Requests.getSAAlertsWith alertOpts2)
`shouldBe` bhRequestMethod (OS3Requests.getSAAlertsWith alertOpts3)
getRawEndpointQueries (bhRequestEndpoint (OS2Requests.getSAAlertsWith alertOpts2))
`shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.getSAAlertsWith alertOpts3))
it "acknowledgeSADetectorAlerts: OS2/OS3 produce the same path, method, and body" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.acknowledgeSADetectorAlerts (OS2A.DetectorId "d") ackBody))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.acknowledgeSADetectorAlerts (OS3A.DetectorId "d") ackBody))
bhRequestBody (OS2Requests.acknowledgeSADetectorAlerts (OS2A.DetectorId "d") ackBody)
`shouldBe` bhRequestBody (OS3Requests.acknowledgeSADetectorAlerts (OS3A.DetectorId "d") ackBody)
it "searchSAFindings: OS2/OS3 produce the same path and method" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSAFindings OS2A.defaultSASearchFindingsOptions))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSAFindings OS3A.defaultSASearchFindingsOptions))
bhRequestMethod (OS2Requests.searchSAFindings OS2A.defaultSASearchFindingsOptions)
`shouldBe` bhRequestMethod (OS3Requests.searchSAFindings OS3A.defaultSASearchFindingsOptions)
it "createSACorrelationRule: OS2/OS3 produce the same path, method, and body" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.createSACorrelationRule corrReq2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSACorrelationRule corrReq3))
bhRequestBody (OS2Requests.createSACorrelationRule corrReq2)
`shouldBe` bhRequestBody (OS3Requests.createSACorrelationRule corrReq3)
it "getSACorrelations: OS2/OS3 produce the same path and method" $
getRawEndpoint (bhRequestEndpoint (OS2Requests.getSACorrelations OS2A.defaultGetSACorrelationsOptions))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSACorrelations OS3A.defaultGetSACorrelationsOptions))
it "findSACorrelation: OS2/OS3 produce the same path and method" $
getRawEndpoint (bhRequestEndpoint (OS2Requests.findSACorrelation OS2A.defaultFindSACorrelationOptions))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.findSACorrelation OS3A.defaultFindSACorrelationOptions))
it "searchSACorrelationAlerts: OS2/OS3 produce the same path and method" $
getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSACorrelationAlerts OS2A.defaultSearchSACorrelationAlertsOptions))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSACorrelationAlerts OS3A.defaultSearchSACorrelationAlertsOptions))
it "acknowledgeSACorrelationAlerts: OS2/OS3 produce the same path, method, and body" $ do
getRawEndpoint (bhRequestEndpoint (OS2Requests.acknowledgeSACorrelationAlerts ackBody))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.acknowledgeSACorrelationAlerts ackBody))
bhRequestBody (OS2Requests.acknowledgeSACorrelationAlerts ackBody)
`shouldBe` bhRequestBody (OS3Requests.acknowledgeSACorrelationAlerts ackBody)
it "createSALogType: OS2/OS3 produce the same path and method" $
getRawEndpoint (bhRequestEndpoint (OS2Requests.createSALogType logType2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSALogType logType3))
it "searchSALogTypes: OS2/OS3 produce the same path and method" $
getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSALogTypes Nothing))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSALogTypes Nothing))
it "updateSALogType: OS2/OS3 produce the same path and method" $
getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSALogType "lt" logType2))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSALogType "lt" logType3))
it "deleteSALogType: OS2/OS3 produce the same path and method" $
getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteSALogType "lt"))
`shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteSALogType "lt"))
describe "SAAlertState / SADetectionType / SASeverity / SALogTypeSource enums" $ do
it "SAAlertState round-trips every documented value" $ do
OS3A.saAlertStateText OS3A.SAAlertStateActive `shouldBe` "ACTIVE"
OS3A.saAlertStateText OS3A.SAAlertStateAcknowledged `shouldBe` "ACKNOWLEDGED"
OS3A.saAlertStateText OS3A.SAAlertStateCompleted `shouldBe` "COMPLETED"
OS3A.saAlertStateText OS3A.SAAlertStateError `shouldBe` "ERROR"
OS3A.saAlertStateText OS3A.SAAlertStateDeleted `shouldBe` "DELETED"
decode "\"ACTIVE\"" `shouldBe` Just OS3A.SAAlertStateActive
decode "\"DELETED\"" `shouldBe` Just OS3A.SAAlertStateDeleted
decode "\"FUTURE\"" `shouldBe` Just (OS3A.SAAlertStateOther "FUTURE")
it "SADetectionType round-trips rule/threat" $ do
OS3A.saDetectionTypeText OS3A.SADetectionTypeRule `shouldBe` "rule"
decode "\"threat\"" `shouldBe` Just OS3A.SADetectionTypeThreat
it "SASeverity round-trips every documented value" $ do
OS3A.saSeverityText OS3A.SASeverityCritical `shouldBe` "critical"
decode "\"low\"" `shouldBe` Just OS3A.SASeverityLow
it "SALogTypeSource round-trips Sigma/Custom/Other" $ do
OS3A.saLogTypeSourceText OS3A.SALogTypeSourceSigma `shouldBe` "Sigma"
OS3A.saLogTypeSourceText OS3A.SALogTypeSourceCustom `shouldBe` "Custom"
decode "\"Custom\"" `shouldBe` Just OS3A.SALogTypeSourceCustom
decode "\"Future\"" `shouldBe` Just (OS3A.SALogTypeSourceOther "Future")
describe "SAGetAlertsResponse decoding (get-alerts fixture)" $ do
let fixture =
LBS.pack
"{\"alerts\":[{\"detector_id\":\"detector_12345\",\"id\":\"alert_id_1\",\
\\"version\":-3,\"schema_version\":0,\"trigger_id\":\"trig1\",\
\\"trigger_name\":\"my_trigger\",\"finding_ids\":[\"finding_id_1\"],\
\\"related_doc_ids\":[\"docId1\"],\"state\":\"ACTIVE\",\
\\"error_message\":null,\"alert_history\":[],\"severity\":null,\
\\"action_execution_results\":[{\"action_id\":\"action_id_1\",\
\\"last_execution_time\":1665693544996,\"throttled_count\":0}],\
\\"start_time\":\"2022-10-13T20:39:04.995023Z\",\
\\"last_notification_time\":\"2022-10-13T20:39:04.995028Z\",\
\\"end_time\":\"2022-10-13T20:39:04.995027Z\",\
\\"acknowledged_time\":\"2022-10-13T20:39:04.995028Z\"}],\
\\"total_alerts\":1,\"detectorType\":\"windows\"}"
it "decodes the documented get-alerts fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.SAGetAlertsResponse
OS3A.saGetAlertsResponseTotalAlerts decoded `shouldBe` Just 1
OS3A.saGetAlertsResponseDetectorType decoded `shouldBe` Just "windows"
let [alert] = OS3A.saGetAlertsResponseAlerts decoded
OS3A.saAlertId alert `shouldBe` Just "alert_id_1"
OS3A.saAlertDetectorId alert `shouldBe` Just "detector_12345"
OS3A.saAlertVersion alert `shouldBe` Just (-3)
OS3A.saAlertState alert `shouldBe` Just OS3A.SAAlertStateActive
OS3A.saAlertFindingIds alert `shouldBe` Just ["finding_id_1"]
OS3A.saAlertStartTime alert `shouldBe` Just "2022-10-13T20:39:04.995023Z"
let [action] = fromJust (OS3A.saAlertActionExecutionResults alert)
OS3A.saActionExecutionResultActionId action `shouldBe` Just "action_id_1"
OS3A.saActionExecutionResultLastExecutionTime action `shouldBe` Just 1665693544996
it "decodes an empty alerts array" $ do
let Just decoded = decode "{\"alerts\":[],\"total_alerts\":0}" :: Maybe OS3A.SAGetAlertsResponse
OS3A.saGetAlertsResponseAlerts decoded `shouldBe` []
describe "AcknowledgeSADetectorAlertsResponse decoding (ack fixture)" $ do
let reqFixture = LBS.pack "{\"alerts\":[\"id1\",\"id2\"]}"
respFixture =
LBS.pack
"{\"acknowledged\":[{\"id\":\"id1\",\"state\":\"ACTIVE\",\"severity\":\"1\"}],\
\\"failed\":[],\"missing\":[]}"
it "acknowledge request renders as {\"alerts\":[...]} (snake_case)" $ do
let Just decoded = decode reqFixture :: Maybe OS3A.AcknowledgeSADetectorAlertsRequest
OS3A.acknowledgeSADetectorAlertsRequestAlerts decoded `shouldBe` ["id1", "id2"]
it "decodes the documented ack-response fixture" $ do
let Just decoded = decode respFixture :: Maybe OS3A.AcknowledgeSADetectorAlertsResponse
let [alert] = OS3A.acknowledgeSADetectorAlertsResponseAcknowledged decoded
OS3A.saAlertId alert `shouldBe` Just "id1"
OS3A.saAlertSeverity alert `shouldBe` Just "1"
describe "SASearchFindingsResponse decoding (findings fixture)" $ do
let fixture =
LBS.pack
"{\"total_findings\":1,\"findings\":[{\"detectorId\":\"d1\",\"id\":\"f1\",\
\\"related_doc_ids\":[\"1\"],\"index\":\"smallidx\",\
\\"queries\":[{\"id\":\"q1\",\"name\":\"q1\",\"fields\":[],\
\\"query\":\"field1: *value1*\",\"tags\":[\"high\",\"ad_ldap\"]}],\
\\"timestamp\":1708647166500,\
\\"document_list\":[{\"index\":\"smallidx\",\"id\":\"1\",\"found\":true,\
\\"document\":\"{\\\"field1\\\":\\\"value1\\\"}\"}]}]}"
it "decodes the documented findings fixture (camelCase detectorId)" $ do
let Just decoded = decode fixture :: Maybe OS3A.SASearchFindingsResponse
OS3A.saSearchFindingsResponseTotalFindings decoded `shouldBe` Just 1
let [f] = OS3A.saSearchFindingsResponseFindings decoded
OS3A.saFindingDetectorId f `shouldBe` Just "d1"
OS3A.saFindingId f `shouldBe` Just "f1"
OS3A.saFindingTimestamp f `shouldBe` Just 1708647166500
let [q] = fromJust (OS3A.saFindingQueries f)
OS3A.saFindingQueryTags q `shouldBe` Just ["high", "ad_ldap"]
let [d] = fromJust (OS3A.saFindingDocumentList f)
OS3A.saFindingDocumentFound d `shouldBe` Just True
-- the @document@ field is a JSON-stringified source, NOT a nested object
OS3A.saFindingDocumentDocument d `shouldBe` Just "{\"field1\":\"value1\"}"
describe "CreateSACorrelationRuleResponse decoding (create fixture)" $ do
let fixture =
LBS.pack
"{\"_id\":\"DxKEUIkBpIjg64IK4nXg\",\"_version\":1,\
\\"rule\":{\"name\":null,\"correlate\":[{\"index\":\"vpc_flow\",\
\\"query\":\"dstaddr:4.5.6.7\",\"category\":\"network\"}]}}"
it "decodes the documented create-correlation-rule fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.CreateSACorrelationRuleResponse
OS3A.createSACorrelationRuleResponseId decoded `shouldBe` "DxKEUIkBpIjg64IK4nXg"
OS3A.createSACorrelationRuleResponseVersion decoded `shouldBe` 1
let rule = OS3A.createSACorrelationRuleResponseRule decoded
OS3A.saCorrelationRuleName rule `shouldBe` Nothing
let [entry] = OS3A.saCorrelationRuleCorrelate rule
OS3A.saCorrelateEntryIndex entry `shouldBe` "vpc_flow"
describe "GetSACorrelationsResponse decoding (time-window fixture)" $ do
let fixture =
LBS.pack
"{\"findings\":[{\"finding1\":\"f1\",\"logType1\":\"network\",\
\\"finding2\":\"f2\",\"logType2\":\"test_windows\",\
\\"rules\":[\"nqI2TokBgL5wWFPZ6Gfu\"]}]}"
it "decodes the documented correlations fixture (camelCase logType1/2)" $ do
let Just decoded = decode fixture :: Maybe OS3A.GetSACorrelationsResponse
let [cf] = OS3A.getSACorrelationsResponseFindings decoded
OS3A.saCorrelationFindingFinding1 cf `shouldBe` Just "f1"
OS3A.saCorrelationFindingLogType1 cf `shouldBe` Just "network"
OS3A.saCorrelationFindingFinding2 cf `shouldBe` Just "f2"
OS3A.saCorrelationFindingRules cf `shouldBe` Just ["nqI2TokBgL5wWFPZ6Gfu"]
describe "FindSACorrelationResponse decoding (correlate fixture)" $ do
let fixture =
LBS.pack
"{\"findings\":[{\"finding\":\"5c661104-aaa9-484b-a91f-9cad4ae6d5f5\",\
\\"detector_type\":\"others_application\",\"score\":0.000015182109564193524}]}"
it "decodes the documented find-correlation fixture (snake_case detector_type)" $ do
let Just decoded = decode fixture :: Maybe OS3A.FindSACorrelationResponse
let [s] = OS3A.findSACorrelationResponseFindings decoded
OS3A.saCorrelationScoreFinding s `shouldBe` Just "5c661104-aaa9-484b-a91f-9cad4ae6d5f5"
OS3A.saCorrelationScoreDetectorType s `shouldBe` Just "others_application"
OS3A.saCorrelationScoreScore s `shouldSatisfy` maybe False (>= 0)
describe "SearchSACorrelationAlertsResponse decoding (list fixture)" $ do
let fixture =
LBS.pack
"{\"correlationAlerts\":[{\"correlated_finding_ids\":[\"cf1\"],\
\\"correlation_rule_id\":\"cr1\",\"correlation_rule_name\":\"rule-corr\",\
\\"user\":null,\"id\":\"ca1\",\"version\":1,\"schema_version\":1,\
\\"trigger_name\":\"trigger1\",\"state\":\"ACTIVE\",\
\\"error_message\":null,\"severity\":\"1\",\
\\"action_execution_results\":[{\"action_id\":\"act1\",\
\\"last_execution_time\":1718824628257,\"throttled_count\":0}],\
\\"start_time\":\"2024-06-19T20:37:08.257Z\",\
\\"end_time\":\"2024-06-19T20:42:08.257Z\",\"acknowledged_time\":null}],\
\\"total_alerts\":1}"
it "decodes the documented correlation-alerts fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.SearchSACorrelationAlertsResponse
OS3A.searchSACorrelationAlertsResponseTotalAlerts decoded `shouldBe` Just 1
let [ca] = OS3A.searchSACorrelationAlertsResponseCorrelationAlerts decoded
OS3A.saCorrelationAlertId ca `shouldBe` Just "ca1"
OS3A.saCorrelationAlertCorrelationRuleId ca `shouldBe` Just "cr1"
OS3A.saCorrelationAlertCorrelatedFindingIds ca `shouldBe` Just ["cf1"]
OS3A.saCorrelationAlertState ca `shouldBe` Just OS3A.SAAlertStateActive
OS3A.saCorrelationAlertSeverity ca `shouldBe` Just "1"
-- the typed action_execution_results (S4 fix: matches SAAlert's typing)
let [action] = fromJust (OS3A.saCorrelationAlertActionExecutionResults ca)
OS3A.saActionExecutionResultActionId action `shouldBe` Just "act1"
OS3A.saActionExecutionResultThrottledCount action `shouldBe` Just 0
describe "AcknowledgeSACorrelationAlerts (request + response) decoding" $ do
let reqFixture = LBS.pack "{\"alertIds\":[\"ca1\",\"ca2\"]}"
respFixture =
LBS.pack
"{\"acknowledged\":[{\"id\":\"ca1\",\"state\":\"ACTIVE\"}],\"failed\":[]}"
it "acknowledge request renders as {\"alertIds\":[...]} (camelCase, NOT alerts)" $ do
let Just decoded = decode reqFixture :: Maybe OS3A.AcknowledgeSACorrelationAlertsRequest
OS3A.acknowledgeSACorrelationAlertsRequestAlertIds decoded `shouldBe` ["ca1", "ca2"]
LBS.unpack (encode (OS3A.AcknowledgeSACorrelationAlertsRequest ["x"]))
`shouldContain` "\"alertIds\""
it "decodes the documented ack-correlation-alerts fixture (NO missing key)" $ do
let Just decoded = decode respFixture :: Maybe OS3A.AcknowledgeSACorrelationAlertsResponse
let [ca] = OS3A.acknowledgeSACorrelationAlertsResponseAcknowledged decoded
OS3A.saCorrelationAlertId ca `shouldBe` Just "ca1"
describe "SALogType / SALogTypeResponse decoding (create fixture)" $ do
let reqFixture =
LBS.pack
"{\"description\":\"custom-log-type-desc\",\"name\":\"custom-log-type4\",\
\\"source\":\"Custom\"}"
respFixture =
LBS.pack
"{\"_id\":\"m98uk4kBlb9cbROIpEj2\",\"_version\":1,\
\\"logType\":{\"name\":\"custom-log-type4\",\
\\"description\":\"custom-log-type-desc\",\"source\":\"Custom\",\
\\"tags\":{\"correlation_id\":27}}}"
it "decodes a documented log-type request body" $ do
let Just decoded = decode reqFixture :: Maybe OS3A.SALogType
OS3A.saLogTypeName decoded `shouldBe` "custom-log-type4"
OS3A.saLogTypeSource decoded `shouldBe` Just OS3A.SALogTypeSourceCustom
it "decodes the documented log-type create/update fixture (camelCase logType wrapper)" $ do
let Just decoded = decode respFixture :: Maybe OS3A.SALogTypeResponse
OS3A.saLogTypeResponseId decoded `shouldBe` "m98uk4kBlb9cbROIpEj2"
OS3A.saLogTypeResponseVersion decoded `shouldBe` 1
let lt = OS3A.saLogTypeResponseLogType decoded
OS3A.saLogTypeName lt `shouldBe` "custom-log-type4"
let tags = fromJust (OS3A.saLogTypeTags lt)
OS3A.saLogTypeTagsCorrelationId tags `shouldBe` Just 27
describe "SALogTypesSearchResponse decoding (search fixture)" $ do
let fixture =
LBS.pack
"{\"took\":3,\"timed_out\":false,\"_shards\":{\"total\":1,\
\\"successful\":1,\"skipped\":0,\"failed\":0},\
\\"hits\":{\"total\":{\"value\":26,\"relation\":\"eq\"},\
\\"max_score\":2.0,\"hits\":[{\"_index\":\".opensearch-sap-log-types-config\",\
\\"_id\":\"s3\",\"_score\":2.0,\
\\"_source\":{\"name\":\"s3\",\"description\":\"Windows logs\",\
\\"source\":\"Sigma\",\"tags\":{\"correlation_id\":21}}},{\
\\"_index\":\".opensearch-sap-log-types-config\",\"_id\":\"custom1\",\
\\"_score\":2.0,\"_source\":{\"name\":\"custom-lt\",\"description\":\"d\",\
\\"source\":\"Custom\",\"tags\":null}}]}}"
it "decodes the documented log-type search fixture (standard OS search envelope)" $ do
let Just decoded = decode fixture :: Maybe OS3A.SALogTypesSearchResponse
OS3A.saLogTypesSearchResponseTook decoded `shouldBe` Just 3
let total = fromJust (OS3A.saLogTypesSearchResponseHitsTotal decoded)
OS3A.saLogTypesTotalValue total `shouldBe` 26
OS3A.saLogTypesTotalRelation total `shouldBe` OS3A.SALogTypesTotalRelationEq
length (OS3A.saLogTypesSearchResponseHits decoded) `shouldBe` 2
let lts = OS3A.saLogTypesSearchResponseLogTypes decoded
length lts `shouldBe` 2
-- Check fields individually (the saLogTypeOther catch-all breaks structural equality)
OS3A.saLogTypeName (head lts) `shouldBe` "s3"
OS3A.saLogTypeDescription (head lts) `shouldBe` Just "Windows logs"
OS3A.saLogTypeSource (head lts) `shouldBe` Just OS3A.SALogTypeSourceSigma
let Just tags = OS3A.saLogTypeTags (head lts)
OS3A.saLogTypeTagsCorrelationId tags `shouldBe` Just 21
it "tolerates null tags on a custom log type" $ do
let Just decoded = decode fixture :: Maybe OS3A.SALogTypesSearchResponse
lts = OS3A.saLogTypesSearchResponseLogTypes decoded
OS3A.saLogTypeTags (lts !! 1) `shouldBe` Nothing
describe "DeleteSALogTypeResponse decoding (delete fixture)" $ do
let fixture = LBS.pack "{\"_id\":\"m98uk4kBlb9cbROIpEj2\",\"_version\":1}"
it "decodes the documented delete-log-type fixture" $ do
let Just decoded = decode fixture :: Maybe OS3A.DeleteSALogTypeResponse
OS3A.deleteSALogTypeResponseId decoded `shouldBe` "m98uk4kBlb9cbROIpEj2"
OS3A.deleteSALogTypeResponseVersion decoded `shouldBe` 1
describe "Alerts/Findings/Correlation/LogType request rendering" $ do
it "CreateSACorrelationRuleRequest round-trips through encode/decode" $ do
let req = OS3A.CreateSACorrelationRuleRequest [OS3A.SACorrelateEntry "i" "q" "c"]
decode (encode req) `shouldBe` Just req
it "SACorrelateEntry round-trips" $ do
let e = OS3A.SACorrelateEntry "vpc_flow" "dstaddr:1.2.3.4" "network"
decode (encode e) `shouldBe` Just e
it "SALogType renders with documented fields (omits Nothing tags)" $ do
let req = OS3A.SALogType "n" (Just "d") (Just OS3A.SALogTypeSourceCustom) Nothing Null
body = encode req
LBS.unpack body `shouldContain` "\"name\""
LBS.unpack body `shouldContain` "\"Custom\""
LBS.unpack body `shouldNotContain` "\"tags\""
it "SALogType preserves an unknown forward-compat key via the catch-all" $ do
let lbs = LBS.pack "{\"name\":\"n\",\"future_key\":42}"
Just decoded = decode lbs :: Maybe OS3A.SALogType
OS3A.saLogTypeName decoded `shouldBe` "n"
LBS.unpack (encode decoded) `shouldContain` "\"future_key\""
it "defaultSALogTypeSearchQuery renders as {\"query\":{\"match_all\":{}}}" $ do
LBS.unpack (encode OS3A.defaultSALogTypeSearchQuery) `shouldContain` "match_all"
it "AcknowledgeSADetectorAlertsRequest round-trips" $ do
let req = OS3A.AcknowledgeSADetectorAlertsRequest ["a", "b"]
decode (encode req) `shouldBe` Just req
it "AcknowledgeSACorrelationAlertsRequest round-trips (camelCase alertIds)" $ do
let req = OS3A.AcknowledgeSACorrelationAlertsRequest ["a", "b"]
decode (encode req) `shouldBe` Just req
LBS.unpack (encode req) `shouldContain` "\"alertIds\""
describe "Alerts/Findings/Correlation/LogType live round-trip (gated)" $ do
os2It <- runIO os2OnlyIT
os3It <- runIO os3OnlyIT
os2It "OS2: getSAAlerts/Findings/Correlations/LogTypes stay pending (no live fixtures yet)" $
pendingWith
"Live round-trips for the alerts/findings/correlation/log-type \
\endpoints are not yet wired (require a Security Analytics \
\detector with findings/alerts set up). The endpoint shapes and \
\wire formats are pinned by the pure tests above; live \
\verification tracked as a follow-up."
os3It "OS3: getSAAlerts/Findings/Correlations/LogTypes stay pending (no live fixtures yet)" $
pendingWith
"Live round-trips for the alerts/findings/correlation/log-type \
\endpoints are not yet wired (require a Security Analytics \
\detector with findings/alerts set up). The endpoint shapes and \
\wire formats are pinned by the pure tests above; live \
\verification tracked as a follow-up."
-- =========================================================== --
-- Live round-trip: gated per-OS with os{n}OnlyIT (the --
-- 'Test.NeuralClearCacheSpec' pattern). Wire shapes --
-- live-verified on OS 2.19.5 and 3.7.0 (bead bloodhound-z5j). --
-- --
-- The DELETE-detector response shape is version-dependent: OS --
-- 2.x/3.x return only @{_id,_version}@. The detector input --
-- requires a pre-existing index, so @windows@ is created in --
-- setup. --
-- =========================================================== --
describe "deleteSADetector live round-trip (version-dependent shape)" $ do
os2It <- runIO os2OnlyIT
os3It <- runIO os3OnlyIT
let ensureWindowsIndex =
tryEsError
( void $
performBHRequest $
createIndex
(IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits)
[qqIndexName|windows|]
)
os2It "OS2: create -> delete returns the minimal {_id,_version} body" $
withTestEnv $ do
_ <- ensureWindowsIndex
resp <- OS2.Client.createSADetector os2SampleDetector
let did = OS2A.DetectorId (OS2A.saDetectorResponseId resp)
del <- OS2.Client.deleteSADetector did
liftIO $ do
OS2A.deleteSADetectorResponseId del `shouldBe` OS2A.saDetectorResponseId resp
OS2A.deleteSADetectorResponseVersion del `shouldSatisfy` (>= 1)
OS2A.deleteSADetectorResponseIndex del `shouldBe` Nothing
OS2A.deleteSADetectorResponseShards del `shouldBe` Nothing
OS2A.deleteSADetectorResponseResult del `shouldBe` Nothing
os3It "OS3: create -> delete returns the minimal {_id,_version} body" $
withTestEnv $ do
_ <- ensureWindowsIndex
resp <- OS3.Client.createSADetector os3SampleDetector
let did = OS3A.DetectorId (OS3A.saDetectorResponseId resp)
del <- OS3.Client.deleteSADetector did
liftIO $ do
OS3A.deleteSADetectorResponseId del `shouldBe` OS3A.saDetectorResponseId resp
OS3A.deleteSADetectorResponseVersion del `shouldSatisfy` (>= 1)
OS3A.deleteSADetectorResponseIndex del `shouldBe` Nothing
OS3A.deleteSADetectorResponseShards del `shouldBe` Nothing
OS3A.deleteSADetectorResponseResult del `shouldBe` Nothing
-- ---------------------------------------------------------------- --
-- Sample fixtures (structurally identical across OS2/OS3; --
-- nominally distinct types, so one per backend). --
-- ---------------------------------------------------------------- --
os3SampleDetector :: OS3A.SADetector
os3SampleDetector =
OS3A.SADetector
{ OS3A.saDetectorName = "test-detector",
OS3A.saDetectorType = OS3A.SADetectorTypeWindows,
OS3A.saDetectorKind = Just "detector",
OS3A.saDetectorEnabled = Just True,
OS3A.saDetectorSchedule =
OS3A.PeriodSASchedule (OS3A.SASchedulePeriod 1 "MINUTES"),
OS3A.saDetectorInputs =
[ OS3A.SADetectorInput
{ OS3A.saDetectorInputDescription = Just "windows detector for security analytics",
OS3A.saDetectorInputIndices = ["windows"],
OS3A.saDetectorInputCustomRules = [],
OS3A.saDetectorInputPrePackagedRules =
[OS3A.SARuleReference "06724a9a-52fc-11ed-bdc3-0242ac120002"],
OS3A.saDetectorInputOther = Null
}
],
OS3A.saDetectorTriggers =
[ OS3A.SATrigger
{ OS3A.saTriggerId = Just "test-trigger-id",
OS3A.saTriggerName = "test-trigger",
OS3A.saTriggerSeverity = "1",
OS3A.saTriggerIds = [],
OS3A.saTriggerTypes = [],
OS3A.saTriggerTags = ["attack.defense_evasion"],
OS3A.saTriggerSevLevels = [],
OS3A.saTriggerActions = [],
OS3A.saTriggerOther = Null
}
],
OS3A.saDetectorOther = Null
}
os2SampleDetector :: OS2A.SADetector
os2SampleDetector =
OS2A.SADetector
{ OS2A.saDetectorName = "test-detector",
OS2A.saDetectorType = OS2A.SADetectorTypeWindows,
OS2A.saDetectorKind = Just "detector",
OS2A.saDetectorEnabled = Just True,
OS2A.saDetectorSchedule =
OS2A.PeriodSASchedule (OS2A.SASchedulePeriod 1 "MINUTES"),
OS2A.saDetectorInputs =
[ OS2A.SADetectorInput
{ OS2A.saDetectorInputDescription = Just "windows detector for security analytics",
OS2A.saDetectorInputIndices = ["windows"],
OS2A.saDetectorInputCustomRules = [],
OS2A.saDetectorInputPrePackagedRules =
[OS2A.SARuleReference "06724a9a-52fc-11ed-bdc3-0242ac120002"],
OS2A.saDetectorInputOther = Null
}
],
OS2A.saDetectorTriggers =
[ OS2A.SATrigger
{ OS2A.saTriggerId = Just "test-trigger-id",
OS2A.saTriggerName = "test-trigger",
OS2A.saTriggerSeverity = "1",
OS2A.saTriggerIds = [],
OS2A.saTriggerTypes = [],
OS2A.saTriggerTags = ["attack.defense_evasion"],
OS2A.saTriggerSevLevels = [],
OS2A.saTriggerActions = [],
OS2A.saTriggerOther = Null
}
],
OS2A.saDetectorOther = Null
}
-- | Helper assertion: the given wire string decodes to the expected
-- 'SADetectorType'.
decodesDetectorTypeTo :: Text -> OS3A.SADetectorType -> Expectation
decodesDetectorTypeTo wire expected =
decode (encode wire) `shouldBe` Just expected
-- | A sample Sigma rule document (raw YAML text), verbatim from the
-- OS docs create-custom-rule example. Used to assert that the rule
-- CRUD request builders forward the body verbatim (not JSON-encoded).
sampleSigmaYaml :: Text
sampleSigmaYaml =
"title: Moriya Rootkit\n\
\id: 25b9c01c-350d-4b95-bed1-836d04a4f324\n\
\description: Detects the use of Moriya rootkit\n\
\status: experimental\n\
\author: Bhabesh Raj\n\
\logsource:\n\
\ product: windows\n\
\ service: system\n\
\detection:\n\
\ selection:\n\
\ Provider_Name: 'Service Control Manager'\n\
\ EventID: 7045\n\
\ ServiceName: ZzNetSvc\n\
\ condition: selection\n\
\level: critical\n\
\falsepositives:\n\
\ - Unknown"