Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expanding coverage and other metrics in summary.yml #257

Merged
merged 22 commits into from
Aug 27, 2024

Conversation

cmcginley-splunk
Copy link
Collaborator

@cmcginley-splunk cmcginley-splunk commented Aug 23, 2024

Context

  • I want to gather more granular metrics about ESCU as a whole
  • e.g.
    • How many detections have tests?
    • How many detections are manually tested?
    • How many production vs. experimental vs deprecated detections do we have?
    • How many detections do we have by datasource or by type (e.g. Anomaly)?
  • Ultimately, this data will be sucked into internal tooling for visualization

Code changes

  • Added notion of ManualTest
  • Expanded the data logged to summary.yml and the CLI
  • Added status at the detection level (computed property as an aggregate of the tests beneath it)
  • Some classes of detections are now "skipped" at model build time, and they are not filtered out of the list of detections to test (we want them to appear in the summary)
  • No longer generating placeholder tests for manually tested detections (not needed)
  • Changes to validation of tests and derivation of test groups and integration tests

Testing

  • Tested in internal pipeline on develop of security_content
  • 100% success rate
  • Sample YAML (from a smaller test run) below

Sample summary.yml

summary:
  mode: selected
  enable_integration_testing: true
  success: true
  total_detections: 8
  total_tested_detections: 1
  total_pass: 1
  total_fail: 0
  total_skipped: 7
  total_untested: 0
  total_production: 4
  total_experimental: 2
  total_deprecated: 2
  total_manual: 2
  total_other_skips: 1
  success_rate: 100.0%
tested_detections:
- name: Kubernetes Falco Shell Spawned
  type: Anomaly
  production_status: production
  status: pass
  source_category: cloud
  data_source:
  - Kubernetes Falco
  search: '`kube_container_falco` "A shell was spawned in a container" |  fillnull
    | stats count by container_image container_image_tag container_name parent proc_exepath
    process user | `kubernetes_falco_shell_spawned_filter`'
  file_path: detections/cloud/kubernetes_falco_shell_spawned.yml
  manual_test: null
  success: true
  tests:
  - name: True Positive Test
    test_type: unit
    success: true
    message: TEST PASSED
    exception: null
    status: pass
    duration: 5.58
    wait_duration: null
    resultCount: '1'
    runDuration: '0.720'
  - name: True Positive Test
    test_type: integration
    success: true
    message: 'TEST PASSED: Expected risk and/or notable events were created for: ESCU
      - Kubernetes Falco Shell Spawned - Rule'
    exception: null
    status: pass
    duration: 72.2
    wait_duration: 60
    resultCount: null
    runDuration: null
skipped_detections:
- name: DLLHost with no Command Line Arguments with Network
  type: TTP
  production_status: experimental
  status: skip
  source_category: endpoint
  data_source:
  - Sysmon EventID 1 AND Sysmon EventID 3
  search: '| tstats `security_content_summariesonly` count min(_time) as firstTime
    max(_time) as lastTime FROM datamodel=Endpoint.Processes where Processes.process_name=dllhost.exe
    Processes.action!="blocked" by host _time span=1h Processes.process_id Processes.process_name
    Processes.dest Processes.process_path Processes.process Processes.parent_process_name
    Processes.parent_process | `drop_dm_object_name(Processes)` | `security_content_ctime(firstTime)`
    | `security_content_ctime(lastTime)` | regex process="(?i)(dllhost\.exe.{0,4}$)"
    | rename dest as src | join host process_id [| tstats `security_content_summariesonly`
    count latest(All_Traffic.dest) as dest latest(All_Traffic.dest_ip) as dest_ip
    latest(All_Traffic.dest_port) as dest_port FROM datamodel=Network_Traffic.All_Traffic
    where All_Traffic.dest_port != 0 by host All_Traffic.process_id | `drop_dm_object_name(All_Traffic)`]
    | `dllhost_with_no_command_line_arguments_with_network_filter`'
  file_path: detections/endpoint/dllhost_with_no_command_line_arguments_with_network.yml
  manual_test: null
  success: true
  tests:
  - name: True Positive Test
    test_type: unit
    success: true
    message: 'TEST SKIPPED: Detection is non-production (experimental)'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
  - name: True Positive Test
    test_type: integration
    success: true
    message: 'TEST SKIPPED: Detection is non-production (experimental)'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
- name: Okta Account Lockout Events
  type: Anomaly
  production_status: deprecated
  status: skip
  source_category: deprecated
  data_source: []
  search: '`okta` eventType IN (user.account.lock.limit,user.account.lock) | rename
    client.geographicalContext.country as country, client.geographicalContext.state
    as state, client.geographicalContext.city as city | stats count min(_time) as
    firstTime max(_time) as lastTime values(src_user) by displayMessage, country,
    state, city, src_ip | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)`
    | `okta_account_lockout_events_filter`'
  file_path: detections/deprecated/okta_account_lockout_events.yml
  manual_test: null
  success: true
  tests:
  - name: True Positive Test
    test_type: unit
    success: true
    message: 'TEST SKIPPED: Detection is non-production (deprecated)'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
  - name: True Positive Test
    test_type: integration
    success: true
    message: 'TEST SKIPPED: Detection is non-production (deprecated)'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
- name: Risk Rule for Dev Sec Ops by Repository
  type: Correlation
  production_status: production
  status: skip
  source_category: cloud
  data_source: []
  search: '| tstats `security_content_summariesonly` min(_time) as firstTime max(_time)
    as lastTime sum(All_Risk.calculated_risk_score) as sum_risk_score, values(All_Risk.annotations.mitre_attack.mitre_tactic)
    as annotations.mitre_attack.mitre_tactic, values(All_Risk.annotations.mitre_attack.mitre_technique_id)
    as annotations.mitre_attack.mitre_technique_id, dc(All_Risk.annotations.mitre_attack.mitre_technique_id)
    as mitre_technique_id_count values(source) as source, dc(source) as source_count
    from datamodel=Risk.All_Risk where All_Risk.analyticstories="Dev Sec Ops" All_Risk.risk_object_type
    = "other" by All_Risk.risk_object All_Risk.risk_object_type All_Risk.annotations.mitre_attack.mitre_tactic
    | `drop_dm_object_name(All_Risk)` | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)`
    | where source_count > 3 and sum_risk_score > 100 | `risk_rule_for_dev_sec_ops_by_repository_filter`'
  file_path: detections/cloud/risk_rule_for_dev_sec_ops_by_repository.yml
  manual_test: null
  success: true
  tests:
  - name: True Positive Test
    test_type: unit
    success: true
    message: 'TEST SKIPPED: Detection type Correlation cannot be tested by contentctl'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
  - name: True Positive Test
    test_type: integration
    success: true
    message: 'TEST SKIPPED: Detection type Correlation cannot be tested by contentctl'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
- name: Splunk Code Injection via custom dashboard leading to RCE
  type: Hunting
  production_status: experimental
  status: skip
  source_category: application
  data_source: []
  search: '`splunkd_ui` uri_path=*/data/ui/views/* OR uri_path=*saved/searches/* |
    dedup uri_path | eval URL=urldecode("uri_path")| rex field=URL "\/saved\/searches\/(?<NAME>[^\/]*)"
    | rex field=URL "\/data\/ui\/views\/(?<NAME1>[^\/]*)" | eval NAME=NAME."( Saved
    Search )",NAME1=NAME1."( Dashboard )" | eval NAME=coalesce(NAME,NAME1) | eval
    STATUS=case(match(status,"2\d+"),"SUCCESS",match(status,"3\d+"),"REDIRECTION",match(status,"4\d+")
    OR match(status,"5\d+"),"ERROR") | stats list(NAME) as DASHBOARD_TITLE,list(method)
    as HTTP_METHOD,list(status) as Status_Code,list(STATUS) as STATUS by user | rename
    user as User | `splunk_code_injection_via_custom_dashboard_leading_to_rce_filter`'
  file_path: detections/application/splunk_code_injection_via_custom_dashboard_leading_to_rce.yml
  manual_test: null
  success: true
  tests:
  - name: True Positive Test
    test_type: unit
    success: true
    message: 'TEST SKIPPED: Detection is non-production (experimental)'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
  - name: True Positive Test
    test_type: integration
    success: true
    message: 'TEST SKIPPED: Detection is non-production (experimental)'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
- name: Splunk Command and Scripting Interpreter Risky SPL MLTK
  type: Anomaly
  production_status: production
  status: skip
  source_category: application
  data_source:
  - Splunk
  search: '| tstats sum(Search_Activity.total_run_time) AS run_time, values(Search_Activity.search)
    as searches, count FROM datamodel=Splunk_Audit.Search_Activity WHERE (Search_Activity.user!="")
    AND (Search_Activity.total_run_time>1) AND (earliest=-1h@h latest=now) AND (Search_Activity.search
    IN ("*| runshellscript *", "*| collect *","*| delete *", "*| fit *", "*| outputcsv
    *", "*| outputlookup *", "*| run *", "*| script *", "*| sendalert *", "*| sendemail
    *", "*| tscolle*")) AND (Search_Activity.search_type=adhoc) AND (Search_Activity.user!=splunk-system-user)
    BY _time, Search_Activity.user span=1h | apply risky_command_abuse | fields _time,
    Search_Activity.user, searches, run_time, IsOutlier(run_time) | rename IsOutlier(run_time)
    as isOutlier, _time as timestamp | where isOutlier>0.5 | `splunk_command_and_scripting_interpreter_risky_spl_mltk_filter`'
  file_path: detections/application/splunk_command_and_scripting_interpreter_risky_spl_mltk.yml
  manual_test: This search has a baseline and timestamps hard coded into the search.
  success: true
  tests:
  - name: True Positive Test
    test_type: manual
    success: true
    message: 'TEST SKIPPED (MANUAL): Detection marked as ''manual_test'' with explanation:
      This search has a baseline and timestamps hard coded into the search.'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
- name: Kubernetes Azure detect sensitive object access
  type: Hunting
  production_status: deprecated
  status: skip
  source_category: deprecated
  data_source: []
  search: '`kubernetes_azure` category=kube-audit | spath input=properties.log| search
    objectRef.resource=secrets OR configmaps user.username=system.anonymous OR annotations.authorization.k8s.io/decision=allow  |table
    user.username user.groups{} objectRef.resource objectRef.namespace objectRef.name
    annotations.authorization.k8s.io/reason |dedup user.username user.groups{} |`kubernetes_azure_detect_sensitive_object_access_filter`'
  file_path: detections/deprecated/kubernetes_azure_detect_sensitive_object_access.yml
  manual_test: null
  success: true
  tests: []
- name: Splunk Information Disclosure in Splunk Add-on Builder
  type: Hunting
  production_status: production
  status: skip
  source_category: application
  data_source:
  - Splunk
  search: '| rest /services/apps/local | search disabled=0 core=0 label="Splunk Add-on
    Builder" | dedup label | search version < 4.1.4 | eval WarningMessage="Splunk
    Add-on Builder Versions older than v4.1.4 contain a critical vulnerability. Update
    to Splunk Add-on Builder v4.1.4 or higher immediately. For more information about
    this vulnerability, please refer to https://advisory.splunk.com/advisories/SVD-2024-0111"
    | table label version WarningMessage | `splunk_information_disclosure_in_splunk_add_on_builder_filter`'
  file_path: detections/application/splunk_information_disclosure_in_splunk_add_on_builder.yml
  manual_test: This search uses a REST call against a running Splunk instance to fetch
    the versions of installed apps. It cannot be replicated with a normal test or
    attack data.
  success: true
  tests: []
untested_detections: []
percent_complete: UKNOWN

if len(self.tests) == 0:
return False
return True
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notable change. I think this fine, as we are enforcing the existence of at least one test where it matters (at model build, for all but a handful of edge cases)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree. I will close this out, but this is where I think we COULD do with things like defining different Classes for each type: or status:. That's a huge rework, though, and I think this is a good change for what we have.
The comment above this statement

        # If no tests are defined, we consider it a success for the detection (this detection was
        # skipped for testing). Note that the existence of at least one test is enforced by Pydantic
        # validation already, with a few specific exceptions

Also gives good insight into the logic

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate a little on what you mean by different Classes?

Copy link
Contributor

@pyth0n1c pyth0n1c Aug 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that a Production Detection, an Experimental Detection, and a Deprecated Detection all have different validations/requirements.
Similarly, TTP, Anomaly, Hunting, and Correlation may have different requirements and behaviors.
Having all these requirements and validations and behaviors expressed via complicated IF/ELSE logic is not as clean as making Detection an abstract class (and making Production/Experimental/Deprecated and/or TTP/Anomaly/Hunting/Correlation non-abstract classes that define behavior specific to their types).

Let me know if we can mark this is RESOLVED

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhhh I see. I like that idea a lot! And I think it makes a lot of sense to compartmentalize validation requirements like that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this unresolved intentionally to look back to later (not blocking this PR)

@cmcginley-splunk cmcginley-splunk changed the title Feature/coverage report Expanding coverage and other metrics in summary.yml Aug 23, 2024
@cmcginley-splunk cmcginley-splunk marked this pull request as ready for review August 23, 2024 16:34
Copy link
Contributor

@pyth0n1c pyth0n1c left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes look great, but I owe a followup PR with suggestions. As part of the tests, I ran a "test" of 100 deprecated and 1 production search. Here is a subset of the summary.yml file, which looks great and now captures much more (+ more useful) information. Note the presence of a production search, a deprecated search with a test (which is not RUN because it is status: deprecated) and a deprecated search with NO tests:

summary:
  mode: selected
  enable_integration_testing: false
  success: true
  total_detections: 101
  total_tested_detections: 1
  total_pass: 1
  total_fail: 0
  total_skipped: 100
  total_untested: 0
  total_production: 1
  total_experimental: 0
  total_deprecated: 100
  total_manual: 0
  total_other_skips: 0
  success_rate: 100.0%
tested_detections:
- name: O365 ZAP Activity Detection
  type: Anomaly
  production_status: production
  status: pass
  source_category: cloud
  data_source:
  - O365 Universal Audit Log
  search: '`o365_management_activity` Workload=SecurityComplianceCenter Operation=AlertEntityGenerated
    Name="*messages containing malicious*" | fromjson Data | stats count min(_time)
    as firstTime max(_time) as lastTime values(zu) as url values(zfn) as file_name
    values(ms) as subject values(ttr) as result values(tsd) as src_user by AlertId,trc,Operation,Name
    | rename Name as signature, AlertId as signature_id, trc as user | eval action
    = CASE(match(result,"Success"), "blocked", true(),"allowed"), url = split(url,";")
    | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)` | `o365_zap_activity_detection_filter`'
  file_path: detections/cloud/o365_zap_activity_detection.yml
  manual_test: null
  success: true
  tests:
  - name: True Positive Test
    test_type: unit
    success: true
    message: TEST PASSED
    exception: null
    status: pass
    duration: 7.94
    wait_duration: null
    resultCount: '3'
    runDuration: '2.082'
  - name: True Positive Test
    test_type: integration
    success: true
    message: 'TEST SKIPPED: Skipping all integration tests'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
skipped_detections:
- name: ASL AWS CreateAccessKey
  type: Hunting
  production_status: deprecated
  status: skip
  source_category: deprecated
  data_source: []
  search: '`amazon_security_lake` api.operation=CreateAccessKey http_request.user_agent!=console.amazonaws.com
    api.response.error=null | rename unmapped{}.key as unmapped_key , unmapped{}.value
    as unmapped_value | eval keyjoin=mvzip(unmapped_key,unmapped_value) | mvexpand
    keyjoin | rex field=keyjoin "^(?<key>[^,]+),(?<value>.*)$" | eval {key} = value
    | search responseElements.accessKey.userName = * | rename identity.user.name as
    identity_user_name, responseElements.accessKey.userName as responseElements_accessKey_userName
    | eval match=if(identity_user_name=responseElements_accessKey_userName,1,0) |
    search match=0 | rename identity_user_name as identity.user.name , responseElements_accessKey_userName
    as responseElements.accessKey.userName | stats count min(_time) as firstTime max(_time)
    as lastTime by responseElements.accessKey.userName api.operation api.service.name
    identity.user.account_uid identity.user.credential_uid identity.user.name identity.user.type
    identity.user.uid identity.user.uuid http_request.user_agent src_endpoint.ip |
    `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)` |`asl_aws_createaccesskey_filter`'
  file_path: detections/deprecated/asl_aws_createaccesskey.yml
  manual_test: null
  success: true
  tests:
  - name: True Positive Test
    test_type: unit
    success: true
    message: 'TEST SKIPPED: Detection is non-production (deprecated)'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
  - name: True Positive Test
    test_type: integration
    success: true
    message: 'TEST SKIPPED: Skipping all integration tests'
    exception: null
    status: skip
    duration: 0
    wait_duration: null
    resultCount: null
    runDuration: null
- name: Windows hosts file modification
  type: TTP
  production_status: deprecated
  status: skip
  source_category: deprecated
  data_source:
  - Sysmon EventID 11
  search: '| tstats `security_content_summariesonly` count min(_time) as firstTime
    max(_time) as lastTime FROM datamodel=Endpoint.Filesystem  by Filesystem.file_name
    Filesystem.file_path Filesystem.dest | `security_content_ctime(lastTime)` | `security_content_ctime(firstTime)`
    | search Filesystem.file_name=hosts AND Filesystem.file_path=*Windows\\System32\\*
    | `drop_dm_object_name(Filesystem)` | `windows_hosts_file_modification_filter`'
  file_path: detections/deprecated/windows_hosts_file_modification.yml
  manual_test: null
  success: true
  tests: []

recommendations
filtering logic to the original
location. Added logic
to throw an error and description
for why baselines throw an error
right now.
to test a production search with
Baseline(s) that is not marked
as manual_test
@cmcginley-splunk
Copy link
Collaborator Author

We should add a validation on manual_test which, if non-null, requires a length greater than 0.

@cmcginley-splunk
Copy link
Collaborator Author

We should add a validation on manual_test which, if non-null, requires a length greater than 0.

#268

@cmcginley-splunk
Copy link
Collaborator Author

Final test went well; 2 attack data download errors but everything else looks good!

@cmcginley-splunk cmcginley-splunk merged commit 90f1b91 into main Aug 27, 2024
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants