├── .gitignore ├── LICENSE ├── README.md ├── actions-template.yml ├── actions ├── 01-Sentinel │ ├── sen_expire_sessions.yml │ ├── sen_get_aad_device_registration.yml │ ├── sen_get_aad_group_create.yml │ ├── sen_get_aad_user_addedtogroup.yml │ ├── sen_get_aad_user_creations.yml │ ├── sen_get_aad_user_delete.yml │ ├── sen_get_aad_user_removedfromgroup.yml │ ├── sen_get_ad_group_additions.yml │ ├── sen_get_ad_group_removal.yml │ ├── sen_get_ad_user_creations.yml │ ├── sen_get_az_role_assignments.yml │ ├── sen_get_host_alerts.yml │ ├── sen_get_local_admin_added.yml │ ├── sen_get_user_alerts.yml │ ├── sen_get_user_mfa_updates.yml │ └── sen_new_sessions.yml ├── 02-MDE │ ├── mde_exploitable_hosts.yml │ ├── mde_new_sessions.yml │ ├── mde_publicly_exposed_machines.yml │ └── mde_update_active_sessions.yml ├── 03-Splunk │ └── spl_new_sessions.yml ├── 04-MSgraph │ ├── graph_eligible_role_assignments.yml │ ├── msgraph_dynamicgroups.yml │ ├── msgraph_mfa.yml │ └── msgraph_oauthconsent.yml ├── 05-LogScale │ └── fls_new_sessions.yml ├── 06-Elastic │ └── elk_new_sessions.yml ├── 08-HTTP │ └── http_entra_roles.yml ├── 09_Bloodhound │ ├── bh_azure_resource_owner_list.yml │ ├── bh_kerberoastable_users.yml │ └── bh_tier0_users.yml ├── 10-Neo4j │ ├── n4j_0_cln_remove_exploitable.yml │ ├── n4j_0_cln_remove_exposed.yml │ ├── n4j_0_cln_remove_mfa_device_sharing.yml │ ├── n4j_0_cln_remove_mfa_email_sharing.yml │ ├── n4j_0_cln_remove_mfa_phone_sharing.yml │ ├── n4j_0_cln_remove_older_sessions.yml │ ├── n4j_0_cln_remove_owned.yml │ ├── n4j_1_edge_add_mfa_authdevice_sharing.yml │ ├── n4j_1_edge_add_mfa_email_sharing.yml │ ├── n4j_1_edge_add_mfa_phone_sharing.yml │ ├── n4j_aad_user_to_high_value.yml │ ├── n4j_aad_user_to_managed_identitiy.yml │ ├── n4j_actionable_stats.yml │ ├── n4j_ad_chokepoints.yml │ ├── n4j_ad_kerberoastable_users.yml │ ├── n4j_ad_unconstrained_delegation.yml │ ├── n4j_ad_user_paths_to_da.yml │ ├── n4j_ad_user_to_high_value.yml │ ├── n4j_ad_user_with_password_in_descr.yml │ ├── n4j_azure_resource_owner_list.yml │ ├── n4j_azure_subscription_owners.yml │ ├── n4j_azure_tier0_or_tier1_assigned_roles.yml │ ├── n4j_azure_vms_with_managed_identity.yml │ ├── n4j_db_stats.yml │ ├── n4j_domain_admin_session_on_non_dc.yml │ ├── n4j_domaincontrollers.yml │ ├── n4j_exploitable_device_to_high_value.yml │ ├── n4j_exposed_device_to_high_value.yml │ ├── n4j_external_azusers_high_priv.yml │ ├── n4j_external_serviceprincipal_high_priv.yml │ ├── n4j_highvalue_resource_with_alerts.yml │ ├── n4j_kerberoastable_user_with_session.yml │ ├── n4j_long_inactive_user_with_session.yml │ ├── n4j_mfa_device_sharing.yml │ ├── n4j_mfa_email_sharing.yml │ ├── n4j_mfa_phone_sharing.yml │ ├── n4j_owned_device_to_high_value.yml │ ├── n4j_owned_to_keyvault.yml │ ├── n4j_owned_user_to_high_value.yml │ ├── n4j_sensitive_resource_with_alerts.yml │ └── n4j_user_session_with_additional_admin_session.yml ├── 11-N4J-Reporting │ ├── n4j-report-1_domainadmins.yml │ ├── n4j-report-AAD_Access_to_highvalue_percentages.yml │ ├── n4j-report-AD_Access_to_highvalue_percentages.yml │ ├── n4j-report-MFA_Device_sharing.yml │ ├── n4j-report-MFA_Email_sharing.yml │ └── n4j-report-MFA_Phone_sharing.yml └── action_schema.json ├── cmd ├── adxinit.go ├── getcreds.go ├── getkeyvaultsecrets.go ├── logging.go ├── printtable.go └── setcolor.go ├── config.yml-sample ├── docs ├── FEATURE_IDEAS.md ├── falconhound-logo.png └── required_permissions.md ├── go.mod ├── go.sum ├── go.work.sum ├── input_processor ├── base.go ├── bloodhound.go ├── elastic.go ├── http.go ├── input_cmd │ ├── graph_getdynamicgroups.go │ ├── graph_getmfa.go │ └── graph_getoauthconsent.go ├── logscale.go ├── mde.go ├── msgraph.go ├── msgraphapi.go ├── neo4j.go ├── sentinel.go └── splunk.go ├── internal ├── banner.go ├── bh_types.go ├── credentials.go ├── query_results.go └── version.go ├── main.go └── output_processor ├── adx.go ├── base.go ├── bloodhound.go ├── bloodhound_session.go ├── csv.go ├── html.go ├── json.go ├── limacharlie.go ├── markdown.go ├── neo4j.go ├── sentinel.go ├── splunk.go └── watchlist.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.out 4 | .DS_Store 5 | *.log 6 | output/*.csv 7 | bin/* 8 | configs/* 9 | .vscode/* 10 | client_actions/*/* 11 | falconhound 12 | dist/* 13 | .goreleaser.yaml 14 | cache.db 15 | .idea/* 16 | report/*/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, FalconForce 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /actions-template.yml: -------------------------------------------------------------------------------- 1 | Name: # Choose a name that describes the action 2 | ID: # Unique ID (short version of the name, no spaces, will end up in the logs) 3 | Description: # Short description (one-liner) 4 | Author: FalconForce # Optional: Author of the action 5 | Version: '1.0' # Optional: Version of the action 6 | Info: |- # Optional: Additional information about the action 7 | Active: true # Enable to run this action 8 | Debug: true # Enable to see verbose results on the console 9 | SourcePlatform: MDE # Supported sources; Sentinel, Neo4j, MDE, Graph 10 | Query: | # Query to run against the source platform 11 | -query here- 12 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Splunk) 13 | - Name: CSV 14 | Enabled: true 15 | Path: output/get_sessions_mde.csv 16 | - Name: Sentinel 17 | Enabled: true 18 | - Name: Splunk 19 | Enabled: true 20 | - Name: Neo4j 21 | Enabled: true 22 | Query: | 23 | MATCH (x:Computer {name:$device_name}) SET c.exploitable = true, c.exploits = $cve_ids 24 | Parameters: 25 | device_name: DeviceName 26 | cve_ids: CveIds 27 | - Name: Watchlist 28 | Enabled: true 29 | WatchlistName: FH_MDE_Exploitable_Machines 30 | DisplayName: MDE Exploitable Machines 31 | SearchKey: DeviceName 32 | Overwrite: true # Overwrite the watchlist with the query results, when false it will append the results to the watchlist 33 | - Name: ADX 34 | Enabled: true 35 | Table: FalconHound 36 | BatchSize: 1000 # Number of records to push to ADX in one batch, these will show up in the ADX table as 1 row with an array of values 37 | - Name: Markdown 38 | Enabled: true 39 | Path: reports/{{date}}/get_sessions_mde.md 40 | 41 | 42 | -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_expire_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: Get logoff events to expire sessions 2 | ID: SEN_Expire_Sessions_by_Logoff 3 | Description: Gets all logoff events from Sentinel and syncs them to Neo4j 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Gets all logoff events from Sentinel, filters out non-user logons, and creates a relationship between the computer and the user in Neo4j, 8 | with the timestamp of the logoff event. It wil also remove the relationship between the computer and the user for the HasSession relationship. 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | 13 | let timeframe = 15m; 14 | SecurityEvent 15 | | where ingestion_time() >= ago(timeframe) 16 | | where EventID in (4647) 17 | | where toupper(AccountType) == 'USER' 18 | | where TargetDomainName !in ('NT AUTHORITY','NT Service') 19 | | where isnotempty(TargetUserSid) 20 | | summarize Timestamp=arg_min(TimeGenerated,0) by Computer,TargetUserSid,TargetUserName,TargetDomainName 21 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 22 | - Name: CSV 23 | Enabled: false 24 | Path: output/get_sessions_sentinel.csv 25 | # - Name: Sentinel 26 | # Enabled: true 27 | - Name: Neo4j 28 | Enabled: true 29 | Query: | 30 | MATCH (x:Computer {name:$Computer})-[R:HasSession]- (y:User {objectid:$TargetUserSid}) 31 | MATCH (x:Computer {name:$Computer}) MATCH (y:User {objectid:$TargetUserSid}) MERGE (x)-[r:HadSession]->(y) SET r.till=$Timestamp SET r.source='falconhound' DELETE R 32 | Parameters: 33 | Computer: Computer 34 | TargetUserSid: TargetUserSid 35 | Timestamp: Timestamp 36 | -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_aad_device_registration.yml: -------------------------------------------------------------------------------- 1 | Name: New device registered in EntraID 2 | ID: SEN_AAD_Device_Registration 3 | Description: Gets all device registrations and their owners 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where ingestion_time() >= ago(timeframe) 14 | | where OperationName contains "Register device" 15 | | mv-expand TargetResources 16 | | extend AdditionalDetails=parse_json(AdditionalDetails), InitiatedBy=parse_json(InitiatedBy) 17 | | mv-expand AdditionalDetails 18 | | where AdditionalDetails contains "Device Id" 19 | | extend deviceId=AdditionalDetails.value, ownerId=InitiatedBy.user.id 20 | | project TimeGenerated,deviceId, ownerId, TenantId 21 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 22 | - Name: Neo4j 23 | Enabled: true 24 | Query: | 25 | WITH toUpper($ownerId) AS OwnerID, toUpper($deviceId) AS DeviceID, toUpper($TenantId) AS TenantID, $TimeGenerated AS TimeGenerated 26 | MERGE (d:AZDevice {objectid: DeviceID}) 27 | ON CREATE SET d.tenantid = TenantID, d.lastseen = TimeGenerated, d.label = "AZBase" 28 | MERGE (u:AZUser {objectid: OwnerID}) 29 | MERGE (u)-[r:AZOwns]->(d) 30 | ON CREATE SET r.added = TimeGenerated, r.source = 'falconhound' 31 | Parameters: 32 | ownerId: ownerId 33 | deviceId: deviceId 34 | TimeGenerated: TimeGenerated 35 | TenantId: TenantId -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_aad_group_create.yml: -------------------------------------------------------------------------------- 1 | Name: New groups added to EntraID 2 | ID: SEN_AAD_Group_Creations 3 | Description: Gets all group creation events 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where ingestion_time() >= ago(timeframe) 14 | | where OperationName contains "Add group" 15 | | mv-expand TargetResources 16 | | extend TargetResources=parse_json(TargetResources), InitiatedBy=parse_json(InitiatedBy) 17 | | extend ObjectId = TargetResources.id, displayName=TargetResources.displayName, createdBy=InitiatedBy.user.id,creatorUserPrincipalName=InitiatedBy.user.userPrincipalName 18 | | project TimeGenerated,ObjectId,displayName,createdBy, creatorUserPrincipalName 19 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 20 | - Name: Neo4j 21 | Enabled: true 22 | Query: | 23 | WITH toUpper($ObjectId) AS ObjectId, toUpper($displayName) AS DisplayName, toUpper($createdBy) AS CreatedBy, toUpper($creatorUserPrincipalName) AS CreatorUserPrincipalName, $TimeGenerated AS TimeGenerated 24 | MERGE (g:AZGroup {objectid: ObjectId}) 25 | ON CREATE SET g.displayname = DisplayName, g.createdby = CreatedBy, g.creatoruserprincipalname = CreatorUserPrincipalName, g.source = 'falconhound', g.whencreated = TimeGenerated, g.label = 'AZBase' 26 | Parameters: 27 | displayName: displayName 28 | ObjectId: ObjectId 29 | TimeGenerated: TimeGenerated 30 | createdBy: createdBy 31 | creatorUserPrincipalName: creatorUserPrincipalName -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_aad_user_addedtogroup.yml: -------------------------------------------------------------------------------- 1 | Name: AAD user added to group 2 | ID: SEN_AAD_User_Added_To_Group 3 | Description: Collects AAD / EntraId user accounts added to a group. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where ingestion_time() >= ago(timeframe) 14 | | where OperationName =~ "Add member to group" 15 | | mv-expand TargetResources 16 | | extend TargetResources=parse_json(TargetResources) 17 | | extend ObjectId = TargetResources.id, userPrincipalName=TargetResources.userPrincipalName 18 | | where TargetResources.modifiedProperties contains "Group.ObjectId" 19 | | extend groupObjectId=trim('\"',tostring(TargetResources.modifiedProperties[0].newValue)) 20 | | project TimeGenerated,ObjectId,userPrincipalName,groupObjectId 21 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 22 | - Name: Neo4j 23 | Enabled: true 24 | Query: | 25 | WITH toUpper($ObjectId) AS ObjectId, toUpper($userPrincipalName) AS UserPrincipalName, toUpper($groupObjectId) AS GroupObjectId, $TimeGenerated AS TimeGenerated 26 | MERGE (u:AZUser {objectid: ObjectId}) ON CREATE SET u.userPrincipalName = UserPrincipalName, u.displayName = UserPrincipalName 27 | MERGE (g:AZGroup {objectid: GroupObjectId}) ON CREATE SET g.displayName = GroupObjectId 28 | MERGE (u)-[r:AZMemberOf]->(g) SET r.added = TimeGenerated, r.source = 'falconhound' 29 | Parameters: 30 | ObjectId: ObjectId 31 | userPrincipalName: userPrincipalName 32 | groupObjectId: groupObjectId 33 | TimeGenerated: TimeGenerated 34 | -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_aad_user_creations.yml: -------------------------------------------------------------------------------- 1 | Name: New AAD user creations 2 | ID: SEN_AAD_New_User_Creations 3 | Description: Collects new AAD / EntraId user accounts created in the last 15 minutes, including Guest accounts. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where ingestion_time() >= ago(timeframe) 14 | | where OperationName contains "Add user" 15 | | extend TargetResources=parse_json(TargetResources) 16 | | extend ObjectId = TargetResources.[0].id, userPrincipalName=TargetResources.[0].userPrincipalName, modifiedProperties=TargetResources.[0].modifiedProperties 17 | | extend displayName=tostring(modifiedProperties[2].newValue),TenantId=AADTenantId 18 | | extend displayName=replace_regex(displayName,'\\["|\\"]', '') 19 | | project TenantId,ObjectId,displayName,userPrincipalName,TimeGenerated 20 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 21 | - Name: Neo4j 22 | Enabled: true 23 | Query: | 24 | WITH toUpper($ObjectId) AS ObjectId, toUpper($userPrincipalName) AS UserPrincipalName, $displayName AS displayName, toUpper($TenantId) AS TenantId, $TimeGenerated AS TimeGenerated 25 | MERGE (x:AZBase {objectid:ObjectId}) 26 | SET x:AZUser, x+={ 27 | name: UserPrincipalName, 28 | userprincipalname: UserPrincipalName, 29 | tenantid: TenantId, 30 | objectid: ObjectId, 31 | displayname: displayName, 32 | highvalue:False, 33 | falconhound:True, 34 | fhdate: TimeGenerated 35 | } 36 | Parameters: 37 | ObjectId: ObjectId 38 | userPrincipalName: userPrincipalName 39 | displayName: displayName 40 | TenantId: TenantId 41 | TimeGenerated: TimeGenerated 42 | -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_aad_user_delete.yml: -------------------------------------------------------------------------------- 1 | Name: New AAD user deletions 2 | ID: SEN_AAD_New_User_Deletions 3 | Description: Collects deleted AAD / EntraId user accounts created in the last 15 minutes and removes them from the graph. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where ingestion_time() >= ago(timeframe) 14 | | where OperationName =~ "Delete user" 15 | | extend TargetResources=parse_json(TargetResources) 16 | | extend 17 | ObjectId = TargetResources.[0].id, 18 | userPrincipalName=TargetResources.[0].userPrincipalName 19 | | project ObjectId, userPrincipalName 20 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 21 | - Name: Neo4j 22 | Enabled: true 23 | Query: | 24 | WITH toUpper($ObjectId) AS ObjectId 25 | MATCH (x:AZUser {objectid:ObjectId}) 26 | DELETE x 27 | Parameters: 28 | ObjectId: ObjectId 29 | -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_aad_user_removedfromgroup.yml: -------------------------------------------------------------------------------- 1 | Name: AAD user removed from group 2 | ID: SEN_AAD_User_Removed_from_Group 3 | Description: Collects AAD / EntraId user accounts removed from a group. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where ingestion_time() >= ago(timeframe) 14 | | where OperationName contains "Remove member from group" 15 | | mv-expand TargetResources 16 | | extend TargetResources=parse_json(TargetResources) 17 | | extend ObjectId = TargetResources.id, userPrincipalName=TargetResources.userPrincipalName 18 | | where TargetResources.modifiedProperties contains "Group.ObjectID" 19 | | extend groupObjectId=trim('\"',tostring(TargetResources.modifiedProperties[0].oldValue)) 20 | | project TimeGenerated,ObjectId,userPrincipalName,groupObjectId 21 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 22 | - Name: Neo4j 23 | Enabled: true 24 | Query: | 25 | WITH toUpper($ObjectId) AS ObjectId, toUpper($userPrincipalName) AS UserPrincipalName, toUpper($groupObjectId) AS GroupObjectId 26 | MATCH (u:AZUser {objectid: ObjectId})-[r:AZMemberOf]->(g:AZGroup {objectid: GroupObjectId}) 27 | DELETE r 28 | Parameters: 29 | ObjectId: ObjectId 30 | userPrincipalName: userPrincipalName 31 | groupObjectId: groupObjectId 32 | -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_ad_group_additions.yml: -------------------------------------------------------------------------------- 1 | Name: All add events to AD groups 2 | ID: SEN_AD_Group_Additions 3 | Description: Gets all group additions from the Security logs, including local groups 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | let targetEvent = dynamic([4728,4732,4756]); // 4732 Domain Local, 4728 >> Global, 4756 >> Universal 13 | SecurityEvent 14 | | where ingestion_time() >= ago(timeframe) 15 | | where EventID in (targetEvent) 16 | | where MemberName != '-' 17 | | project TargetAccount, TargetDomainName, GroupSid=TargetSid, MemberSid, Actor=SubjectUserName, TimeGenerated 18 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 19 | - Name: Neo4j 20 | Enabled: true 21 | Query: | 22 | WITH $MemberSid AS MemberSid, $GroupSid AS GroupSid, $TimeGenerated AS Timestamp 23 | MATCH (u:User) WHERE u.objectid = MemberSid 24 | WITH u, GroupSid, Timestamp 25 | MATCH (g:Group) WHERE g.objectid = GroupSid 26 | MERGE (u)-[r:MemberOf]->(g) 27 | SET r.source = 'falconhound', r.added = Timestamp 28 | Parameters: 29 | MemberSid: MemberSid 30 | GroupSid: GroupSid 31 | TimeGenerated: TimeGenerated -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_ad_group_removal.yml: -------------------------------------------------------------------------------- 1 | Name: All remove events from AD groups 2 | ID: SEN_AD_Group_Removal 3 | Description: Gets all group removals from the Security logs, including local groups 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | let targetEvent = dynamic([4729,4733,4757]); // 4733 Domain Local, 4729 >> Global, 4757 >> Universal 13 | SecurityEvent 14 | | where ingestion_time() >= ago(timeframe) 15 | | where EventID in (targetEvent) 16 | | where MemberName != '-' 17 | | project TargetAccount, TargetDomainName, GroupSid=TargetSid, MemberSid, Actor=SubjectUserName, TimeGenerated 18 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 19 | - Name: Neo4j 20 | Enabled: true 21 | Query: | 22 | WITH $MemberSid AS MemberSid, $GroupSid AS GroupSid, $TimeGenerated AS Timestamp 23 | MATCH (u:User) WHERE u.objectid = MemberSid 24 | WITH u, GroupSid 25 | MATCH (g:Group) WHERE g.objectid = GroupSid 26 | MATCH (u)-[r:MemberOf]->(g) 27 | DELETE r 28 | Parameters: 29 | MemberSid: MemberSid 30 | GroupSid: GroupSid 31 | TimeGenerated: TimeGenerated -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_ad_user_creations.yml: -------------------------------------------------------------------------------- 1 | Name: AD new user creation events 2 | ID: SEN_AD_New_User_Creations 3 | Description: Collects new AD user accounts created in the last 15 minutes. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | SecurityEvent 13 | | where ingestion_time() >= ago(timeframe) 14 | | where EventID in (4720) 15 | | extend TargetDomainSid = substring(TargetSid, 0, strlen(TargetSid) - 5) 16 | | extend PrimaryGroup=strcat(TargetDomainSid,"-",PrimaryGroupId) 17 | | project TimeGenerated,TargetAccount,TargetSid=toupper(TargetSid),UserPrincipalName=toupper(UserPrincipalName),SamAccountName=toupper(SamAccountName),TargetDomainName,TargetDomainSid,PrimaryGroup 18 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 19 | - Name: CSV 20 | Enabled: false 21 | Path: output/get_sessions_sentinel.csv 22 | # - Name: Sentinel 23 | # Enabled: true 24 | - Name: Neo4j 25 | Enabled: true 26 | Query: | 27 | MERGE (x:Base {objectid:$TargetSid}) 28 | SET x:User, x+={ 29 | name: $UserPrincipalName, 30 | samaccountname: $SamAccountName, 31 | domainsid: $TargetDomainSid, 32 | domain: $TargetDomainName, 33 | displayname: $SamAccountName, 34 | distinguishedname: 'tbd', 35 | highvalue:False, 36 | falconhound:True, 37 | fhdate: $TimeGenerated 38 | } 39 | WITH x 40 | MATCH (y:Group) WHERE y.objectid = $PrimaryGroup 41 | MERGE (x)-[:MemberOf]->(y) 42 | Parameters: 43 | TargetSid: TargetSid 44 | UserPrincipalName: UserPrincipalName 45 | SamAccountName: SamAccountName 46 | TargetDomainSid: TargetDomainSid 47 | TargetDomainName: TargetDomainName 48 | TimeGenerated: TimeGenerated 49 | PrimaryGroup: PrimaryGroup 50 | -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_az_role_assignments.yml: -------------------------------------------------------------------------------- 1 | Name: New Azure role assignments 2 | ID: SEN_AZ_Role_Assignments 3 | Description: Gets all new Azure role assignments 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where ingestion_time() >= ago(timeframe) 14 | | where OperationName =~ "Add member to role" 15 | | extend TargetResources=parse_json(TargetResources) 16 | | extend ObjectId = TargetResources.[0].id, userPrincipalName=TargetResources.[0].userPrincipalName, modifiedProperties=TargetResources.[0].modifiedProperties 17 | | extend RoleObjectId = tostring(modifiedProperties[0].newValue),TenantId=AADTenantId 18 | | extend RoleObjectId=replace_regex(RoleObjectId,'\"', '') 19 | | project TenantId,ObjectId,RoleObjectId,userPrincipalName,TimeGenerated 20 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 21 | - Name: Neo4j 22 | Enabled: true 23 | Query: | 24 | WITH toUpper($ObjectId) AS ObjectId, toUpper($RoleObjectId + '@' + $TenantId) AS TargetObjectId, $TimeGenerated AS TimeGenerated 25 | MATCH (x:AZUser {objectid:ObjectId}) 26 | MATCH (y:AZRole {objectid:TargetObjectId}) 27 | MERGE (x)-[r:AZHasRole]-(y) 28 | SET r.source = 'falconhound', r.since = TimeGenerated 29 | Parameters: 30 | ObjectId: ObjectId 31 | RoleObjectId: RoleObjectId 32 | TenantId: TenantId 33 | TimeGenerated: TimeGenerated -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_host_alerts.yml: -------------------------------------------------------------------------------- 1 | Name: Get Alerts on Hosts 2 | ID: SEN_Host_Alerts 3 | Description: Gets all alerts from Sentinel, checks the status and marks the hosts or users as compromised in Neo4j. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | SecurityAlert 13 | | where ingestion_time() >= ago(timeframe) 14 | | extend EntitiesDynamicArray=parse_json(Entities) | mvexpand EntitiesDynamicArray 15 | | extend Entitytype=tostring(parse_json(EntitiesDynamicArray).Type) 16 | | where Entitytype=='host' 17 | | extend FQDN=tostring(parse_json(EntitiesDynamicArray).FQDN), HostName=parse_json(EntitiesDynamicArray).HostName, DnsDomain=tostring(parse_json(EntitiesDynamicArray).DnsDomain) 18 | | extend FQDN2=strcat(HostName,".",DnsDomain) // Sometimes the FQDN is not populated for some reason, so we can fix most this way. 19 | | extend EntityName=iff(isnotempty(FQDN),FQDN,FQDN2) 20 | | project EntityName,AlertId=VendorOriginalId,Entitytype,ProviderName, Status 21 | | summarize make_set(AlertId) by Entitytype,DeviceName=EntityName,Status, ProviderName 22 | //| where not((Entitytype == 'host' and EntityName !contains '.') or EntityName endswith ".") // Optional. Filters non-domain joined hosts and incomplete hostnames. 23 | | mv-expand set_AlertId 24 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 25 | - Name: Neo4j 26 | Enabled: true 27 | Query: | 28 | WITH $set_AlertId AS alertId, toUpper($EntityName) AS entityName, $Status AS status 29 | MATCH (c) 30 | WHERE c.name = entityName AND (c:Computer OR c:AZVM) 31 | SET c.alertid = (CASE WHEN status = 'New' AND NOT alertId IN coalesce(c.alertid, []) THEN coalesce(c.alertid, []) + [alertId] WHEN status = 'Resolved' THEN [val IN c.alertid WHERE val <> alertId] ELSE c.alertid END), 32 | c.owned = True 33 | Parameters: 34 | set_AlertId: set_AlertId 35 | EntityName: DeviceName 36 | Status: Status -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_local_admin_added.yml: -------------------------------------------------------------------------------- 1 | Name: Local Admin User Added to Machine 2 | ID: SEN_Local_Admin_added 3 | Description: Adds the AdminTo relation to a user which is added to the Administrators group on a machine. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | SecurityEvent 13 | | where EventID == 4732 14 | | where TargetSid == "S-1-5-32-544" 15 | | project TimeGenerated, Computer, TargetSid, SubjectUserSid 16 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 17 | - Name: Neo4j 18 | Enabled: true 19 | Query: | 20 | WITH toUpper($SubjectUserSid) AS SubjectUserSid, $TimeGenerated AS Timestamp, toUpper($Computer) AS Computer 21 | MATCH (x:User {objectid:SubjectUserSid}) 22 | MATCH (y:Computer {name: Computer}) 23 | MERGE (x)-[r:AdminTo]->(y) 24 | SET r.source = 'falconhound', r.since = Timestamp 25 | Parameters: 26 | SubjectUserSid: SubjectUserSid 27 | Computer: Computer 28 | TimeGenerated: TimeGenerated -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_user_alerts.yml: -------------------------------------------------------------------------------- 1 | Name: Get Alerts on Accounts 2 | ID: SEN_Acct_Alerts 3 | Description: Gets all alerts from Sentinel, checks the status and marks the hosts or users as compromised in Neo4j. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | SecurityAlert 13 | | where ingestion_time() >= ago(timeframe) 14 | | extend EntitiesDynamicArray=parse_json(Entities) | mvexpand EntitiesDynamicArray 15 | | extend Entitytype=tostring(parse_json(EntitiesDynamicArray).Type) 16 | | where Entitytype=='account' 17 | | extend Sid=tostring(parse_json(EntitiesDynamicArray).Sid) 18 | | extend EntityName=tostring(parse_json(EntitiesDynamicArray).Name),Domain=tostring(parse_json(EntitiesDynamicArray).NTDomain) 19 | | where not(isempty(Sid) or Sid == 'S-1-0-0' or Sid == 'S-1-5-18' or isempty(EntityName)) 20 | | project AlertId=VendorOriginalId,Entitytype,EntityName,Sid, ProviderName, Status 21 | | summarize make_set(AlertId) by Entitytype,EntityName,Status, ProviderName,Sid 22 | | mv-expand set_AlertId 23 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 24 | - Name: Neo4j 25 | Enabled: true 26 | Query: | 27 | WITH $set_AlertId AS alertId, toUpper($EntityName) AS entityName, toUpper($Sid) AS sid, $Status AS status 28 | MATCH (u) 29 | WHERE u.objectid = sid AND (u:User OR u:AZUser) 30 | SET u.alertid = (CASE WHEN status = 'New' AND NOT alertId IN coalesce(u.alertid, []) THEN coalesce(u.alertid, []) + [alertId] WHEN status = 'Resolved' THEN [val IN u.alertid WHERE val <> alertId] ELSE u.alertid END), 31 | u.owned = True 32 | Parameters: 33 | set_AlertId: set_AlertId 34 | EntityName: EntityName 35 | Sid: Sid 36 | Status: Status -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_get_user_mfa_updates.yml: -------------------------------------------------------------------------------- 1 | Name: User MFA setting updates 2 | ID: SEN_AZ_MFA_Updates 3 | Description: Gets all additive and updates to MFA settings, deletions are not captured 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | AuditLogs 13 | | where Result == "success" 14 | | where OperationName == "Update user" 15 | | extend UserPrincipalName = tostring(TargetResources[0].userPrincipalName) 16 | | extend modifiedProperties = parse_json(TargetResources[0].modifiedProperties) 17 | | mv-expand modifiedProperty = modifiedProperties 18 | | extend displayName = tostring(modifiedProperty.displayName), 19 | oldValue = modifiedProperty.oldValue, 20 | newValue = modifiedProperty.newValue 21 | | project-away modifiedProperties, modifiedProperty 22 | | where displayName startswith "Strong" 23 | | mv-expand newValue 24 | | extend newValues = parse_json(tostring(newValue))[0] 25 | | extend MfaAuthenticatorDeviceName = newValues.DeviceName 26 | | extend MfaDeviceId = newValues.DeviceId 27 | | extend MfaPhoneNumber = newValues.PhoneNumber 28 | | extend AuthenticatorFlavor = newValues.AuthenticatorFlavor 29 | | extend MfaEmailAddress = newValues.Email 30 | | extend DeviceTag = newValues.DeviceTag 31 | | where isnotnull(AuthenticatorFlavor) or isnotnull( MfaPhoneNumber) 32 | | extend MfaDeviceId=case(MfaDeviceId == "00000000-0000-0000-0000-000000000000","",MfaDeviceId) 33 | | extend MfaAuthMethods=case((AuthenticatorFlavor =="Authenticator" and DeviceTag =~ "SoftwareTokenActivated"),"SoftwareOath", 34 | (AuthenticatorFlavor =="Authenticator" and DeviceTag !~ "SoftwareTokenActivated"),"MicrosoftAuthenticator", 35 | (isnotempty(MfaPhoneNumber)),"Phone", 36 | (isnotempty(MfaEmailAddress)),"Email", 37 | "Unknown" ) 38 | | project UserPrincipalName=toupper(UserPrincipalName), UserId=toupper(TargetResources[0].id), MfaAuthenticatorDeviceName,MfaPhoneNumber, MfaAuthMethods, MfaDeviceId=toupper(MfaDeviceId), MfaEmailAddress=toupper(MfaEmailAddress) 39 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 40 | - Name: Neo4j 41 | Enabled: true 42 | Query: | 43 | WITH toUpper($objectid) as objectid, $MfaAuthMethods as MfaAuthMethods, $MfaPhoneNumber as MfaPhoneNumber,$MfaAuthenticatorDeviceName as MfaAuthenticatorDeviceName, toUpper($MfaDeviceId) as MfaDeviceId, $MfaEmailAddress as MfaEmailAddress 44 | MATCH (t:AZUser {objectid: objectid}) 45 | SET t.MfaAuthMethods = (CASE WHEN NOT MfaAuthMethods IN coalesce(t.MfaAuthMethods, []) THEN coalesce(t.MfaAuthMethods, []) + [MfaAuthMethods] ELSE t.MfaAuthMethods END) 46 | SET t.MfaPhoneNumber = MfaPhoneNumber, t.MfaAuthenticatorDeviceName = MfaAuthenticatorDeviceName, t.MfaDeviceId = MfaDeviceId, t.MfaEmailAddress = MfaEmailAddress 47 | Parameters: 48 | objectid: UserId 49 | MfaAuthMethods: MfaAuthMethods 50 | MfaPhoneNumber: MfaPhoneNumber 51 | MfaEmailAddress: MfaEmailAddress 52 | MfaAuthenticatorDeviceName: MfaAuthenticatorDeviceName 53 | MfaDeviceId: MfaDeviceId -------------------------------------------------------------------------------- /actions/01-Sentinel/sen_new_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: Get new logon sessions 2 | ID: SEN_New_Sessions 3 | Description: Gets all logon events from Sentinel and sends them to Neo4j 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Gets all logon events from Sentinel, filters out non-user logons, and creates a relationship between the computer and the user in Neo4j, 8 | with the timestamp of the first logon event. 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | 13 | let timeframe = 15m; 14 | let excludeSid = '^S-1-5-(90|96)-'; 15 | SecurityEvent 16 | | where ingestion_time() >= ago(timeframe) 17 | | where EventID == 4624 18 | | where toupper(AccountType) == 'USER' 19 | | where LogonType !in (3,7) 20 | | where not(TargetUserSid matches regex excludeSid) 21 | | where TargetDomainName !in ('NT AUTHORITY','NT Service') 22 | | extend ComputerName = tostring(split(Computer, '.')[0]), DomainName = strcat(tostring(split(Computer, '.')[-2]), '.', tostring(split(Computer, '.')[-1])) 23 | | summarize Timestamp=arg_min(TimeGenerated,0) by Computer,TargetUserSid,TargetUserName,TargetDomainName,IpAddress 24 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 25 | - Name: Neo4j 26 | Enabled: true 27 | Query: | 28 | WITH toUpper($Computer) as Computer, toUpper($TargetUserSid) as TargetUserSid, $Timestamp as Timestamp 29 | MATCH (x:Computer {name:Computer}) MATCH (y:User {objectid:TargetUserSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=Timestamp SET r.source='falconhound' 30 | Parameters: 31 | Computer: Computer 32 | TargetUserSid: TargetUserSid 33 | Timestamp: Timestamp 34 | - Name: BloodHound 35 | Enabled: false 36 | Query: | 37 | MATCH (x:Computer {name:$Computer}) MATCH (y:User {objectid:$TargetUserSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=$Timestamp SET r.source='falconhound' 38 | Parameters: 39 | Computer: Computer 40 | TargetUserSid: TargetUserSid 41 | Timestamp: Timestamp -------------------------------------------------------------------------------- /actions/02-MDE/mde_exploitable_hosts.yml: -------------------------------------------------------------------------------- 1 | Name: Hosts with public available code for CVE 2 | ID: MDE_Exploitable_Hosts 3 | Description: Find hosts in MDE with exploitable software 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: MDE # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let Timestamp = datetime(now)-1d; 12 | let vulnerablesoftware=DeviceTvmSoftwareVulnerabilities 13 | | where isempty(CveId)==false; 14 | let exploitable=DeviceTvmSoftwareVulnerabilitiesKB 15 | | where CveId in ((vulnerablesoftware|project CveId)) 16 | | where IsExploitAvailable == "1"; 17 | let machinesToPatch=vulnerablesoftware 18 | | join exploitable on CveId 19 | | summarize SoftwareNames=tostring(make_set(SoftwareName)),CveIds=tostring(make_set(CveId)) by DeviceId; 20 | DeviceInfo 21 | | summarize arg_max(Timestamp,*) by DeviceId 22 | | join kind=inner machinesToPatch on DeviceId 23 | | where isnotempty(DeviceName) // Added to prevent an error when DeviceName is empty. This happens on Linux devices, for example. 24 | | where DeviceName !contains " " // Added to prevent an error when DeviceName has a space in it. This happens on phones, for example. 25 | | extend DeviceName=toupper(DeviceName) 26 | | project Timestamp,DeviceName, OSPlatform,IsAzureADJoined,JoinType,DeviceType,IsInternetFacing,ExposureLevel,SoftwareNames,CveIds 27 | # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 28 | Targets: 29 | - Name: Neo4j 30 | Enabled: true 31 | Query: | 32 | MATCH (c:Computer {name:$DeviceName}) SET c.exploitable = true, c.exploits = $CveIds, c.exploitableSince = $Timestamp 33 | Parameters: 34 | DeviceName: DeviceName 35 | CveIds: CveIds 36 | Timestamp: Timestamp 37 | - Name: Watchlist 38 | Enabled: true 39 | WatchlistName: FH_MDE_Exploitable_Machines 40 | DisplayName: MDE Exploitable Machines 41 | SearchKey: DeviceName 42 | Overwrite: true 43 | - Name: ADX 44 | Enabled: false 45 | Table: FalconHound 46 | - Name: LimaCharlie 47 | Enabled: false -------------------------------------------------------------------------------- /actions/02-MDE/mde_new_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: Get new logon sessions 2 | ID: MDE_New_Sessions 3 | Description: Collects new logon sessions from MDE. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Gets all logon events from MDE, filters out non-user logons, and creates a relationship between the computer and the user in Neo4j, 8 | with the timestamp of the first logon event. 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: MDE # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | 13 | let timeframe = 15m; 14 | let excludeSid = '^S-1-5-(90|96)-0-'; 15 | DeviceLogonEvents 16 | | where ingestion_time() >= ago(timeframe) 17 | | where ActionType == "LogonSuccess" and Protocol != "Negotiate" 18 | | where LogonType !in ("Network","Service") 19 | | where isnotempty(AccountSid) 20 | | where not(AccountSid matches regex excludeSid) 21 | | extend DeviceName = toupper(DeviceName), DomainName = strcat(tostring(split(DeviceName, '.')[-2]), '.', tostring(split(DeviceName, '.')[-1])) 22 | | summarize Timestamp=min(Timestamp) by DeviceName,AccountSid,AccountName,AccountDomain, LogonType 23 | # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 24 | Targets: 25 | - Name: Neo4j 26 | Enabled: true 27 | Query: | 28 | MATCH (x:Computer {name:$DeviceName}) MATCH (y:User {objectid:$AccountSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=$Timestamp SET r.source='falconhound' 29 | Parameters: 30 | DeviceName: DeviceName 31 | AccountSid: AccountSid 32 | Timestamp: Timestamp 33 | - Name: Watchlist 34 | Enabled: false 35 | WatchlistName: FH_MDE_Sessions 36 | DisplayName: MDE Sessions 37 | SearchKey: AccountName 38 | Overwrite: true -------------------------------------------------------------------------------- /actions/02-MDE/mde_publicly_exposed_machines.yml: -------------------------------------------------------------------------------- 1 | Name: Get hosts with ports exposed to the internet 2 | ID: MDE_Publicly_exposed_machines 3 | Description: Find Machines in MDE which have sensitive ports exposed on the internet. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: MDE # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 10 | Query: | 11 | let timeframe = 15m; 12 | DeviceNetworkEvents 13 | | where Timestamp > ago(timeframe) 14 | | where ActionType == "InboundInternetScanInspected" 15 | | where RemotePort in (3389,445,389,636,135,139,161,53,21,22,23,1433) 16 | | summarize LastSeen=arg_max(Timestamp, * ), PublicPorts=make_set(RemotePort) by DeviceId,DeviceName, PublicIP=RemoteIP 17 | | project LastSeen,PublicPorts,DeviceId,DeviceName,PublicIP,LocalIP 18 | # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 19 | Targets: 20 | - Name: Neo4j 21 | Enabled: true 22 | Query: | 23 | WITH toUpper($DeviceName) as DeviceName, $PublicPorts as PublicPorts 24 | MATCH (c:Computer {name:DeviceName}) SET c.exposed = true, c.ports = PublicPorts, c.exposedSince = $LastSeen 25 | Parameters: 26 | DeviceName: DeviceName 27 | PublicPorts: PublicPorts 28 | LastSeen: LastSeen 29 | - Name: Watchlist 30 | Enabled: true 31 | WatchlistName: FH_MDE_Exposed_Machines 32 | DisplayName: MDE Exposed Machines 33 | SearchKey: DeviceName 34 | Overwrite: false -------------------------------------------------------------------------------- /actions/02-MDE/mde_update_active_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: Get all currently active sessions on hosts 2 | ID: MDE_Update_Active_Sessions 3 | Description: This query will look for all active sessions in MDE and add them to Neo4j 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | This query will look for all active sessions in MDE and add them to the graph. 8 | If there are still sessions in the graph that are older than 4 hours, they will be removed and set to HadSession. 9 | Active: false # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: MDE # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | 13 | let timeframe = 15m; 14 | DeviceInfo 15 | | where Timestamp >= ago(timeframe) 16 | | extend LoggedOnUsers=parse_json(LoggedOnUsers) 17 | | mv-expand LoggedOnUsers 18 | | extend UserName=toupper(tostring(LoggedOnUsers.UserName)), AccountSid=toupper(tostring(LoggedOnUsers.Sid)) 19 | | summarize Timestamp=arg_min(Timestamp,*) by DeviceName, UserName, AccountSid 20 | Targets: 21 | - Name: Neo4j 22 | Enabled: true 23 | Query: | 24 | WITH toUpper($DeviceName) as DeviceName, $AccountSid as AccountSid, $Timestamp as Timestamp 25 | MATCH (x:Computer {name:DeviceName}) MATCH (y:User {objectid:AccountSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=Timestamp SET r.source='falconhound' 26 | WITH DeviceName 27 | MATCH (c)-[R:HasSession]->(u) 28 | WHERE c.name=DeviceName 29 | AND duration.between(datetime(R.since), datetime()).hours > 4 30 | MERGE (c)-[r:HadSession]->(u) SET r.till=datetime() SET r.source='falconhound' SET r.reason='timeout' DELETE R 31 | Parameters: 32 | DeviceName: DeviceName 33 | AccountSid: AccountSid 34 | Timestamp: Timestamp -------------------------------------------------------------------------------- /actions/03-Splunk/spl_new_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: Get new logon sessions 2 | ID: SPL_New_Sessions 3 | Description: Gets all logon events from Splunk and sends them to Neo4j 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Gets all logon events from Splunk, filters out non-user logons, and creates a relationship between the computer and the user in Neo4j, 8 | with the timestamp of the first logon event. 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: Splunk # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | # Splunk index can be hardcoded or a variable set in the config.yml file 13 | index=%s EventCode=4624 earliest=-15m NOT(Logon_Type IN (3,7)) Security_ID ="S-1-5-21-*" 14 | | eval UserName=mvindex(Account_Name,1), AccountSid=mvindex(Security_ID,1) 15 | | sort - _time 16 | | dedup ComputerName, UserName, AccountSid 17 | | table _time, ComputerName, UserName, AccountSid, Logon_Type 18 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 19 | - Name: Neo4j 20 | Enabled: true 21 | Query: | 22 | WITH toUpper($Computer) as Computer, toUpper($TargetUserSid) as TargetUserSid, $Timestamp as Timestamp 23 | MATCH (x:Computer {name:Computer}) MATCH (y:User {objectid:TargetUserSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=Timestamp SET r.source='falconhound' 24 | Parameters: 25 | Computer: ComputerName 26 | TargetUserSid: AccountSid 27 | Timestamp: _time -------------------------------------------------------------------------------- /actions/04-MSgraph/graph_eligible_role_assignments.yml: -------------------------------------------------------------------------------- 1 | Name: Get eligible role assignments for a tenant 2 | ID: GRAPH_Eligible_Role_Assignments 3 | Description: Get eligible role assignments for a tenant 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: MSGraph # Sentinel, Watchlist, Neo4j, MDE, Graph, Splunk 10 | Query: | 11 | /beta/roleManagement/directory/roleEligibilitySchedules 12 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 13 | - Name: Neo4j 14 | Enabled: true 15 | Query: | 16 | WITH toUpper($principalId) as principalId, toupper($roleDefinitionId + '@' + $GraphTenantID) as targetId, $status as status, $createdDateTime as createdDateTime 17 | MATCH (s {objectid:principalId}) MATCH (t {objectid: targetId}) 18 | MERGE (s)-[r:AZHasRole]-(t) 19 | SET r.set=createdDateTime, r.status=status,r.source='falconhound', r.enforced = false 20 | Parameters: 21 | principalId: principalId 22 | roleDefinitionId: roleDefinitionId 23 | status: status 24 | createdDateTime: createdDateTime 25 | GraphTenantID: GraphTenantID -------------------------------------------------------------------------------- /actions/04-MSgraph/msgraph_dynamicgroups.yml: -------------------------------------------------------------------------------- 1 | Name: Get Dynamic Groups 2 | ID: GRAPH_DynamicGroups 3 | Description: Get Dynamic Groups and their rules. Requires Directory.Read.All permissions 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: MSGraphApi # Sentinel, Watchlist, Neo4j, MDE, Graph, Splunk 10 | Query: | 11 | GetDynamicGroups 12 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 13 | - Name: Neo4j 14 | Enabled: true 15 | Query: | 16 | WITH toUpper($objectid) as objectid, toUpper($displayname) as name, $membershiprule as membershiprule, $membershiprulestate as membershiprulestate, $grouptype as grouptype, $displayname as displayname, $tenantid as tenantid 17 | MERGE (x:AZBase {objectid:objectid}) 18 | SET x:AZGroup, x+={ 19 | name: name, 20 | tenantid: tenantid, 21 | objectid: objectid, 22 | displayname: displayname, 23 | falconhound:True, 24 | membershiprule: membershiprule, 25 | membershiprulestate: membershiprulestate, 26 | grouptype: grouptype 27 | } 28 | Parameters: 29 | objectid: ObjectId 30 | grouptype: GroupType 31 | membershiprule: MembershipRule 32 | membershiprulestate: MembershipRuleProcessingState 33 | displayname: DisplayName 34 | tenantid: TenantId 35 | - Name: Markdown 36 | Enabled: false 37 | Path: report/TEST/oauth.md -------------------------------------------------------------------------------- /actions/04-MSgraph/msgraph_mfa.yml: -------------------------------------------------------------------------------- 1 | Name: Get MFA Status 2 | ID: GRAPH_MFA_Status 3 | Description: Get per user MFA settings. WARNING, this will be slow. Requires User.Read.All and UserAuthenticationMethod.Read.All permissions 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: false # disabled by default due to the long processing time, enable only when needed, updates can be gathered from logs 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: MSGraphApi # Sentinel, Watchlist, Neo4j, MDE, Graph, Splunk 10 | Query: | 11 | GetMFA 12 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 13 | - Name: Neo4j 14 | Enabled: true 15 | Query: | 16 | WITH toUpper($objectid) as objectid, $MfaAuthMethods as MfaAuthMethods, $MfaPhoneNumber as MfaPhoneNumber, $MfaSmsMethod as MfaSmsMethod, $MfaSignInPreference as MfaSignInPreference, toUpper($MfaEmailAddress) as MfaEmailAddress, $MfaHelloDevice as MfaHelloDevice, $MfaFidoDeviceName as MfaFidoDeviceName, $MfaFidoModel as MfaFidoModel, $MfaAuthenticatorDeviceName as MfaAuthenticatorDeviceName, $MfaAuthenticatorDeviceId as MfaAuthenticatorDeviceId 17 | MATCH (t {objectid: objectid}) 18 | SET t.MfaAuthMethods = MfaAuthMethods, t.MfaPhoneNumber = MfaPhoneNumber, t.MfaSmsMethod = MfaSmsMethod, t.MfaSignInPreference = MfaSignInPreference, t.MfaEmailAddress = MfaEmailAddress, t.MfaHelloDevice = MfaHelloDevice, t.MfaFidoDeviceName = MfaFidoDeviceName, t.MfaFidoModel = MfaFidoModel, t.MfaAuthenticatorDeviceName = MfaAuthenticatorDeviceName, t.MfaAuthenticatorDeviceId = MfaAuthenticatorDeviceId 19 | Parameters: 20 | objectid: ObjectId 21 | MfaAuthMethods: MfaAuthMethods 22 | MfaPhoneNumber: PhoneNumber 23 | MfaSmsMethod: SmsSignInState 24 | MfaSignInPreference: SignInPreference 25 | MfaEmailAddress: MfaEmailAddress 26 | MfaHelloDevice: HelloDevice 27 | MfaFidoDeviceName: FidoDeviceName 28 | MfaFidoModel: FidoModel 29 | MfaAuthenticatorDeviceName: AuthenticatorDeviceName 30 | MfaAuthenticatorDeviceId: AuthenticatorDeviceId 31 | - Name: Markdown 32 | Enabled: false 33 | Path: report/TEST/MFA.md -------------------------------------------------------------------------------- /actions/04-MSgraph/msgraph_oauthconsent.yml: -------------------------------------------------------------------------------- 1 | Name: Get OAuth Consent 2 | ID: GRAPH_OAuthConsent 3 | Description: Get OAuth Consent. Requires Directory.Read.All permissions 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: MSGraphApi # Sentinel, Watchlist, Neo4j, MDE, Graph, Splunk 10 | Query: | 11 | GetOAuthConsent 12 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 13 | - Name: Neo4j 14 | Enabled: true 15 | Query: | 16 | WITH toUpper($objectid) as objectid, $ConsentType as ConsentType, $StartTime as StartTime, $ExpiryTime as ExpiryTime, toUpper($ResourceId) as ResourceId, $Scope as Scope 17 | MATCH (s {objectid: objectid}) MATCH (t {objectid: ResourceId}) 18 | MERGE (s)-[r:HasConsent]->(t) 19 | SET r.ConsentType = ConsentType, r.StartTime = StartTime, r.ExpiryTime = ExpiryTime, r.Scope = Scope 20 | Parameters: 21 | objectid: ClientId 22 | ConsentType: ConsentType 23 | StartTime: StartTime 24 | ExpiryTime: ExpiryTime 25 | ResourceId: ResourceId 26 | Scope: Scope 27 | - Name: Markdown 28 | Enabled: false 29 | Path: report/TEST/oauth.md -------------------------------------------------------------------------------- /actions/05-LogScale/fls_new_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: Get new logon sessions 2 | ID: FLS_New_Sessions 3 | Description: Gets all logon events from Falcon LogScale and sends them to Neo4j 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Gets all logon events from LogScale, filters out non-user logons, and creates a relationship between the computer and the user in Neo4j, 8 | with the timestamp of the first logon event. 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: LogScale # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | # Splunk index can be hardcoded or a variable set in the config.yml file 13 | "@collect.source_name" = "windows_events" 14 | | windows.EventID = 4624 15 | | windows.EventData.TargetUserSid = "S-1-5-21-*" 16 | | windows.EventData.LogonType!=3 17 | | table([@timestamp,windows.EventData.TargetUserSid,windows.Computer]) 18 | | rename(field=[[windows.EventData.TargetUserSid, TargetUserSid], [windows.Computer, Computer], [@timestamp, Timestamp]]) 19 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 20 | - Name: Neo4j 21 | Enabled: true 22 | Query: | 23 | WITH toUpper($Computer) as Computer, toUpper($TargetUserSid) as TargetUserSid, $Timestamp as Timestamp 24 | MATCH (x:Computer {name:Computer}) MATCH (y:User {objectid:TargetUserSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=Timestamp SET r.source='falconhound' 25 | Parameters: 26 | Computer: Computer 27 | TargetUserSid: TargetUserSid 28 | Timestamp: Timestamp -------------------------------------------------------------------------------- /actions/06-Elastic/elk_new_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: Get new logon sessions 2 | ID: ELK_New_Sessions 3 | Description: Gets all logon events from Elastic Cloud and sends them to Neo4j 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Gets all logon events from Elastic Cloud, filters out non-user logons, and creates a relationship between the computer and the user in Neo4j, 8 | with the timestamp of the first logon event. 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: Elastic # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | # Splunk index can be hardcoded or a variable set in the config.yml file 13 | @timestamp:[now-15h TO now] 14 | AND winlog.event_id: 4624 15 | AND winlog.event_data.TargetUserSid: *S-1-5-21-* 16 | AND winlog.event_data.LogonType: (2 OR 10 OR 11 OR 12 OR 13 OR 14 OR 15) 17 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 18 | - Name: Neo4j 19 | Enabled: true 20 | Query: | 21 | WITH toUpper($Computer) as Computer, toUpper($TargetUserSid) as TargetUserSid, $Timestamp as Timestamp 22 | MATCH (x:Computer {name:Computer}) MATCH (y:User {objectid:TargetUserSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=Timestamp SET r.source='falconhound' 23 | Parameters: 24 | Computer: winlog.computer_name 25 | TargetUserSid: winlog.event_data.TargetUserSid 26 | Timestamp: "@timestamp" -------------------------------------------------------------------------------- /actions/08-HTTP/http_entra_roles.yml: -------------------------------------------------------------------------------- 1 | Name: Get Entra admin tier roles 2 | ID: HTTP_Entra_Roles 3 | Description: Get all roles from Entra and create a relationship between the role and the user in Neo4j. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Based on a tweet by martinsohndk https://twitter.com/martinsohndk/status/1768065960148136277 8 | and references the project by Thomas Naunheim https://github.com/Cloud-Architekt/AzurePrivilegedIAM 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: HTTP # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk 12 | Query: | # Splunk index can be hardcoded or a variable set in the config.yml file 13 | EntraRoles 14 | Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) 15 | - Name: Neo4j 16 | Enabled: true 17 | Query: | 18 | WITH $AdminTierLevel AS AdminTierLevel, toUpper($RoleObjectId + '@' + $TenantId) AS TargetObjectId 19 | MATCH (x:AZRole {objectid: TargetObjectId}) 20 | SET x.system_tags = AdminTierLevel 21 | Parameters: 22 | AdminTierLevel: AdminTierLevel 23 | RoleObjectId: RoleId 24 | TenantId: TenantId -------------------------------------------------------------------------------- /actions/09_Bloodhound/bh_azure_resource_owner_list.yml: -------------------------------------------------------------------------------- 1 | Name: Azure Resource Owner list 2 | ID: BH_Azure_Owners_list 3 | Description: This action lists all Azure resources and their owners 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | This is intended to be used as an enrichment list to quantify the impact of an alert on a user that owns a resource. 8 | Active: false # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: BloodHound 11 | Query: | 12 | MATCH p = (a)-[r:AZOwns|AZUserAccessAdministrator]->(b) 13 | RETURN p 14 | Targets: 15 | - Name: CSV 16 | Enabled: true 17 | Path: output/azowners.csv 18 | - Name: Sentinel 19 | Enabled: false 20 | # TODO: Add filtering for output - WIP for now -------------------------------------------------------------------------------- /actions/09_Bloodhound/bh_kerberoastable_users.yml: -------------------------------------------------------------------------------- 1 | Name: Kerberoastable users 2 | ID: BH_SPN_Users 3 | Description: This action lists all users with an SPN 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | This is intended to be used as an enrichment list to quantify the impact of an alert on a user that owns a resource. 8 | Active: false # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: BloodHound 11 | Query: | 12 | MATCH (a:User {hasspn:true}) RETURN a 13 | Targets: 14 | - Name: CSV 15 | Enabled: true 16 | Path: output/kerberoastable_users.csv 17 | - Name: Sentinel 18 | Enabled: false 19 | # TODO: Add filtering for output - WIP for now -------------------------------------------------------------------------------- /actions/09_Bloodhound/bh_tier0_users.yml: -------------------------------------------------------------------------------- 1 | Name: Kerberoastable users 2 | ID: BH_Tier0_Users 3 | Description: This action lists all users with an SPN 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | This is intended to be used as an enrichment list to quantify the impact of an alert on a user that owns a resource. 8 | Active: false # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: BloodHound 11 | Query: | 12 | MATCH (x:Group) 13 | WHERE (coalesce(x.system_tags,'') CONTAINS 'admin_tier_0') 14 | WITH x.objectid as ObjectID, x.name as Name 15 | MATCH (y:Group {objectid:ObjectID}) 16 | MATCH (u:User) 17 | MATCH (u)-[MemberOf]->(y) 18 | RETURN u 19 | Targets: 20 | - Name: CSV 21 | Enabled: true 22 | Path: output/tier0_users.csv -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_0_cln_remove_exploitable.yml: -------------------------------------------------------------------------------- 1 | Name: CLEANUP - Remove exploitable attribute after 45 days 2 | ID: N4J_CLN_Remove_Old_Exploitable 3 | Description: Removes the Exploitable flags if the exploitableSince is older than 45 days. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | We assume a 30 day patch cycle and 15 days grace period. If the exploitableSince is older than 45 days, we remove the exploitable flag. 8 | Should the machine not be patched yet the timestamp should be updated by the Exploitable Hosts action. 9 | Active: true # Enable to run this action 10 | Debug: false # Enable to see query results in the console 11 | SourcePlatform: Neo4j 12 | Query: | 13 | MATCH (c {exploitable:true}) 14 | WHERE duration.between(datetime(c.exploitableSince), datetime()).days > 45 15 | REMOVE c.exploitable, c.exploits, c.exploitableSince 16 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_0_cln_remove_exposed.yml: -------------------------------------------------------------------------------- 1 | Name: CLEANUP - Remove exposed attribute after 14 days 2 | ID: N4J_CLN_Remove_Old_Exposed 3 | Description: Removes the Exposed flags if the exposedSince is older than 14 days. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (c {exposed:true}) 12 | WHERE duration.between(datetime(c.exposedSince), datetime()).days > 14 13 | REMOVE c.exposed, c.ports, c.exposedSince 14 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_0_cln_remove_mfa_device_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: CLEANUP - Remove the MFA Device Sharing edge 2 | ID: N4J_CLN_Remove_MFA_Device_Sharing 3 | Description: Removes the MFA Device Sharing edge from the graph if the nodes do not share the same device 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (u1:AZUser)-[r:MfaDeviceSharing]-(u2:AZUser) 12 | WHERE u1.MfaAuthenticatorDeviceId IS NOT NULL AND u2.MfaAuthenticatorDeviceId IS NOT NULL 13 | AND u1.MfaAuthenticatorDeviceId <> u2.MfaAuthenticatorDeviceId 14 | DELETE r 15 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_0_cln_remove_mfa_email_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: CLEANUP - Remove the MFA Email Sharing edge 2 | ID: N4J_CLN_Remove_MFA_EMAIL_Sharing 3 | Description: Removes the MFA Email edge from the graph if the nodes do not share the same address 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (u1:AZUser)-[r:MfaEmailSharing]-(u2:AZUser) 12 | WHERE u1.MfaEmailAddress IS NOT NULL AND u2.MfaEmailAddress IS NOT NULL 13 | AND u1.MfaEmailAddress <> u2.MfaEmailAddress 14 | DELETE r 15 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_0_cln_remove_mfa_phone_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: CLEANUP - Remove the MFA Phone Sharing edge 2 | ID: N4J_CLN_Remove_MFAPhoneSharing 3 | Description: Removes the MFA Phone Sharing edge from the graph if the nodes do not share the same phone number 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (u1:AZUser)-[r:MfaPhoneSharing]-(u2:AZUser) 12 | WHERE u1.MfaPhoneNumber IS NOT NULL AND u2.MfaPhoneNumber IS NOT NULL 13 | AND u1.MfaPhoneNumber <> u2.MfaPhoneNumber 14 | DELETE r 15 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_0_cln_remove_older_sessions.yml: -------------------------------------------------------------------------------- 1 | Name: CLEANUP - Time-out sessions older than 3 days 2 | ID: N4J_CLN_Remove_Older_Sessions 3 | Description: Removes the HasSession relation and replaces it with HadSession if the session is older than 3 days. 4 | Author: FalconForce 5 | Version: '0.8' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (c)-[R:HasSession]->(u) 12 | WHERE duration.between(datetime(R.since), datetime()).days > 3 13 | MERGE (c)-[r:HadSession]->(u) SET r.till=datetime() SET r.source='falconhound' SET r.reason='timeout' DELETE R 14 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_0_cln_remove_owned.yml: -------------------------------------------------------------------------------- 1 | Name: CLEANUP - Remove Owned property where there are no AlertIds 2 | ID: N4J_CLN_Remove_Owned 3 | Description: Removes the Owned proprety from nodes in BloodHound that don't have any alerts in Sentinel. 4 | Author: FalconForce 5 | Version: '0.8' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x{owned:True}) WHERE x.alertid[0] IS NULL SET x.owned=False 12 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_1_edge_add_mfa_authdevice_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: EDGE - Add MFA Authenticator Device Sharing edges 2 | ID: N4J_EDGE_ADD_MFAAUTHDEVICE_SHARING 3 | Description: Checks for accounts with the same MFA Authenticator device and adds a MfaDeviceSharing edge. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (user1:AZUser) 12 | WHERE user1.MfaAuthenticatorDeviceId IS NOT NULL AND user1.MfaAuthenticatorDeviceId <> "" 13 | WITH user1.MfaAuthenticatorDeviceId AS device, COLLECT(user1) AS users 14 | UNWIND users AS u1 15 | UNWIND users AS u2 16 | WITH u1, u2 17 | WHERE id(u1) < id(u2) 18 | MERGE (u1)-[r:MfaDeviceSharing]-(u2) 19 | SET r.enforced = false 20 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_1_edge_add_mfa_email_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: EDGE - Add MFA Email Address Sharing edges 2 | ID: N4J_EDGE_ADD_MFA_EMAIL_SHARING 3 | Description: Checks for accounts with the same MFA email address and adds a MfaEmailSharing edge. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (user1:AZUser) 12 | WHERE user1.MfaEmailAddress IS NOT NULL AND user1.MfaEmailAddress <> "" 13 | WITH user1.MfaEmailAddress AS device, COLLECT(user1) AS users 14 | UNWIND users AS u1 15 | UNWIND users AS u2 16 | WITH u1, u2 17 | WHERE id(u1) < id(u2) 18 | MERGE (u1)-[r:MfaEmailSharing]-(u2) 19 | SET r.enforced = false 20 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_1_edge_add_mfa_phone_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: EDGE - Add MFA Phone Sharing edges 2 | ID: N4J_EDGE_ADD_MFAPHONESHARING 3 | Description: Checks for accounts with the same MFA PhoneNumber and adds a MfaPhoneSharing edge. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (user1:AZUser) 12 | WHERE user1.MfaPhoneNumber IS NOT NULL AND user1.MfaPhoneNumber <> "" 13 | WITH user1.MfaPhoneNumber AS phoneNumber, COLLECT(user1) AS users 14 | UNWIND users AS u1 15 | UNWIND users AS u2 16 | WITH u1, u2 17 | WHERE id(u1) < id(u2) 18 | MERGE (u1)-[r:MfaPhoneSharing]-(u2) 19 | SET r.enforced = false 20 | Targets: [] -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_aad_user_to_high_value.yml: -------------------------------------------------------------------------------- 1 | Name: AAD User with path to high-value assets 2 | ID: N4J_AAD_User_to_HighValue 3 | Description: Counts all direct and nested shortest paths to high-value nodes from all users. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x {highvalue:true}) 12 | WITH x.objectid as ObjectID, x.name as Name 13 | MATCH (y {objectid:ObjectID}) WHERE NOT y:AZUser AND NOT y:Group AND NOT y:Domain 14 | MATCH (u:AZUser) 15 | WITH Name, COUNT(shortestPath((u)-[]->(y))) as Direct, COUNT(shortestPath((u)-[*1..]->(y))) as Nested, nodes(shortestPath((u)-[]->(y))) as DirectNames,nodes(shortestPath((u)-[*1..]->(y))) as NestedNames 16 | RETURN {Name: Name, Direct: Direct, DirectNames: [node in DirectNames | node.name],Nested: Nested, NestedNames: [node in NestedNames | node.name]} as info 17 | Targets: 18 | - Name: CSV 19 | Enabled: false 20 | Path: output/azuser_to_highvaluecount.csv 21 | - Name: Sentinel 22 | BHQuery: | 23 | MATCH (x {highvalue:true}) 24 | WITH x.objectid as ObjectID, x.name as Name 25 | MATCH (y {objectid:ObjectID}) WHERE NOT y:AZUser AND NOT y:Group AND NOT y:Domain 26 | MATCH (u:AZUser) 27 | RETURN shortestPath((u)-[*1..]->(y)) 28 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_aad_user_to_managed_identitiy.yml: -------------------------------------------------------------------------------- 1 | Name: AAD User with path to ManagedIdentity 2 | ID: N4J_AAD_User_to_ManagedIdentity 3 | Description: Gets all paths to managed identities from users and returns the users, roles and managed identities. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | This query disregards the GlobalAdmin role, as this is a high-value role and should be handled separately. 8 | Active: true # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH (u:AZUser), (m:AZServicePrincipal {serviceprincipaltype: 'ManagedIdentity'}) 13 | MATCH p = shortestPath((u)-[*..]->(m)) 14 | WHERE NONE(r IN relationships(p) WHERE type(r) = 'AZGlobalAdmin') 15 | WITH m, [n IN nodes(p) WHERE n:AZUser | n.name] AS InboundUsers, [r IN relationships(p) | TYPE(r)] AS Roles 16 | RETURN {ManagedIdentity: m.name, Roles: Roles, InboundUsers: InboundUsers} 17 | Targets: 18 | - Name: CSV 19 | Enabled: false 20 | Path: output/azuser_to_highvaluecount.csv 21 | - Name: Sentinel 22 | BHQuery: | 23 | MATCH (u:AZUser), (m:AZServicePrincipal {serviceprincipaltype: 'ManagedIdentity'}) 24 | MATCH p = shortestPath((u)-[*..]->(m)) 25 | WHERE NONE(r IN relationships(p) 26 | WHERE type(r) = 'AZGlobalAdmin') RETURN p 27 | Enabled: true 28 | - Name: Splunk 29 | Enabled: false -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_actionable_stats.yml: -------------------------------------------------------------------------------- 1 | Name: N4J Actionble Stats 2 | ID: N4J_Actionable_Stats 3 | Description: This action gets actionable BloodHound stats. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (r:User {owned:true}) RETURN {ownedUsers:COUNT(r)} as Stats 12 | UNION 13 | MATCH (r:Computer {owned:true}) RETURN {ownedComputers:COUNT(r)} as Stats 14 | UNION 15 | MATCH (r:Computer {exposed:true}) RETURN {exposedComputers:COUNT(r)} as Stats 16 | UNION 17 | MATCH (r:Computer {exploitable:true}) RETURN {exploitableComputers:COUNT(r)} as Stats 18 | UNION 19 | MATCH ()-[r:HasSession]->() RETURN {activeSessions:COUNT(r)} as Stats 20 | UNION 21 | MATCH ()-[r:HadSession]->() RETURN {formerSessions:COUNT(r)} as Stats 22 | Targets: 23 | - Name: Sentinel 24 | Enabled: true 25 | - Name: Markdown 26 | Enabled: false 27 | Path: report/ActionableStats-{{date}}.md -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_ad_chokepoints.yml: -------------------------------------------------------------------------------- 1 | Name: N4J AD Chokepoints 2 | ID: N4J_AD_Chokepoints 3 | Description: This action collects all paths to groups that can control many resources 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Based on a blog by sadprocess0r (https://falconforce.nl/bloodhound-calculating-ad-metrics-0x02/) 8 | Active: true # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: Neo4j 11 | Query: | 12 | CALL {MATCH (allU:User) RETURN COUNT(allU) AS TotalU} 13 | CALL {MATCH (allC:Computer) RETURN COUNT(allC) AS TotalC} 14 | MATCH (y:Group) 15 | CALL {WITH y 16 | OPTIONAL MATCH pIN=shortestPath((x:User)-[*1..]->(y)) RETURN x 17 | } 18 | CALL {WITH y 19 | OPTIONAL MATCH pOUT=shortestPath((y)-[*1..]->(z:Computer)) RETURN z 20 | } 21 | WITH DISTINCT y.name AS Target, TotalU, TotalC, 22 | COUNT(DISTINCT(x)) AS CountIN, 23 | COUNT(DISTINCT(z)) AS CountOUT 24 | WHERE CountOUT > 0 AND CountIN > 0 25 | RETURN {Target:Target, 26 | CountIn:CountIN, TotalUsers:TotalU, PercentIn:round(CountIN/toFloat(TotalU)*100,1), 27 | CountOut:CountOUT,TotalCount:TotalC, PercentOut:round(CountOUT/toFloat(TotalC)*100,1)} as info 28 | Targets: 29 | - Name: Sentinel 30 | Enabled: true 31 | - Name: Watchlist 32 | Enabled: true 33 | WatchlistName: FH_AD_Chokeopoints 34 | DisplayName: AD Chokepoints 35 | SearchKey: Target 36 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_ad_kerberoastable_users.yml: -------------------------------------------------------------------------------- 1 | Name: AD Kerberoastable Users 2 | ID: N4J_AD_Kerberoastable_Users 3 | Description: This action lists all Kerberoastable users. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (n:User) WHERE n.hasspn=true 12 | RETURN {Name: n.name, LastPasswordSet: n.pwdlastset, HighValue: n.highvalue, Sensitive: n.sensitive, Sid: n.objectid } 13 | Targets: 14 | - Name: CSV 15 | Enabled: false 16 | Path: output/ad_kerberoastable_users.csv 17 | - Name: Sentinel 18 | Enabled: false 19 | - Name: Watchlist 20 | Enabled: true 21 | WatchlistName: FH_Kerberoastable_Users 22 | DisplayName: Kerberoastable Users 23 | SearchKey: Name 24 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_ad_unconstrained_delegation.yml: -------------------------------------------------------------------------------- 1 | Name: AD Hosts with unconstrained delegation 2 | ID: N4J_AD_Unconstrained_delegation 3 | Description: This action lists all computers with unconstrained delegation. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (c:Computer {unconstraineddelegation:true}) 12 | RETURN {Name: c.name, Enabled: c.enabled, HighValue: c.highvalue, Created: c.whencreated} 13 | Targets: 14 | - Name: CSV 15 | Enabled: false 16 | Path: output/ad_unconstrained_delegation.csv 17 | - Name: Sentinel 18 | Enabled: false 19 | - Name: Watchlist 20 | Enabled: true 21 | WatchlistName: FH_AD_Unconstrained_delegation 22 | DisplayName: Unconstrained Delegation 23 | SearchKey: Name 24 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_ad_user_paths_to_da.yml: -------------------------------------------------------------------------------- 1 | Name: AD user with a path to Domain Admin 2 | ID: N4J_AD_User_paths_to_DA 3 | Description: Counts all shortest paths to Domain Admin from all users and lists the path and number of hops. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x:User) MATCH (y:Group) WHERE y.objectid ENDS WITH '-512' 12 | MATCH p = allShortestpaths((x)-[*1..]->(y)) 13 | WITH p, LENGTH(p) as hops, 14 | [a in NODES(p)|a.name] as nod, 15 | [b in NODES(p)|LABELS(b)[0]] as labl, 16 | [c IN RELATIONSHIPS(p)|TYPE(c)] as rels 17 | WHERE hops > 1 18 | RETURN {Hops:hops, Nodes:nod, Labels:labl, EdgeTypes:rels} 19 | LIMIT 25 20 | Targets: 21 | - Name: CSV 22 | Enabled: false 23 | Path: output/user_to_da_paths.csv 24 | - Name: Sentinel 25 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_ad_user_to_high_value.yml: -------------------------------------------------------------------------------- 1 | Name: AD User to high-value assets 2 | ID: N4J_AD_User_to_HighValue 3 | Description: Counts all direct and nested shortest paths to high-value nodes from all users. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x:Group) 12 | WHERE (coalesce(x.system_tags,"") CONTAINS "admin_tier_0" or x.highvalue=true) 13 | WITH x.objectid as ObjectID, x.name as Name 14 | MATCH (y:Group {objectid:ObjectID}) 15 | MATCH (u:User) 16 | WITH Name, COUNT(shortestPath((u)-[:MemberOf]->(y))) as Direct, COUNT(shortestPath((u)-[:MemberOf*1..]->(y))) as Nested, nodes(shortestPath((u)-[:MemberOf]->(y))) as DirectNames,nodes(shortestPath((u)-[:MemberOf*1..]->(y))) as NestedNames 17 | RETURN {Name: Name, Direct: Direct, DirectNames: [node in DirectNames | node.name],Nested: Nested, NestedNames: [node in NestedNames | node.name]} as info 18 | Targets: 19 | - Name: CSV 20 | Enabled: false 21 | Path: output/user_to_highvaluecount.csv 22 | - Name: Sentinel 23 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_ad_user_with_password_in_descr.yml: -------------------------------------------------------------------------------- 1 | Name: AD Users with a potential password in their description 2 | ID: N4J_AD_Users_with_password_in_description 3 | Description: This action lists all users that may have a password in their description. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (n:User) WHERE n.description =~ '.*((?i)pass|pw|:).*' 12 | RETURN {Name: n.name, Description: n.description ,LastPasswordSet: n.pwdlastset, HighValue: n.highvalue, Sensitive: n.sensitive } 13 | Targets: 14 | - Name: CSV 15 | Enabled: false 16 | Path: output/ad_users_with password_in_descr.csv 17 | - Name: Sentinel 18 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_azure_resource_owner_list.yml: -------------------------------------------------------------------------------- 1 | Name: Azure Resource Owner list 2 | ID: N4J_Azure_Owners_list 3 | Description: This action lists all Azure resources and their owners. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | This is intended to be used as an enrichment list to quantify the impact of an alert on a user that owns a resource. 8 | Active: true # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH p = (a)-[r:AZOwns|AZUserAccessAdministrator]->(b) 13 | RETURN {Name:a.name , Count:COUNT(b.name), Role:type(r), Resources:COLLECT(b.name)} 14 | Targets: 15 | - Name: CSV 16 | Enabled: false 17 | Path: output/azowners.csv 18 | - Name: Sentinel 19 | Enabled: false -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_azure_subscription_owners.yml: -------------------------------------------------------------------------------- 1 | Name: Azure Subscription Owner list 2 | ID: N4J_Azure_Subscription_Owners 3 | Description: This action lists all Azure subscriptions and their owners. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: false # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH p = (n)-[r:AZOwns|AZUserAccessAdministrator]->(g:AZSubscription) 12 | RETURN {Name:g.name , Count:COUNT(g.name), Role:type(r), Owners:COLLECT(n.name)} 13 | Targets: 14 | - Name: CSV 15 | Enabled: false 16 | Path: output/azusers_subscription_owners.csv 17 | - Name: Sentinel 18 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_azure_tier0_or_tier1_assigned_roles.yml: -------------------------------------------------------------------------------- 1 | Name: N4J Azure Tier0 or Tier1 Assigned Roles 2 | ID: N4j_Azure_Tier0_or_Tier1_Assigned_Roles 3 | Description: Collects all Azure roles assigned to Tier0 or Tier1 groups and creates a relationship between the role and the entity in Neo4j. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (a:AZRole)-[r:HasConsent|AZRunsAs|AZHasRole|AZGlobalAdmin|AZPrivilegedRoleAdmin|AZOwns]-(b) 12 | WHERE (a.system_tags CONTAINS "admin_tier_0" or a.system_tags CONTAINS "admin_tier_1") 13 | RETURN {Entity: b.name, Role: a.name, Relation: type(r)} as info 14 | Targets: 15 | - Name: CSV 16 | Enabled: false 17 | Path: output/azure_tier0_and_tier1_assigned.csv 18 | - Name: Sentinel 19 | BHQuery: | 20 | MATCH b=(a:AZRole)-[r:HasConsent|AZRunsAs|AZHasRole|AZGlobalAdmin|AZPrivilegedRoleAdmin|AZOwns]-() 21 | WHERE (a.system_tags CONTAINS "admin_tier_0" or a.system_tags CONTAINS "admin_tier_1") 22 | RETURN b 23 | Enabled: true 24 | - Name: Watchlist 25 | Enabled: true 26 | WatchlistName: FH_Azure_Tier0_or_Tier1_Assigned_Roles 27 | DisplayName: Azure Tier0 or Tier1 Assigned Roles 28 | SearchKey: Entity 29 | Overwrite: true 30 | - Name: Markdown 31 | Enabled: true 32 | Path: report/{{date}}/Azure_Tier0_or_Tier1_Assigned_Roles.md -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_azure_vms_with_managed_identity.yml: -------------------------------------------------------------------------------- 1 | Name: Azure VMs with a Managed Identity 2 | ID: N4J_Azure_VM_Managed_Identity 3 | Description: This action lists VMs with an assigned Managed Identity. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (c:AZVM)-[r:AZManagedIdentity]->(n) 12 | RETURN {Name:c.name , Count:COUNT(n.name), Role:type(r), Identities:COLLECT(n.name)} 13 | Targets: 14 | - Name: Watchlist 15 | Enabled: true 16 | WatchlistName: FH_AZ_VM_Managed_Identity 17 | DisplayName: VMs with Managed Identity 18 | SearchKey: Name 19 | Overwrite: true 20 | - Name: Sentinel 21 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_db_stats.yml: -------------------------------------------------------------------------------- 1 | Name: N4J DB Stats 2 | ID: N4J_DB_Stats 3 | Description: This action gets all relevant unit counts from BloodHound. 4 | Author: FalconForce 5 | Version: '0.8' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x) RETURN {Class:'Node',Type:'All',Count:COUNT(x)} AS Stats 12 | UNION 13 | MATCH (x) WITH x UNWIND LABELS(x) AS labels WITH DISTINCT labels AS type, COUNT(x) AS count 14 | RETURN {Class:'Node',Type:type,Count:count} AS Stats ORDER BY type 15 | UNION 16 | MATCH ()-[r]->() RETURN {Class:'Edge',Type:'All',Count:COUNT(r)} AS Stats 17 | UNION 18 | MATCH ()-[r{isazure:true}]->() RETURN {Class:'Edge',Type:'Azure',Count:COUNT(r)} AS Stats 19 | UNION 20 | MATCH ()-[r]->() WITH DISTINCT TYPE(r) AS type, COUNT(r) AS count 21 | RETURN {Class:'Edge',Type:type,Count:count} AS Stats ORDER BY type 22 | UNION 23 | MATCH ()-[r{isacl:true}]->() RETURN {Class:'Edge',Type:'ACL',Count:COUNT(r)} AS Stats 24 | Targets: 25 | - Name: Sentinel 26 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_domain_admin_session_on_non_dc.yml: -------------------------------------------------------------------------------- 1 | Name: AD Domain Admin Session on Non-DC 2 | ID: N4J_AD_DA_Session_on_Non_DC 3 | Description: This action looks for Domain Admin sessions on non-domain controllers. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (dc:Computer)-[:MemberOf]->(g:Group) 12 | WHERE g.name CONTAINS "DOMAIN CONTROLLERS" 13 | WITH COLLECT(dc.name) as dcs MATCH (c:Computer) 14 | WHERE NOT c.name in dcs 15 | MATCH p=(c)-[:HasSession]->(n:User)-[:MemberOf]->(g:Group) 16 | WHERE g.name STARTS WITH "DOMAIN ADMINS" 17 | RETURN {Name: n.name, LoggedOnTo: c.name, HighValue: n.highvalue, Sensitive: n.sensitive, Exploitable: c.exploitable, Exposed: c.exposed, Owned: c.owned} 18 | Targets: 19 | - Name: Sentinel 20 | Enabled: true 21 | - Name: Watchlist 22 | Enabled: true 23 | WatchlistName: FH_DA_Session_on_Non_DC 24 | DisplayName: Domain Admin Session on Non-DC 25 | SearchKey: Name 26 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_domaincontrollers.yml: -------------------------------------------------------------------------------- 1 | Name: N4J Domain Controllers 2 | ID: N4J_DomainControllers 3 | Description: This action lists BloodHound domain controllers and pushes list of names/SIDs to Sentinel watchlist. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (y:Group) WHERE y.objectid ENDS WITH '-516' MATCH p=shortestPath((x)-[:MemberOf*1..]->(y)) WHERE x<>y 12 | RETURN { 13 | Type:Labels(x)[0], 14 | Name:x.name, 15 | ObjectID:x.objectid, 16 | Distance:LENGTH(p), 17 | Parent:(NODES(p))[1].name 18 | } 19 | Targets: 20 | - Name: CSV 21 | Enabled: false 22 | Path: output/domain_controllers.csv 23 | - Name: Sentinel 24 | Enabled: true 25 | - Name: Watchlist 26 | Enabled: true 27 | WatchlistName: FH_DomainControllers 28 | DisplayName: AD Domain Controllers 29 | SearchKey: Name 30 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_exploitable_device_to_high_value.yml: -------------------------------------------------------------------------------- 1 | Name: N4J exploitable device with path to high-value nodes 2 | ID: N4J_Exploitable_Device_to_HighValue 3 | Description: Counts all direct and nested shortest paths to high-value nodes from devices that have vulnerabilities that have publicly available exploits. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x:Group) 12 | WHERE (coalesce(x.system_tags,"") CONTAINS "admin_tier_0" or x.highvalue=true) 13 | WITH x.objectid as ObjectID, x.name as Name 14 | MATCH (y:Group {objectid:ObjectID}) 15 | MATCH (u:Computer {exploitable:true}) 16 | WITH Name, COUNT(shortestPath((u)-[]->(y))) as Direct, COUNT(shortestPath((u)-[*1..]->(y))) as Nested, nodes(shortestPath((u)-[]->(y))) as DirectNames, nodes(shortestPath((u)-[*1..]->(y))) as NestedNames 17 | WHERE Nested <> 0 or Direct <> 0 18 | RETURN {Name: Name, Direct: Direct, DirectNames: [node in DirectNames | node.name],Nested: Nested, NestedNames: [node in NestedNames | node.name]} as info 19 | Targets: 20 | - Name: CSV 21 | Enabled: false 22 | Path: output/exploitable_to_highvaluecount.csv 23 | - Name: Sentinel 24 | BHQuery: | 25 | MATCH (x:Group) 26 | WHERE (coalesce(x.system_tags,"") CONTAINS "admin_tier_0" or x.highvalue=true) 27 | WITH x.objectid as ObjectID, x.name as Name 28 | MATCH (y:Group {objectid:ObjectID}) 29 | MATCH (u:Computer {exploitable:true}) 30 | MATCH a=shortestPath((u)-[*1..4]->(y)) RETURN a 31 | Enabled: true 32 | - Name: Watchlist 33 | Enabled: true 34 | WatchlistName: FH_Exploitable_Device_to_HighValue 35 | DisplayName: Exploitable Device to HighValue 36 | SearchKey: Name 37 | Overwrite: true 38 | - Name: ADX 39 | Enabled: false 40 | Table: FalconHound 41 | - Name: Markdown 42 | Enabled: true 43 | Path: report/{{date}}/exploitable_to_highvaluecount.md -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_exposed_device_to_high_value.yml: -------------------------------------------------------------------------------- 1 | Name: N4J exposed device with path to high-value nodes 2 | ID: N4J_Exposed_Device_to_HighValue 3 | Description: Counts all direct and nested shortest paths to high-value nodes from devices with exposed ports to the internet. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x:Group) 12 | WHERE (coalesce(x.system_tags,"") CONTAINS "admin_tier_0" or x.highvalue=true) 13 | WITH x.objectid as ObjectID, x.name as Name 14 | MATCH (y:Group {objectid:ObjectID}) 15 | MATCH (u:Computer {exposed:true}) 16 | WITH Name, COUNT(shortestPath((u)-[]->(y))) as Direct, COUNT(shortestPath((u)-[*1..]->(y))) as Nested, nodes(shortestPath((u)-[]->(y))) as DirectNames, nodes(shortestPath((u)-[*1..]->(y))) as NestedNames 17 | WHERE Nested <> 0 or Direct <> 0 18 | RETURN {Name: Name, Direct: Direct, DirectNames: [node in DirectNames | node.name],Nested: Nested, NestedNames: [node in NestedNames | node.name]} as info 19 | Targets: 20 | - Name: CSV 21 | Enabled: false 22 | Path: output/exposed_to_highvaluecount.csv 23 | - Name: Sentinel 24 | BHQuery: | 25 | MATCH (x:Group) 26 | WHERE (coalesce(x.system_tags,"") CONTAINS "admin_tier_0" or x.highvalue=true) 27 | WITH x.objectid as ObjectID, x.name as Name 28 | MATCH (y:Group {objectid:ObjectID}) 29 | MATCH (u:Computer {exposed:true}) 30 | MATCH a=shortestPath((u)-[*1..5]->(y)) RETURN a 31 | Enabled: true 32 | - Name: Watchlist 33 | Enabled: true 34 | WatchlistName: FH_Exposed_Device_to_HighValue 35 | DisplayName: Exposed Device to HighValue 36 | SearchKey: Name 37 | Overwrite: true 38 | - Name: ADX 39 | Enabled: false 40 | Table: FalconHound 41 | BatchSize: 1000 -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_external_azusers_high_priv.yml: -------------------------------------------------------------------------------- 1 | Name: AAD external users with high privileges 2 | ID: N4J_External_AZUsers_High_Priv 3 | Description: This action lists all external users with sensitive roles or high privileges. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH p = (n:AZUser)-[r]->(g) 12 | WHERE n.name contains "#EXT#" AND NOT(r:AZMemberOf) 13 | RETURN {Name:n.name , Count:COUNT(g.name), Role:type(r), RoleAssignments:COLLECT(g.name)} 14 | Targets: 15 | - Name: CSV 16 | Enabled: false 17 | Path: output/azusers_highprivs.csv 18 | - Name: Sentinel 19 | Enabled: true 20 | - Name: Watchlist 21 | Enabled: true 22 | WatchlistName: FH_Externals_High_Priv 23 | DisplayName: FH Externals with High Privs 24 | SearchKey: Name 25 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_external_serviceprincipal_high_priv.yml: -------------------------------------------------------------------------------- 1 | Name: External Service Principal with High Privileges 2 | ID: N4J_Azure_EXT_SP_HIGH_PRIV 3 | Description: This action lists all externally owned Service Principals with high privileges in Azure 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: More information about the potential impact here > https://posts.specterops.io/microsoft-breach-how-can-i-see-this-in-bloodhound-33c92dca4c65 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (t:AZTenant) 12 | WITH t.tenantid as TENANTID 13 | MATCH p=(s)-[:AZContributor|AZMGGrantAppRoles|AZPrivilegedRoleAdmin|AZMGAddOwner|AZMGApplication_ReadWrite_All|AZMGDirectory_ReadWrite_All|AZMGRoleManagement_ReadWrite_Directory|AZUserAccessAdministrator|AZOwner|AZMGGrantRole|AZMGAddMember|AZMGAddOwner|AZMGAddSecret|AZHasRole|AZMemberOf|HasConsent|AZResetPassword*1..]->(r:AZRole) 14 | WHERE (coalesce(s.system_tags,"") CONTAINS "admin_tier_0" or s.highvalue=true) 15 | AND NOT toUpper(s.appownerorganizationid) = TENANTID 16 | AND s.appownerorganizationid CONTAINS "-" 17 | RETURN {SPName:s.name,Roles:COLLECT(r.name), Count:COUNT(r.name)} as info 18 | Targets: 19 | - Name: Sentinel 20 | Enabled: true 21 | BHQuery: | 22 | MATCH (t:AZTenant) 23 | WITH t.tenantid as TENANTID 24 | MATCH p=(s)-[:AZContributor|AZMGGrantAppRoles|AZPrivilegedRoleAdmin|AZMGAddOwner|AZMGApplication_ReadWrite_All|AZMGDirectory_ReadWrite_All|AZMGRoleManagement_ReadWrite_Directory|AZUserAccessAdministrator|AZOwner|AZMGGrantRole|AZMGAddMember|AZMGAddOwner|AZMGAddSecret|AZHasRole|AZMemberOf|HasConsent|AZResetPassword*1..]->(r:AZRole) 25 | WHERE (coalesce(s.system_tags,"") CONTAINS "admin_tier_0" or s.highvalue=true) 26 | AND NOT toUpper(s.appownerorganizationid) = TENANTID 27 | AND s.appownerorganizationid CONTAINS "-" 28 | RETURN p 29 | - Name: Watchlist 30 | Enabled: true 31 | WatchlistName: FH_Azure_EXT_SP_HIGH_PRIV 32 | DisplayName: Azure External SP High Privileges 33 | SearchKey: SPName 34 | Overwrite: true 35 | -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_highvalue_resource_with_alerts.yml: -------------------------------------------------------------------------------- 1 | Name: High value resource with alerts 2 | ID: N4J_Highvalue_Resource_with_alerts 3 | Description: Searches for TIER0 or high value nodes with alerts 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (n {owned:true}) 12 | WHERE (coalesce(n.system_tags,"") CONTAINS "admin_tier_0" or n.highvalue=true) 13 | RETURN {Name: n.name, AlertIds: n.alertid, Description:n.description, NodeType:HEAD([label IN LABELS(n) WHERE label <> "base"])} as info 14 | Targets: 15 | - Name: Sentinel 16 | Enabled: true 17 | BHQuery: | 18 | MATCH (n {owned:true}) 19 | WHERE (coalesce(n.system_tags,"") CONTAINS "admin_tier_0" or n.highvalue=true) 20 | RETURN n 21 | - Name: Watchlist 22 | Enabled: true 23 | WatchlistName: FH_HighValue_Resource_with_alerts 24 | DisplayName: HighValue Resource with Alerts 25 | SearchKey: Name 26 | Overwrite: true 27 | -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_kerberoastable_user_with_session.yml: -------------------------------------------------------------------------------- 1 | Name: Kerberoastable User with a Session on a Computer 2 | ID: N4J_Kerberoastable_User_with_Session 3 | Description: Searches for kerberoastable users with a session on a computer 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH p=(k:User {hasspn:true})-[r:HasSession]-(c:Computer) 12 | RETURN {UserName: k.name, Computer: c.name, UserHasAlerts: k.owned, ComputerHasAlerts: c.owned, ComputerIsExposed: c.exposed, ComputerIsVulnerable: c.exploitable } as info 13 | Targets: 14 | - Name: Sentinel 15 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_long_inactive_user_with_session.yml: -------------------------------------------------------------------------------- 1 | Name: Long inactive User with a Session on a Computer 2 | ID: N4J_Long_Inactive_User_with_Session 3 | Description: Searches for users with a session on a computer that have not been seen in the past 90 days 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Requires SharpHound runs to be run at least once every 90 days to be reliable, since this property is not updated by FalconHound. Preferably more frequent. 8 | Active: true # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH (u:User) WHERE u.lastlogon > (datetime().epochseconds - (90 * 86400)) and NOT u.lastlogon IN [-1.0, 0.0] 13 | MATCH (u)-[r:HasSession]-(c:Computer) 14 | RETURN {UserName: u.name, LastLogonSeen: u.lastlogon, UserHasAlerts: u.owned, LoggedOnComputer: c.name} 15 | Targets: 16 | - Name: Sentinel 17 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_mfa_device_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: MFA AuthenticatorDevice Sharing 2 | ID: N4J_MFA_DEVICE_SHARING 3 | Description: MFA AuthenticatorDevice Sharing 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (u:AZUser) 12 | WHERE u.MfaAuthenticatorDeviceId IS NOT NULL AND u.MfaAuthenticatorDeviceId <> '' 13 | WITH u.MfaAuthenticatorDeviceId AS deviceId, u.MfaAuthenticatorDeviceName as deviceName, collect(u.name) AS userNames, COUNT(u) AS UserCount 14 | WHERE UserCount > 1 15 | RETURN {DeviceId: deviceId, DeviceName: deviceName ,UserNames: userNames, UserCount: UserCount} AS info 16 | ORDER BY UserCount DESC 17 | Targets: 18 | - Name: Sentinel 19 | Enabled: true 20 | - Name: Watchlist 21 | Enabled: true 22 | WatchlistName: FH_MFA_Shared_AuthenticatorDevice_Users 23 | DisplayName: MFA Shared AuthenticatorDevice Users 24 | SearchKey: DeviceId 25 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_mfa_email_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: MFA Email Sharing 2 | ID: N4J_MFA_EMAIL_SHARING 3 | Description: MFA Email Sharing 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (u:AZUser) 12 | WHERE u.MfaEmailAddress IS NOT NULL AND u.MfaEmailAddress <> '' 13 | WITH u.MfaEmailAddress AS emailAddress, collect(u.name) AS userNames, COUNT(u) AS UserCount 14 | WHERE UserCount > 1 15 | RETURN {EmailAddress: emailAddress, UserNames: userNames, UserCount: UserCount} AS info 16 | ORDER BY UserCount DESC 17 | Targets: 18 | - Name: Sentinel 19 | Enabled: true 20 | - Name: Watchlist 21 | Enabled: true 22 | WatchlistName: FH_MFA_Shared_Email_Users 23 | DisplayName: MFA Shared Email Users 24 | SearchKey: EmailAddress 25 | Overwrite: true 26 | -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_mfa_phone_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: MFA Phone Sharing 2 | ID: N4J_MFA_PHONE_SHARING 3 | Description: MFA Phone Sharing 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (u:AZUser) 12 | WHERE u.MfaPhoneNumber IS NOT NULL AND u.MfaPhoneNumber <> '' 13 | WITH u.MfaPhoneNumber AS phoneNumber, collect(u.name) AS userNames, COUNT(u) AS UserCount 14 | WHERE UserCount > 1 15 | RETURN {PhoneNumber: phoneNumber, UserNames: userNames, UserCount: UserCount} AS info 16 | ORDER BY UserCount DESC 17 | Targets: 18 | - Name: Sentinel 19 | Enabled: true 20 | - Name: Watchlist 21 | Enabled: true 22 | WatchlistName: FH_MFA_Shared_PhoneNumber_Users 23 | DisplayName: MFA Shared PhoneNumber Users 24 | SearchKey: PhoneNumber 25 | Overwrite: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_owned_device_to_high_value.yml: -------------------------------------------------------------------------------- 1 | Name: AD owned device with a path to high-value assets 2 | ID: N4J_Owned_Device_to_HighValue 3 | Description: Counts all direct and nested shortest paths to high-value nodes from owned users. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x:Group{highvalue:true}) 12 | WITH x.objectid as ObjectID, x.name as Name 13 | MATCH (y:Group {objectid:ObjectID}) 14 | MATCH (u:Computer {owned:true}) 15 | WITH Name, COUNT(shortestPath((u)-[:MfaDeviceSharing|MfaEmailSharing|MfaPhoneNumberSharing|MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession]->(y))) as Direct, COUNT(shortestPath((u)-[:MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession*1..]->(y))) as Nested, nodes(shortestPath((u)-[:MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession]->(y))) as DirectNames, nodes(shortestPath((u)-[:MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession*1..]->(y))) as NestedNames 16 | WHERE Nested <> 0 or Direct <> 0 17 | RETURN {Name: Name, Direct: Direct, DirectNames: [node in DirectNames | node.name],Nested: Nested, NestedNames: [node in NestedNames | node.name]} as info 18 | Targets: 19 | - Name: CSV 20 | Enabled: false 21 | Path: output/owned_device_to_highvaluecount.csv 22 | - Name: Sentinel 23 | BHQuery: | 24 | MATCH a=shortestPath((c:Computer {owned:true})-[*..]->({highvalue:TRUE})) RETURN a 25 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_owned_to_keyvault.yml: -------------------------------------------------------------------------------- 1 | Name: AZ owned resource with a path to a Keyvaults 2 | ID: N4J_Owned_to_KeyVaults 3 | Description: Counts all direct and nested paths to Keyvaults from owned resources. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (u {owned: true})-[*1..3]->(g:AZKeyVault) 12 | WITH u.Name as Name, COUNT(g.Name) as KeyVaultCount, COLLECT(g.Name) as KeyVaults 13 | RETURN {Name: Name, KeyVaultCount: KeyVaultCount, KeyVaults: KeyVaults} as info 14 | Targets: 15 | - Name: Sentinel 16 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_owned_user_to_high_value.yml: -------------------------------------------------------------------------------- 1 | Name: Owned user with a path to high-value nodes 2 | ID: N4J_Owned_User_to_HighValue 3 | Description: Counts all direct and nested shortest paths to high-value nodes from owned users. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (x:Group) 12 | WHERE (coalesce(x.system_tags,"") CONTAINS "admin_tier_0" OR x.highvalue=true) 13 | WITH x.objectid AS ObjectID, x.name AS Name 14 | MATCH (y:Group {objectid:ObjectID}) 15 | WITH Name, y 16 | // Match Users with owned:true 17 | OPTIONAL MATCH (u:User {owned:true}) 18 | WITH Name, y, u 19 | WHERE u IS NOT NULL 20 | WITH Name, y, COLLECT(u) AS Users 21 | // Match AZUsers with owned:true 22 | OPTIONAL MATCH (u:AZUser {owned:true}) 23 | WITH Name, y, Users + COLLECT(u) AS AllUsers 24 | UNWIND AllUsers AS AllU 25 | WITH Name, COUNT(shortestPath((AllU)-[:MfaDeviceSharing|MfaEmailSharing|MfaPhoneNumberSharing|MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession]->(y))) as Direct, COUNT(shortestPath((AllU)-[:MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession*1..]->(y))) as Nested, nodes(shortestPath((AllU)-[:MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession]->(y))) as DirectNames, nodes(shortestPath((AllU)-[:MemberOf|HasSession|AdminTo|AllExtendedRights|AddMember|ForceChangePassword|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|CanRDP|ExecuteDCOM|AllowedToDelegate|ReadLAPSPassword|Contains|GPLink|AddAllowedToAct|AllowedToAct|WriteAccountRestrictions|SQLAdmin|ReadGMSAPassword|HasSIDHistory|CanPSRemote|SyncLAPSPassword|DumpSMSAPassword|AZMGGrantRole|AZMGAddSecret|AZMGAddOwner|AZMGAddMember|AZMGGrantAppRoles|AZNodeResourceGroup|AZWebsiteContributor|AZLogicAppContributo|AZAutomationContributor|AZAKSContributor|AZAddMembers|AZAddOwner|AZAddSecret|AZAvereContributor|AZContains|AZContributor|AZExecuteCommand|AZGetCertificates|AZGetKeys|AZGetSecrets|AZGlobalAdmin|AZHasRole|AZManagedIdentity|AZMemberOf|AZOwns|AZPrivilegedAuthAdmin|AZPrivilegedRoleAdmin|AZResetPassword|AZUserAccessAdministrator|AZAppAdmin|AZCloudAppAdmin|AZRunsAs|AZKeyVaultContributor|AZVMAdminLogin|AZVMContributor|AZLogicAppContributor|AddSelf|WriteSPN|AddKeyCredentialLink|DCSync|HadSession*1..]->(y))) as NestedNames 26 | WHERE Nested <> 0 or Direct <> 0 27 | RETURN {Name: Name, Direct: Direct, DirectNames: [node in DirectNames | node.name],Nested: Nested, NestedNames: [node in NestedNames | node.name]} as info 28 | Targets: 29 | - Name: CSV 30 | Enabled: false 31 | Path: output/owned_user_to_highvaluecount.csv 32 | - Name: Sentinel 33 | BHQuery: | 34 | MATCH a=shortestPath((u:User {owned:true})-[*..]->({highvalue:TRUE})) RETURN a 35 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_sensitive_resource_with_alerts.yml: -------------------------------------------------------------------------------- 1 | Name: Sensitive resource with alerts 2 | ID: N4J_Sensitive_Resource_with_alerts 3 | Description: Searches for sensitive nodes with alerts 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | SourcePlatform: Neo4j 10 | Query: | 11 | MATCH (n {sensitive:true}) WHERE n.owned = true 12 | RETURN {Name: n.name, AlertIds: n.alertid} as info 13 | Targets: 14 | - Name: Sentinel 15 | Enabled: true -------------------------------------------------------------------------------- /actions/10-Neo4j/n4j_user_session_with_additional_admin_session.yml: -------------------------------------------------------------------------------- 1 | Name: AD user with session on a machine with their user and their admin account 2 | ID: N4J_User_Session_with_Additional_Admin_Session 3 | Description: Looks for all users that also have a session with their admin account. By default, this is the same username prepended by "ADM". 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: | 7 | Change the prepend "ADM" to match your naming convention. 8 | Active: true # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH (u:User)-[:HAS_SESSION]->(s1:Session)-[:ON_MACHINE]->(m:Computer) 13 | WITH u, s1, m 14 | MATCH (m)-[:HAS_SESSION]->(s2:Session) 15 | WHERE s2.username = "ADM" + s1.username 16 | RETURN {Name: u.name, Computer: m.name, AdminName: s2.name} 17 | Targets: 18 | - Name: CSV 19 | Enabled: false 20 | Path: output/user_witn_additional_admin_session.csv 21 | - Name: Sentinel 22 | Enabled: true -------------------------------------------------------------------------------- /actions/11-N4J-Reporting/n4j-report-1_domainadmins.yml: -------------------------------------------------------------------------------- 1 | Name: Get a list of Domain Admins 2 | ID: N4J_REPORT_DomainAdmins 3 | Description: Get a list of Domain Admins. 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | Type: Report 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH (n:Group) 13 | WHERE n.objectid =~ '(?i)S-1-5.*-512' 14 | WITH n MATCH (n)<-[r:MemberOf*1..]-(m) 15 | RETURN {Name: m.name , ObjectID: m.objectid} as info 16 | Targets: 17 | - Name: CSV 18 | Enabled: true 19 | Path: report/{{date}}/BH-DA.1_pathToDA_{{date}}.csv 20 | - Name: ADX 21 | Enabled: false 22 | Table: FalconHound 23 | BatchSize: 1000 24 | - Name: Markdown 25 | Enabled: true 26 | Path: report/{{date}}/BH-DA.1_pathToDA_{{date}}.md 27 | - Name: HTML 28 | Enabled: true 29 | Path: report/{{date}}/BH-DA.1_pathToDA_{{date}}.html -------------------------------------------------------------------------------- /actions/11-N4J-Reporting/n4j-report-AAD_Access_to_highvalue_percentages.yml: -------------------------------------------------------------------------------- 1 | Name: Get the amount of users with a path to Tier 0 and High Value Roles 2 | ID: N4J_REPORT_AAD_Access_to_highvalue_percentages 3 | Description: Gets the amount of users with a path to Tier 0 and High Value Roles 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Based on a blog by @sadprocess0r. (https://falconforce.nl/bloodhound-calculating-ad-metrics-0x02/) 8 | Active: false # Enable to run this action 9 | Debug: false # Enable to see query results in the console 10 | Type: Report 11 | SourcePlatform: Neo4j 12 | Query: | 13 | CALL {MATCH (all:AZUser) RETURN COUNT(all) AS Total} 14 | MATCH (x:AZUser) 15 | MATCH (y:AZRole) 16 | WHERE (coalesce(y.system_tags,"") CONTAINS "admin_tier_0" or y.highvalue=true) 17 | MATCH p=shortestPath((x)-[*1..]->(y)) 18 | WITH y.name AS Target, COUNT(p) AS Count, Total, 19 | COLLECT(length(p)) AS lengthList 20 | RETURN {Target:Target, Count:Count, Total:Total, 21 | Percentage:round(Count/toFloat(Total)*100,2), 22 | avgHops:round(reduce(s=0,l in lengthList|s+l)/toFloat(SIZE(lengthList)),2)} as info 23 | Targets: 24 | - Name: CSV 25 | Enabled: true 26 | Path: report/{{date}}/BH-AccessToAADHighValue_{{date}}.csv 27 | - Name: Markdown 28 | Enabled: true 29 | Path: report/{{date}}/BH-AccessToAADHighValue_{{date}}.md 30 | - Name: HTML 31 | Enabled: true 32 | Path: report/{{date}}/BH-AccessToAADHighValue_{{date}}.html -------------------------------------------------------------------------------- /actions/11-N4J-Reporting/n4j-report-AD_Access_to_highvalue_percentages.yml: -------------------------------------------------------------------------------- 1 | Name: Get the amount of users with a path to Tier 0 and High Value Groups 2 | ID: N4J_REPORT_AD_Access_to_highvalue_percentages 3 | Description: Gets the amount of users with a path to Tier 0 and High Value Groups 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | This action gets a list of all Domain Admins and their groups, and calculates the percentage of users that have a path to a Domain Admin. 8 | The action also calculates the average distance of the paths to the Domain Admins. 9 | Based on a blog by @sadprocess0r. (https://falconforce.nl/bloodhound-calculating-ad-metrics-0x02/) 10 | Active: false # Enable to run this action 11 | Debug: false # Enable to see query results in the console 12 | Type: Report 13 | SourcePlatform: Neo4j 14 | Query: | 15 | CALL {MATCH (all:User) RETURN COUNT(all) AS Total} 16 | MATCH (x:User) 17 | MATCH (y:Group) 18 | WHERE (coalesce(y.system_tags,"") CONTAINS "admin_tier_0" or y.highvalue=true) 19 | MATCH p=shortestPath((x)-[*1..]->(y)) 20 | WITH y.name AS Target, COUNT(p) AS Count, Total, 21 | COLLECT(length(p)) AS lengthList 22 | RETURN {Target:Target, Count:Count, Total:Total, 23 | Percentage:round(Count/toFloat(Total)*100,2), 24 | avgHops:round(reduce(s=0,l in lengthList|s+l)/toFloat(SIZE(lengthList)),2)} as info 25 | Targets: 26 | - Name: CSV 27 | Enabled: true 28 | Path: report/{{date}}/BH-AccessToADHighValue_{{date}}.csv 29 | - Name: Markdown 30 | Enabled: true 31 | Path: report/{{date}}/BH-AccessToADHighValue_{{date}}.md 32 | - Name: HTML 33 | Enabled: true 34 | Path: report/{{date}}/BH-AccessToADHighValue_{{date}}.html -------------------------------------------------------------------------------- /actions/11-N4J-Reporting/n4j-report-MFA_Device_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: MFA Device Sharing 2 | ID: N4J_REPORT_MFA_DEVICE_SHARING 3 | Description: MFA Device Sharing 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | Type: Report 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH (u:AZUser) 13 | WHERE u.MfaAuthenticatorDeviceId IS NOT NULL AND u.MfaAuthenticatorDeviceId <> '' 14 | WITH u.MfaAuthenticatorDeviceId AS deviceId, u.MfaAuthenticatorDeviceName as deviceName, collect(u.name) AS userNames, COUNT(u) AS UserCount 15 | WHERE UserCount > 1 16 | RETURN {DeviceId: deviceId, DeviceName: deviceName ,UserNames: userNames, UserCount: UserCount} AS info 17 | ORDER BY UserCount DESC 18 | Targets: 19 | - Name: CSV 20 | Enabled: true 21 | Path: report/{{date}}/BH-MFA_DEVICE_SHARING_{{date}}.csv 22 | - Name: Markdown 23 | Enabled: true 24 | Path: report/{{date}}/BH-MFA_DEVICE_SHARING_{{date}}.md 25 | - Name: HTML 26 | Enabled: true 27 | Path: report/{{date}}/BH-MFA_DEVICE_SHARING_{{date}}.html -------------------------------------------------------------------------------- /actions/11-N4J-Reporting/n4j-report-MFA_Email_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: MFA Email Sharing 2 | ID: N4J_REPORT_MFA_EMAIL_SHARING 3 | Description: MFA Email Sharing 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | Type: Report 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH (u:AZUser) 13 | WHERE u.MfaEmailAddress IS NOT NULL AND u.MfaEmailAddress <> '' 14 | WITH u.MfaEmailAddress AS emailAddress, collect(u.name) AS userNames, COUNT(u) AS UserCount 15 | WHERE UserCount > 1 16 | RETURN {EmailAddress: emailAddress, UserNames: userNames, UserCount: UserCount} AS info 17 | ORDER BY UserCount DESC 18 | Targets: 19 | - Name: CSV 20 | Enabled: true 21 | Path: report/{{date}}/BH-MFA_EMAIL_SHARING_{{date}}.csv 22 | - Name: Markdown 23 | Enabled: true 24 | Path: report/{{date}}/BH-MFA_EMAIL_SHARING_{{date}}.md 25 | - Name: HTML 26 | Enabled: true 27 | Path: report/{{date}}/BH-MFA_EMAIL_SHARING_{{date}}.html -------------------------------------------------------------------------------- /actions/11-N4J-Reporting/n4j-report-MFA_Phone_sharing.yml: -------------------------------------------------------------------------------- 1 | Name: MFA Phone Sharing 2 | ID: N4J_REPORT_MFA_PHONE_SHARING 3 | Description: MFA Phone Sharing 4 | Author: FalconForce 5 | Version: '1.0' 6 | Info: |- 7 | Active: true # Enable to run this action 8 | Debug: false # Enable to see query results in the console 9 | Type: Report 10 | SourcePlatform: Neo4j 11 | Query: | 12 | MATCH (u:AZUser) 13 | WHERE u.MfaPhoneNumber IS NOT NULL AND u.MfaPhoneNumber <> '' 14 | WITH u.MfaPhoneNumber AS phoneNumber, collect(u.name) AS userNames, COUNT(u) AS UserCount 15 | WHERE UserCount > 1 16 | RETURN {PhoneNumber: phoneNumber, UserNames: userNames, UserCount: UserCount} AS info 17 | ORDER BY UserCount DESC 18 | Targets: 19 | - Name: CSV 20 | Enabled: true 21 | Path: report/{{date}}/BH-MFA_PHONE_SHARING_{{date}}.csv 22 | - Name: Markdown 23 | Enabled: true 24 | Path: report/{{date}}/BH-MFA_PHONE_SHARING_{{date}}.md 25 | - Name: HTML 26 | Enabled: true 27 | Path: report/{{date}}/BH-MFA_PHONE_SHARING_{{date}}.html -------------------------------------------------------------------------------- /cmd/adxinit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "falconhound/internal" 6 | "github.com/Azure/azure-kusto-go/kusto" 7 | "log" 8 | ) 9 | 10 | func AdxInitTable(creds internal.Credentials) error { 11 | 12 | kustoConnectionStringBuilder := kusto.NewConnectionStringBuilder(creds.AdxClusterURL) 13 | kustoConnectionString := kustoConnectionStringBuilder.WithAadAppKey(creds.AdxAppID, creds.AdxAppSecret, creds.AdxTenantID) 14 | 15 | client, err := kusto.New(kustoConnectionString) 16 | if err != nil { 17 | log.Fatalf("failed to create Kusto client: %s", err) 18 | return err 19 | } 20 | 21 | // Create a context 22 | ctx := context.Background() 23 | const command = (".create table FalconHound (Name: string, Description: string, EventID: string, BHQuery: string, EventData: dynamic, Timestamp: datetime)") 24 | 25 | // Execute the control command 26 | _, err = client.Mgmt(ctx, creds.AdxDatabase, kusto.NewStmt(command)) 27 | //_, err = client.Mgmt(ctx, creds.AdxDatabase, command) 28 | if err != nil { 29 | log.Fatalf("Failed to execute the control command: %s", err) 30 | return err 31 | } 32 | 33 | LogInfo("[+] Table FalconHound created successfully, ready for ingestion.") 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /cmd/getcreds.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "falconhound/internal" 5 | "log" 6 | "path/filepath" 7 | "reflect" 8 | 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | func setCredValue(credentials *internal.Credentials, field string, value string) { 13 | r := reflect.ValueOf(credentials) 14 | f := reflect.Indirect(r).FieldByName(field) 15 | if f.Kind() != reflect.Invalid { 16 | f.SetString(value) 17 | } 18 | } 19 | 20 | func GetCreds(configFile string, keyvaultFlag bool) (theCreds internal.Credentials) { 21 | var err error 22 | //read config file 23 | dir := filepath.Dir(configFile) 24 | fileName := filepath.Base(configFile) 25 | 26 | viper.SetConfigName(fileName) 27 | if configFile == "config.yml" { 28 | viper.AddConfigPath(".") 29 | } else { 30 | viper.AddConfigPath(dir) 31 | } 32 | viper.SetConfigType("yml") 33 | 34 | if err := viper.ReadInConfig(); err != nil { 35 | log.Fatalf("Failed to read configuration file: %v", err) 36 | } 37 | 38 | log.Printf("[+] Using config file: %s\n", viper.ConfigFileUsed()) 39 | if keyvaultFlag { 40 | log.Printf("[+] Using keyvault: %s\n", viper.GetString("keyvault.uri")) 41 | } 42 | 43 | // Parse the Credentials structure, either get it from the config or from the keyvault 44 | // The values in keyvault are equal to the field names in the Credentials struct 45 | // the values in the config are equal specified using a tag in the Credentials struct 46 | creds := internal.Credentials{} 47 | t := reflect.TypeOf(internal.Credentials{}) 48 | for i := 0; i < t.NumField(); i++ { 49 | field := t.Field(i) 50 | // Get the field tag value 51 | tag := field.Tag.Get("config") 52 | var value string 53 | if keyvaultFlag { 54 | value, err = GetSecretFromAzureKeyVault(viper.GetString("keyvault.uri"), field.Name, viper.GetString("keyvault.managedIdentity")) 55 | if err != nil { 56 | LogInfo("[!] %s not in keyvault, grabbing it from the config...", field.Name) 57 | value = viper.GetString(tag) 58 | } 59 | } else { 60 | value = viper.GetString(tag) 61 | } 62 | setCredValue(&creds, field.Name, value) 63 | } 64 | return creds 65 | } 66 | -------------------------------------------------------------------------------- /cmd/getkeyvaultsecrets.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 9 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 10 | "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | func GetSecretFromAzureKeyVault(keyVaultName string, secretName string, managedIdentity string) (string, error) { 15 | // Create a new DefaultAzureCredential 16 | var cred azcore.TokenCredential 17 | var err error 18 | if managedIdentity == "true" { 19 | cred, err = azidentity.NewManagedIdentityCredential(nil) 20 | } else { 21 | cred, err = azidentity.NewClientSecretCredential(viper.GetString("keyvault.tenantID"), viper.GetString("keyvault.appID"), viper.GetString("keyvault.appSecret"), nil) 22 | } 23 | // cred, err := azidentity.NewDefaultAzureCredential(nil) 24 | if err != nil { 25 | log.Fatalf("Failed to create the credentials: %v", err) 26 | } 27 | 28 | // Create a new client using the DefaultAzureCredential. 29 | client, err := azsecrets.NewClient(keyVaultName, cred, nil) 30 | if err != nil { 31 | log.Fatalf("Failed to create the client: %v", err) 32 | } 33 | 34 | // Get the secret 35 | secretResponse, err := client.GetSecret(context.Background(), secretName, "", &azsecrets.GetSecretOptions{}) 36 | if err != nil { 37 | return "", fmt.Errorf("Failed to get the secret: %v", err) 38 | } 39 | 40 | return *secretResponse.Value, nil 41 | } 42 | -------------------------------------------------------------------------------- /cmd/logging.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | func LogInfo(format string, v ...any) { 8 | log.Printf(Blue+format+Reset, v...) 9 | } 10 | 11 | func LogError(format string, v ...any) { 12 | log.Printf(Red+format+Reset, v...) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/printtable.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func PrintTable(headers []string, data [][]string) { 8 | // Calculate the maximum width of each column 9 | widths := make([]int, len(headers)) 10 | for i, header := range headers { 11 | widths[i] = len(header) 12 | } 13 | for _, row := range data { 14 | for i, cell := range row { 15 | if len(cell) > widths[i] { 16 | widths[i] = len(cell) 17 | } 18 | } 19 | } 20 | 21 | // Print the column headers 22 | for i, header := range headers { 23 | fmt.Printf(Blue+"%-*s"+Reset, widths[i]+2, header) 24 | } 25 | fmt.Println() 26 | 27 | // Print the data rows 28 | for _, row := range data { 29 | for i, cell := range row { 30 | fmt.Printf("%-*s", widths[i]+2, cell) 31 | } 32 | fmt.Println() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/setcolor.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "runtime" 4 | 5 | var Reset = "\033[0m" 6 | var Red = "\033[31m" 7 | var Green = "\033[32m" 8 | var Yellow = "\033[33m" 9 | var Blue = "\033[34m" 10 | var Purple = "\033[35m" 11 | var Cyan = "\033[36m" 12 | var Gray = "\033[37m" 13 | var White = "\033[97m" 14 | 15 | func init() { 16 | if runtime.GOOS == "windows" { 17 | Reset = "" 18 | Red = "" 19 | Green = "" 20 | Yellow = "" 21 | Blue = "" 22 | Purple = "" 23 | Cyan = "" 24 | Gray = "" 25 | White = "" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /config.yml-sample: -------------------------------------------------------------------------------- 1 | ################################################ 2 | # FalconHound Configuration File 3 | ################################################ 4 | # This file contains the configuration for FalconHound 5 | # Add your API keys here in the proper section 6 | 7 | ################################################ 8 | # Add your Keyvault information here 9 | # This is optional, when used, FalconHound will pull the 10 | # API keys from Keyvault instead of this file 11 | # start with the -keyvault flag 12 | # if managedIdentity is true, then tenantID, appID and appSecret can be omitted. 13 | # if you wish to use client secret credentials, then define appid, tenantID and appSecret 14 | ################################################ 15 | keyvault: 16 | uri: https://XXXXXXXX.vault.azure.net/ 17 | tenantID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 18 | appID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 19 | appSecret: xxxxxxxxxxxxxx 20 | managedIdentity: false 21 | 22 | ################################################ 23 | # Add your Sentinel connection information here 24 | ################################################ 25 | sentinel: 26 | managedIdentity: false 27 | tenantID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 28 | appID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 29 | appSecret: xxxxxxxxxxxxxx 30 | ## All the below information is required 31 | workspaceID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 32 | subscriptionID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 33 | resourceGroup: "XXXXXXX" 34 | sharedKey: xxxxxxxxxxxxxx 35 | targetTable: "FalconHound" 36 | workspaceName: "XXXXXXXX" 37 | 38 | ################################################ 39 | # Add your MDE connection information here 40 | # This can be the same app as Sentinel 41 | # or preferably a different one 42 | ################################################ 43 | MDE: 44 | managedIdentity: false 45 | tenantID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 46 | appID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 47 | appSecret: xxxxxxxxxxxxxx 48 | 49 | ################################################ 50 | # Add your MS Graph connection information here 51 | # This can be the same app as Sentinel or a different one 52 | ################################################ 53 | graph: 54 | managedIdentity: false 55 | tenantID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 56 | appID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 57 | appSecret: xxxxxxxxxxxxxx 58 | 59 | ################################################ 60 | # Add your Neo4j connection information here 61 | ################################################ 62 | neo4j: 63 | uri: bolt://XX.XX.XX.XX:7687 64 | password: xxxxxxxxxxxxxx 65 | username: neo4j 66 | 67 | ################################################ 68 | # Add your BloodHound connection information here 69 | ################################################ 70 | bloodhound: 71 | url: http://xxxxx:8080 72 | tokenID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 73 | tokenKey: xxxxxxxxxxxxxx== 74 | 75 | ################################################ 76 | # Add your Splunk connection information here 77 | ################################################ 78 | splunk: 79 | url: https://10.10.2.140 80 | index: wineventlog 81 | hecport: 8088 82 | hectoken: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 83 | apiport: 8089 84 | apitoken: 85 | 86 | ################################################ 87 | # Add your LogScale / Humio connection information here 88 | ################################################ 89 | logscale: 90 | url: https://cloud.community.humio.com 91 | token: 92 | repository: 93 | 94 | ################################################ 95 | # Add your Elastic cloud connection information here 96 | ################################################ 97 | elastic: 98 | cloudid: 99 | apikey: 100 | 101 | ################################################ 102 | # Add your Azure Data Explorer connection information here 103 | # This can be the same app as Sentinel or a different one 104 | ################################################ 105 | adx: 106 | clusterUrl: https://xxx.westeurope.kusto.windows.net 107 | database: enrichments 108 | table: FalconHound 109 | tenantID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 110 | appID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 111 | appSecret: xxxxxxxxxxxxxx 112 | -------------------------------------------------------------------------------- /docs/FEATURE_IDEAS.md: -------------------------------------------------------------------------------- 1 | # Feature ideas and ambitions for the project 2 | 3 | ## Actions ideas 4 | 5 | - [ ] Add new role to Azure user 6 | - [ ] Group added 7 | - [ ] Role assignment 8 | - [ ] API access changes 9 | - [ ] Added to local admins group > AdminTo 10 | - [ ] Owned to sensitive resource with more than 1 owned 11 | - [ ] Next step on path from owned prediction 12 | - [ ] Old last set passwords 13 | - [ ] User owns list 14 | - [ ] Get public groups and dynamic groups 15 | - [ ] Public groups with path 16 | - [ ] Dynamic groups with path 17 | - [ ] Machine onboarded in EDR property to Neo4j 18 | - [ ] Query for not onboarded machines with a patch to sensitive 19 | 20 | - [ ] Azure risk score to users / devices 21 | - [ ] Get VMs and IPs from graph 22 | - [ ] Get conditional access policies 23 | 24 | ## In/out processors 25 | 26 | Sensitive resource list processor 27 | - [ ] Read from CSV 28 | - [ ] Read from watchlist 29 | 30 | BH(E) API - under development 31 | - [ ] Read from new BH(E) API > TODO: wait for query over API to be fixed/improved 32 | - [-] Query new BH(E) API, parse results 33 | - [ ] Write to new BH(E) API > TODO: solve the objectid issue 34 | 35 | Generic output processors 36 | - [ ] Write BH compatible JSON outputs 37 | - [x] Write markdown outputs 38 | - [x] Write to storage account 39 | - [-] Write to ADX (beta) 40 | 41 | GraphAPI 42 | - [ ] Look into Defender, AAD, Intune, CA policies, ? 43 | - [ ] Write GraphAPI (User properties) 44 | - [ ] Read signinlog 45 | - [ ] Read auditlog / azureactivity 46 | 47 | - [ ] Read watchlists 48 | - [x] Save watchlists 49 | 50 | ## Operational 51 | 52 | - [x] Token needs to be used more efficiently 53 | - [x] Add global debug mode 54 | - [x] Add check that if a credential is empty in the config the in/out processor is not used 55 | 56 | - [ ] Add more logging 57 | - [x] Add more error handling 58 | 59 | ## Future releases 60 | 61 | - [ ] Add env variable to creds options 62 | - [x] Add managed identity option 63 | - [ ] Excel sheet reports 64 | - [x] Configurable time window for actions 65 | - [ ] Write to Teams 66 | - [ ] Write to Slack -------------------------------------------------------------------------------- /docs/falconhound-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FalconForceTeam/FalconHound/e9151074926ed7a26b5c71b82cbeb6ddf3c8e903/docs/falconhound-logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module falconhound 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/Azure/azure-kusto-go v0.15.0 7 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 8 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 9 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 10 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/securityinsights/armsecurityinsights v1.2.0 11 | github.com/elastic/go-elasticsearch/v8 v8.12.0 12 | github.com/gamepat/azure-oauth2-token v0.2.0 13 | github.com/iancoleman/orderedmap v0.3.0 14 | github.com/microsoft/kiota-abstractions-go v1.5.6 15 | github.com/microsoftgraph/msgraph-beta-sdk-go v0.94.0 16 | github.com/microsoftgraph/msgraph-sdk-go-core v1.0.2 17 | github.com/neo4j/neo4j-go-driver/v4 v4.4.7 18 | github.com/santhosh-tekuri/jsonschema v1.2.4 19 | github.com/spf13/viper v1.18.2 20 | gopkg.in/yaml.v2 v2.4.0 21 | ) 22 | 23 | require ( 24 | github.com/Azure/azure-pipeline-go v0.2.3 // indirect 25 | github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect 26 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 // indirect 27 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect 28 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.0 // indirect 29 | github.com/Azure/azure-storage-queue-go v0.0.0-20230927153703-648530c9aaf2 // indirect 30 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 31 | github.com/Azure/go-autorest/autorest v0.11.29 // indirect 32 | github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect 33 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 34 | github.com/Azure/go-autorest/logger v0.2.1 // indirect 35 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 36 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect 37 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 38 | github.com/cjlapao/common-go v0.0.39 // indirect 39 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 40 | github.com/elastic/elastic-transport-go/v8 v8.4.0 // indirect 41 | github.com/fsnotify/fsnotify v1.7.0 // indirect 42 | github.com/go-logr/logr v1.4.1 // indirect 43 | github.com/go-logr/stdr v1.2.2 // indirect 44 | github.com/gofrs/uuid v4.4.0+incompatible // indirect 45 | github.com/golang-jwt/jwt/v4 v4.5.1 // indirect 46 | github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 47 | github.com/google/uuid v1.6.0 // indirect 48 | github.com/hashicorp/hcl v1.0.0 // indirect 49 | github.com/kylelemons/godebug v1.1.0 // indirect 50 | github.com/magiconair/properties v1.8.7 // indirect 51 | github.com/mattn/go-ieproxy v0.0.11 // indirect 52 | github.com/microsoft/kiota-authentication-azure-go v1.0.2 // indirect 53 | github.com/microsoft/kiota-http-go v1.3.0 // indirect 54 | github.com/microsoft/kiota-serialization-form-go v1.0.0 // indirect 55 | github.com/microsoft/kiota-serialization-json-go v1.0.6 // indirect 56 | github.com/microsoft/kiota-serialization-multipart-go v1.0.0 // indirect 57 | github.com/microsoft/kiota-serialization-text-go v1.0.0 // indirect 58 | github.com/mitchellh/mapstructure v1.5.0 // indirect 59 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 60 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 61 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 62 | github.com/sagikazarmark/locafero v0.4.0 // indirect 63 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 64 | github.com/samber/lo v1.39.0 // indirect 65 | github.com/shopspring/decimal v1.3.1 // indirect 66 | github.com/sourcegraph/conc v0.3.0 // indirect 67 | github.com/spf13/afero v1.11.0 // indirect 68 | github.com/spf13/cast v1.6.0 // indirect 69 | github.com/spf13/pflag v1.0.5 // indirect 70 | github.com/std-uritemplate/std-uritemplate/go v0.0.50 // indirect 71 | github.com/stretchr/testify v1.9.0 // indirect 72 | github.com/subosito/gotenv v1.6.0 // indirect 73 | go.opentelemetry.io/otel v1.23.1 // indirect 74 | go.opentelemetry.io/otel/metric v1.23.1 // indirect 75 | go.opentelemetry.io/otel/trace v1.23.1 // indirect 76 | go.uber.org/multierr v1.11.0 // indirect 77 | golang.org/x/crypto v0.31.0 // indirect 78 | golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect 79 | golang.org/x/net v0.33.0 // indirect 80 | golang.org/x/sys v0.28.0 // indirect 81 | golang.org/x/text v0.21.0 // indirect 82 | gopkg.in/ini.v1 v1.67.0 // indirect 83 | gopkg.in/yaml.v3 v3.0.1 // indirect 84 | ) 85 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 2 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 3 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 4 | github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 5 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 6 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 7 | golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= 8 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 9 | -------------------------------------------------------------------------------- /input_processor/base.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "falconhound/internal" 5 | "falconhound/output_processor" 6 | ) 7 | 8 | type InputProcessor struct { 9 | Type string 10 | Enabled bool 11 | Debug bool 12 | Name string 13 | ID string 14 | SourcePlatform string 15 | Credentials internal.Credentials 16 | Query string 17 | OutputProcessors []output_processor.OutputProcessorInterface 18 | } 19 | 20 | type InputProcessorInterface interface { 21 | ExecuteQuery() (internal.QueryResults, error) 22 | GetOutputProcessors() []output_processor.OutputProcessorInterface 23 | InputProcessorConfig() InputProcessor 24 | } 25 | 26 | func (m InputProcessor) GetOutputProcessors() []output_processor.OutputProcessorInterface { 27 | return m.OutputProcessors 28 | } 29 | 30 | func (m InputProcessor) InputProcessorConfig() InputProcessor { 31 | return m 32 | } 33 | -------------------------------------------------------------------------------- /input_processor/bloodhound.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "encoding/json" 9 | "falconhound/internal" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net/http" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type BHConfig struct { 19 | } 20 | 21 | type BHProcessor struct { 22 | *InputProcessor 23 | Config BHConfig 24 | } 25 | 26 | func (m *BHProcessor) ExecuteQuery() (internal.QueryResults, error) { 27 | results, err := BHRequest(m.Query, m.Credentials) 28 | if err != nil { 29 | return internal.QueryResults{}, err 30 | } 31 | return results, nil 32 | } 33 | 34 | func BHRequest(query string, creds internal.Credentials) (internal.QueryResults, error) { 35 | if creds.BHTokenKey == "" { 36 | return internal.QueryResults{}, fmt.Errorf("BHTokenKey is empty, skipping..") 37 | } 38 | 39 | // Convert query from a multiline string from the yaml to a single line string so the API can parse it 40 | query = strings.ReplaceAll(query, "\n", " ") 41 | 42 | method := "POST" 43 | uri := "/api/v2/graphs/cypher" 44 | queryBody := fmt.Sprintf("{\"query\":\"%s\", \"include_properties\": true}", query) 45 | log.Println("Query body:", queryBody) 46 | body := []byte(queryBody) 47 | 48 | // The first HMAC digest is the token key 49 | digester := hmac.New(sha256.New, []byte(creds.BHTokenKey)) 50 | 51 | // OperationKey is the first HMAC digestresource 52 | digester.Write([]byte(fmt.Sprintf("%s%s", method, uri))) 53 | 54 | // Update the digester for further chaining 55 | digester = hmac.New(sha256.New, digester.Sum(nil)) 56 | datetimeFormatted := time.Now().Format("2006-01-02T15:04:05.999999-07:00") 57 | digester.Write([]byte(datetimeFormatted[:13])) 58 | 59 | // Update the digester for further chaining 60 | digester = hmac.New(sha256.New, digester.Sum(nil)) 61 | 62 | // Body signing is the last HMAC digest link in the signature chain. This encodes the request body as part of 63 | // the signature to prevent replay attacks that seek to modify the payload of a signed request. In the case 64 | // where there is no body content the HMAC digest is computed anyway, simply with no values written to the 65 | // digester. 66 | if body != nil { 67 | digester.Write(body) 68 | } 69 | 70 | bhendpoint := fmt.Sprintf("%s%s", creds.BHUrl, uri) 71 | 72 | // Perform the request with the signed and expected headers 73 | req, err := http.NewRequest(method, bhendpoint, bytes.NewBuffer(body)) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | req.Header.Set("User-Agent", internal.Version) 79 | req.Header.Set("Authorization", fmt.Sprintf("bhesignature %s", creds.BHTokenID)) 80 | req.Header.Set("RequestDate", datetimeFormatted) 81 | req.Header.Set("Signature", base64.StdEncoding.EncodeToString(digester.Sum(nil))) 82 | req.Header.Set("Content-Type", "application/json") 83 | 84 | client := &http.Client{} 85 | resp, err := client.Do(req) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | respbody, err := io.ReadAll(resp.Body) 91 | if err != nil { 92 | fmt.Println("Error reading response body:", err) 93 | } 94 | 95 | //fmt.Println("Response:", string(respbody)) 96 | 97 | var bhresults internal.BHQueryResults 98 | err = json.Unmarshal(respbody, &bhresults) 99 | if err != nil { 100 | return internal.QueryResults{}, fmt.Errorf("error unmarshalling response body: %w", err) 101 | } 102 | 103 | // append each node in bhresults to the results 104 | results := make(internal.QueryResults, 0) 105 | for _, node := range bhresults.Data.Nodes { 106 | result := make(map[string]interface{}) 107 | result["label"] = node.Label 108 | result["kind"] = node.Kind 109 | result["objectId"] = node.ObjectID 110 | result["isTierZero"] = node.IsTierZero 111 | result["lastSeen"] = node.LastSeen 112 | result["properties"] = node.Properties 113 | results = append(results, result) 114 | } 115 | return results, nil 116 | } 117 | -------------------------------------------------------------------------------- /input_processor/elastic.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "encoding/json" 5 | "falconhound/internal" 6 | "fmt" 7 | "github.com/elastic/go-elasticsearch/v8" 8 | "io" 9 | "log" 10 | "strings" 11 | ) 12 | 13 | type ElasticConfig struct { 14 | } 15 | 16 | type ElasticProcessor struct { 17 | *InputProcessor 18 | Config ElasticConfig 19 | } 20 | 21 | type ElasticsearchResponse struct { 22 | Hits struct { 23 | Hits []struct { 24 | Source json.RawMessage `json:"_source"` 25 | } `json:"hits"` 26 | } `json:"hits"` 27 | } 28 | 29 | func (m *ElasticProcessor) ExecuteQuery() (internal.QueryResults, error) { 30 | if m.Credentials.ElasticApiKey == "" { 31 | return internal.QueryResults{}, fmt.Errorf("ElasticApiKey is empty, skipping..") 32 | } 33 | 34 | results, err := queryElastic(m.Query, m.Credentials, m.Debug) 35 | if err != nil { 36 | if strings.Contains(err.Error(), "unexpected HTTP status code: 400") { 37 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q. Most likely there is a syntax error in the query", m.Query) 38 | } else { 39 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q: %w", m.Query, err) 40 | } 41 | } 42 | 43 | var ElasticResults internal.QueryResults 44 | 45 | err = json.Unmarshal([]byte(results), &ElasticResults) 46 | if err != nil { 47 | return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON from Elastic: %v", err) 48 | } 49 | 50 | return ElasticResults, nil 51 | } 52 | 53 | func queryElastic(query string, credentials internal.Credentials, debug bool) (string, error) { 54 | 55 | cfg := elasticsearch.Config{ 56 | CloudID: credentials.ElasticCloudID, 57 | APIKey: credentials.ElasticApiKey, 58 | } 59 | es, err := elasticsearch.NewClient(cfg) 60 | if err != nil { 61 | log.Fatalf("Error creating the client: %s", err) 62 | } 63 | 64 | cleanquery := strings.ReplaceAll(query, "\n", " ") 65 | esquery := fmt.Sprintf(`{ "query": { "query_string": { "query": "(%s)" }}}`, cleanquery) 66 | fmt.Printf("Query: %s\n", esquery) 67 | res, err := es.Search( 68 | es.Search.WithIndex(".ds-logs-system.security-default*"), // <-- TODO: now with index 69 | es.Search.WithBody(strings.NewReader(esquery)), 70 | es.Search.WithTrackTotalHits(true), 71 | es.Search.WithPretty(), 72 | ) 73 | if err != nil { 74 | log.Fatalf("Error getting response: %s", err) 75 | } 76 | 77 | defer res.Body.Close() 78 | //log.Println(res) 79 | 80 | bodyBytes, _ := io.ReadAll(res.Body) 81 | 82 | var esResponse ElasticsearchResponse 83 | if err := json.Unmarshal([]byte(bodyBytes), &esResponse); err != nil { 84 | fmt.Printf("Error parsing JSON: %v\n", err) 85 | //return 86 | } 87 | 88 | var allFlattenedData []map[string]interface{} 89 | 90 | for _, hit := range esResponse.Hits.Hits { 91 | var result map[string]interface{} 92 | err := json.Unmarshal(hit.Source, &result) 93 | if err != nil { 94 | log.Fatalf("Error unmarshalling JSON: %v", err) 95 | } 96 | 97 | flattenedData := make(map[string]interface{}) 98 | flatten(result, "", flattenedData) 99 | 100 | allFlattenedData = append(allFlattenedData, flattenedData) 101 | } 102 | 103 | flattenedJSON, err := json.Marshal(allFlattenedData) 104 | if err != nil { 105 | log.Fatalf("Error marshalling flattened data: %v", err) 106 | } 107 | 108 | return string(flattenedJSON), nil 109 | } 110 | 111 | func flatten(data interface{}, prefix string, flattenedData map[string]interface{}) { 112 | switch data := data.(type) { 113 | case map[string]interface{}: 114 | for k, v := range data { 115 | var fullKey string 116 | if prefix == "" { 117 | fullKey = k 118 | } else { 119 | fullKey = prefix + "." + k 120 | } 121 | flatten(v, fullKey, flattenedData) 122 | } 123 | case []interface{}: 124 | for i, v := range data { 125 | flatten(v, fmt.Sprintf("%s.%d", prefix, i), flattenedData) 126 | } 127 | default: 128 | flattenedData[prefix] = data 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /input_processor/http.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "encoding/json" 5 | "falconhound/internal" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | ) 10 | 11 | type Role struct { 12 | RoleId string `json:"RoleId"` 13 | RoleName string `json:"RoleName"` 14 | IsPrivilegedRole bool `json:"isPrivileged"` 15 | Classification struct { 16 | EAMTierLevelTagValue string `json:"EAMTierLevelTagValue,omitempty"` 17 | EAMTierLevelName string `json:"EAMTierLevelName,omitempty"` 18 | } `json:"Classification"` 19 | RolePermissions []RolePermissions `json:"RolePermissions"` 20 | } 21 | 22 | type RolePermissions struct { 23 | AuthorizedResourceAction string `json:"AuthorizedResourceAction,omitempty"` 24 | //Category []string `json:"Category,omitempty"` 25 | EAMTierLevelTagValue string `json:"EAMTierLevelTagValue,omitempty"` 26 | EAMTierLevelName string `json:"EAMTierLevelName,omitempty"` 27 | } 28 | 29 | type RoleMap struct { 30 | RoleId string `json:"RoleId"` 31 | EAMTierLevelTagValue string `json:"EAMTierLevelTagValue"` 32 | AdminTierLevel string `json:"AdminTierLevel"` 33 | TenantId string `json:"TenantId"` 34 | } 35 | 36 | type HTTPConfig struct { 37 | } 38 | 39 | type HTTPProcessor struct { 40 | *InputProcessor 41 | Config HTTPConfig 42 | } 43 | 44 | type HTTPResults struct { 45 | Results internal.QueryResults `json:"Results"` 46 | } 47 | 48 | func (m *HTTPProcessor) ExecuteQuery() (internal.QueryResults, error) { 49 | // 50 | //func GetRoleJson() { 51 | resp, err := http.Get("https://github.com/Cloud-Architekt/AzurePrivilegedIAM/raw/main/Classification/Classification_EntraIdDirectoryRoles.json") 52 | if err != nil { 53 | fmt.Println(err) 54 | return nil, fmt.Errorf("failed to get role json") 55 | } 56 | defer resp.Body.Close() 57 | 58 | body, err := ioutil.ReadAll(resp.Body) 59 | if err != nil { 60 | fmt.Println(err) 61 | return nil, fmt.Errorf("failed to read role json") 62 | } 63 | 64 | var roles []Role 65 | err = json.Unmarshal(body, &roles) 66 | if err != nil { 67 | fmt.Println(err) 68 | return nil, fmt.Errorf("failed to unmarshal role json") 69 | } 70 | 71 | var EAMTierLevelTagValueAlias = map[string]string{ 72 | "0": "admin_tier_0", 73 | "1": "admin_tier_1", 74 | "2": "admin_tier_2", 75 | } 76 | 77 | roleMaps := make([]RoleMap, 0) 78 | 79 | for _, role := range roles { 80 | roleMaps = append(roleMaps, RoleMap{ 81 | RoleId: role.RoleId, 82 | EAMTierLevelTagValue: role.Classification.EAMTierLevelTagValue, 83 | AdminTierLevel: EAMTierLevelTagValueAlias[role.Classification.EAMTierLevelTagValue], 84 | TenantId: m.Credentials.SentinelTenantID, 85 | }) 86 | } 87 | 88 | results := internal.QueryResults{} 89 | 90 | for _, roleMap := range roleMaps { 91 | roleMapJson, err := json.Marshal(roleMap) 92 | if err != nil { 93 | fmt.Println(err) 94 | return nil, fmt.Errorf("failed to marshal role map") 95 | } 96 | 97 | var roleMapInterface map[string]interface{} 98 | err = json.Unmarshal(roleMapJson, &roleMapInterface) 99 | if err != nil { 100 | fmt.Println(err) 101 | return nil, fmt.Errorf("failed to unmarshal role map json") 102 | } 103 | 104 | results = append(results, roleMapInterface) 105 | } 106 | 107 | return results, nil 108 | } 109 | 110 | func (r *Role) UnmarshalJSON(data []byte) error { 111 | type Alias Role 112 | aux := &struct { 113 | RolePermissions json.RawMessage `json:"RolePermissions"` 114 | *Alias 115 | }{ 116 | Alias: (*Alias)(r), 117 | } 118 | if err := json.Unmarshal(data, &aux); err != nil { 119 | return err 120 | } 121 | 122 | var single RolePermissions 123 | if err := json.Unmarshal(aux.RolePermissions, &single); err == nil { 124 | r.RolePermissions = []RolePermissions{single} 125 | return nil 126 | } 127 | 128 | var multiple []RolePermissions 129 | if err := json.Unmarshal(aux.RolePermissions, &multiple); err != nil { 130 | return fmt.Errorf("RolePermissions could not be unmarshalled as an array: %v", err) 131 | } 132 | 133 | r.RolePermissions = multiple 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /input_processor/input_cmd/graph_getdynamicgroups.go: -------------------------------------------------------------------------------- 1 | package input_cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | msgraphsdk "github.com/microsoftgraph/msgraph-beta-sdk-go" 8 | graphgroups "github.com/microsoftgraph/msgraph-beta-sdk-go/groups" 9 | "github.com/microsoftgraph/msgraph-beta-sdk-go/models" 10 | msgraphcore "github.com/microsoftgraph/msgraph-sdk-go-core" 11 | ) 12 | 13 | type DynamicGroups struct { 14 | GroupType string 15 | MembershipRule string 16 | ObjectId string 17 | MembershipRuleProcessingState string 18 | DisplayName string 19 | TenantId string 20 | } 21 | 22 | // requires Directory.Read.All 23 | func GetDynamicGroups(graphClient msgraphsdk.GraphServiceClient) ([]byte, error) { 24 | 25 | requestFilter := "mailEnabled eq false and securityEnabled eq true and membershipRuleProcessingState eq 'On'" 26 | requestCount := true 27 | 28 | requestParameters := &graphgroups.GroupsRequestBuilderGetQueryParameters{ 29 | Filter: &requestFilter, 30 | Count: &requestCount, 31 | Select: []string{"id", "membershipRule", "membershipRuleProcessingState", "groupTypes", "displayName", "organizationId"}, 32 | } 33 | configuration := &graphgroups.GroupsRequestBuilderGetRequestConfiguration{ 34 | QueryParameters: requestParameters, 35 | } 36 | 37 | dynamicGroups, err := graphClient.Groups().Get(context.Background(), configuration) 38 | if err != nil { 39 | fmt.Println("Error dynamic groups:", err) 40 | return nil, err 41 | } 42 | settingsperGroup := make([]DynamicGroups, 0) 43 | 44 | pageIterator, err := msgraphcore.NewPageIterator[*models.Group](dynamicGroups, graphClient.GetAdapter(), models.CreateGroupCollectionResponseFromDiscriminatorValue) 45 | err = pageIterator.Iterate(context.Background(), func(pageItem *models.Group) bool { 46 | settings := DynamicGroups{} 47 | // no need for nil check as we are filtering on these fields, also dynamic groups can only have one group type 48 | settings.GroupType = pageItem.GetGroupTypes()[0] 49 | settings.MembershipRule = *pageItem.GetMembershipRule() 50 | settings.ObjectId = *pageItem.GetId() 51 | settings.MembershipRuleProcessingState = *pageItem.GetMembershipRuleProcessingState() 52 | settings.DisplayName = *pageItem.GetDisplayName() 53 | settings.TenantId = *pageItem.GetOrganizationId() 54 | settingsperGroup = append(settingsperGroup, settings) 55 | return true 56 | }) 57 | if err != nil { 58 | fmt.Println("Error iterating dynamic group requests:", err) 59 | return nil, err 60 | } 61 | json, err := json.MarshalIndent(settingsperGroup, "", " ") 62 | if err != nil { 63 | fmt.Println("Error converting to JSON:", err) 64 | return nil, err 65 | } 66 | return json, nil 67 | } 68 | -------------------------------------------------------------------------------- /input_processor/input_cmd/graph_getoauthconsent.go: -------------------------------------------------------------------------------- 1 | package input_cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | msgraphsdk "github.com/microsoftgraph/msgraph-beta-sdk-go" 8 | "github.com/microsoftgraph/msgraph-beta-sdk-go/models" 9 | msgraphcore "github.com/microsoftgraph/msgraph-sdk-go-core" 10 | "time" 11 | ) 12 | 13 | type OauthConsent struct { 14 | ClientId string 15 | ConsentType string 16 | StartTime time.Time 17 | ExpiryTime time.Time 18 | ResourceId string 19 | Scope string 20 | } 21 | 22 | // requires Directory.Read.All 23 | func GetOAuthConsent(graphClient msgraphsdk.GraphServiceClient) ([]byte, error) { 24 | userRiskDetection, err := graphClient.Oauth2PermissionGrants().Get(context.Background(), nil) 25 | if err != nil { 26 | fmt.Println("Error getting consented apps:", err) 27 | return nil, err 28 | } 29 | oauthconsentperAccount := make([]OauthConsent, 0) 30 | 31 | pageIterator, err := msgraphcore.NewPageIterator[*models.OAuth2PermissionGrant](userRiskDetection, graphClient.GetAdapter(), models.CreateOAuth2PermissionGrantCollectionResponseFromDiscriminatorValue) 32 | err = pageIterator.Iterate(context.Background(), func(pageItem *models.OAuth2PermissionGrant) bool { 33 | settings := OauthConsent{} 34 | settings.ClientId = *pageItem.GetClientId() 35 | settings.ConsentType = *pageItem.GetConsentType() 36 | settings.StartTime = *pageItem.GetStartTime() 37 | settings.ExpiryTime = *pageItem.GetExpiryTime() 38 | settings.ResourceId = *pageItem.GetResourceId() 39 | settings.Scope = *pageItem.GetScope() 40 | oauthconsentperAccount = append(oauthconsentperAccount, settings) 41 | return true 42 | }) 43 | if err != nil { 44 | fmt.Println("Error iterating role app consents:", err) 45 | return nil, err 46 | } 47 | json, err := json.MarshalIndent(oauthconsentperAccount, "", " ") 48 | if err != nil { 49 | fmt.Println("Error converting to JSON:", err) 50 | return nil, err 51 | } 52 | return json, nil 53 | } 54 | -------------------------------------------------------------------------------- /input_processor/logscale.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "falconhound/internal" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "strings" 12 | ) 13 | 14 | type LogScaleConfig struct { 15 | } 16 | 17 | type LogScaleProcessor struct { 18 | *InputProcessor 19 | Config LogScaleConfig 20 | } 21 | 22 | func (m *LogScaleProcessor) ExecuteQuery() (internal.QueryResults, error) { 23 | if m.Credentials.LogScaleToken == "" { 24 | return internal.QueryResults{}, fmt.Errorf("LogScaleToken is empty, skipping..") 25 | } 26 | 27 | results, err := queryLogscale(m.Query, m.Credentials, m.Debug) 28 | if err != nil { 29 | if strings.Contains(err.Error(), "unexpected HTTP status code: 400") { 30 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q. Most likely there is a syntax error in the query", m.Query) 31 | } else { 32 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q: %w", m.Query, err) 33 | } 34 | } 35 | 36 | var LogScaleResults internal.QueryResults 37 | 38 | err = json.Unmarshal([]byte(results), &LogScaleResults) 39 | if err != nil { 40 | return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON from LogScale: %v", err) 41 | } 42 | 43 | return LogScaleResults, nil 44 | } 45 | 46 | func queryLogscale(query string, credentials internal.Credentials, debug bool) (string, error) { 47 | payload := map[string]interface{}{ 48 | "queryString": query, 49 | "start": "15m", 50 | "end": "now", 51 | "isLive": false, 52 | } 53 | jsonPayload, _ := json.Marshal(payload) 54 | 55 | req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/repositories/%s/query", credentials.LogScaleUrl, credentials.LogScaleRepository), bytes.NewBuffer(jsonPayload)) 56 | req.Header.Set("Authorization", "Bearer "+credentials.LogScaleToken) 57 | req.Header.Set("Content-Type", "application/json") 58 | req.Header.Set("Accept", "application/json") 59 | 60 | client := &http.Client{} 61 | resp, err := client.Do(req) 62 | if err != nil { 63 | log.Fatalln(err) 64 | } 65 | defer resp.Body.Close() 66 | 67 | bodyBytes, _ := io.ReadAll(resp.Body) 68 | fmt.Println(string(bodyBytes)) 69 | 70 | return string(bodyBytes), nil 71 | } 72 | -------------------------------------------------------------------------------- /input_processor/mde.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "falconhound/internal" 8 | "fmt" 9 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 10 | "io" 11 | "log" 12 | "net/http" 13 | 14 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" 15 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 16 | ) 17 | 18 | type MDEResults struct { 19 | Schema []struct { 20 | Name string `json:"Name"` 21 | Type string `json:"Type"` 22 | } `json:"Schema"` 23 | Results internal.QueryResults `json:"Results"` 24 | } 25 | 26 | type MDEConfig struct { 27 | } 28 | 29 | type MDEProcessor struct { 30 | *InputProcessor 31 | Config MDEConfig 32 | } 33 | 34 | type MDESession struct { 35 | initialized bool 36 | token string 37 | } 38 | 39 | // Used to persist MDE token across requests 40 | var _MDESession MDESession 41 | 42 | func (m *MDEProcessor) ExecuteQuery() (internal.QueryResults, error) { 43 | if m.Credentials.MDEAppSecret == "" && (m.Credentials.MDEManagedIdentity == "" || m.Credentials.MDEManagedIdentity == "false") { 44 | return internal.QueryResults{}, fmt.Errorf("MDEAppSecret is empty and no Managed Identity Enabled, skipping..") 45 | } 46 | 47 | if !_MDESession.initialized { 48 | _MDESession.token = MDEToken(m.Credentials) 49 | _MDESession.initialized = true 50 | } 51 | 52 | url := "https://api.securitycenter.microsoft.com/api/advancedqueries/run" 53 | 54 | body := map[string]string{ 55 | "Query": m.Query, 56 | } 57 | jsonBody, err := json.Marshal(body) 58 | 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to marshal JSON request body: %v", err) 61 | } 62 | 63 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 66 | } 67 | 68 | req.Header.Set("Content-Type", "application/json") 69 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", _MDESession.token)) 70 | 71 | client := &http.Client{} 72 | resp, err := client.Do(req) 73 | if err != nil { 74 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 75 | } 76 | defer resp.Body.Close() 77 | 78 | if resp.StatusCode != http.StatusOK { 79 | log.Printf("status code: %v. (StatusCode 400 = most likely there is a syntax error in the query)", resp.StatusCode) 80 | return nil, fmt.Errorf("failed to run query %q: %w", m.Name, err) 81 | } 82 | 83 | result, err := io.ReadAll(resp.Body) 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to read HTTP response body: %v", err) 86 | } 87 | // Get rows 88 | var MDEResults MDEResults 89 | 90 | err = json.Unmarshal([]byte(result), &MDEResults) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to unmarshal JSON data received from MDE: %w", err) 93 | } 94 | return MDEResults.Results, nil 95 | } 96 | 97 | func MDEToken(creds internal.Credentials) string { 98 | var cred azcore.TokenCredential 99 | var err error 100 | 101 | if creds.MDEManagedIdentity == "true" { 102 | log.Printf("Using Managed Identity for MDE") 103 | cred, err = azidentity.NewManagedIdentityCredential(nil) 104 | if err != nil { 105 | fmt.Println("Error creating ManagedIdentityCredential:", err) 106 | } 107 | } else { 108 | cred, err = azidentity.NewClientSecretCredential(creds.MDETenantID, creds.MDEAppID, creds.MDEAppSecret, nil) 109 | if err != nil { 110 | fmt.Println("Error creating ClientSecretCredential:", err) 111 | } 112 | } 113 | 114 | var ctx = context.Background() 115 | policy := policy.TokenRequestOptions{Scopes: []string{"https://api.securitycenter.microsoft.com/.default"}} 116 | token, err := cred.GetToken(ctx, policy) 117 | if err != nil { 118 | fmt.Println("Error getting token:", err) 119 | } 120 | return token.Token 121 | } 122 | -------------------------------------------------------------------------------- /input_processor/msgraph.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "falconhound/internal" 7 | "fmt" 8 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 9 | "io" 10 | "log" 11 | "net/http" 12 | "strings" 13 | 14 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" 15 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 16 | ) 17 | 18 | type MSGraphConfig struct { 19 | } 20 | 21 | type MSGraphProcessor struct { 22 | *InputProcessor 23 | Config MSGraphConfig 24 | } 25 | 26 | type MSGraphSession struct { 27 | initialized bool 28 | token string 29 | } 30 | 31 | type MSGraphResults struct { 32 | Schema []struct { 33 | Context string `json:"@odata.context"` 34 | } `json:"Schema"` 35 | Results internal.QueryResults `json:"Value"` 36 | } 37 | 38 | var _MSGraphSession MSGraphSession 39 | 40 | func (m *MSGraphProcessor) ExecuteQuery() (internal.QueryResults, error) { 41 | if m.Credentials.GraphAppSecret == "" && (m.Credentials.GraphManagedIdentity == "false" || m.Credentials.GraphManagedIdentity == "") { 42 | return internal.QueryResults{}, fmt.Errorf("GraphAppSecret is empty, skipping..") 43 | } 44 | 45 | if !_MSGraphSession.initialized { 46 | _MSGraphSession.token = graphToken(m.Credentials) 47 | _MSGraphSession.initialized = true 48 | } 49 | 50 | // Build request based on query in YAML 51 | baseURL := "https://graph.microsoft.com/" 52 | reqUrl := fmt.Sprintf("%s%s", baseURL, m.Query) 53 | url := strings.TrimSpace(reqUrl) 54 | 55 | req, err := http.NewRequest("GET", url, nil) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 58 | } 59 | 60 | req.Header.Set("Content-Type", "application/json") 61 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", _MSGraphSession.token)) 62 | 63 | client := &http.Client{} 64 | resp, err := client.Do(req) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 67 | } 68 | defer resp.Body.Close() 69 | 70 | if resp.StatusCode != http.StatusOK { 71 | return nil, fmt.Errorf("failed to run query %q: %w", m.Name, err) 72 | } 73 | 74 | result, err := io.ReadAll(resp.Body) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to read HTTP response body: %v", err) 77 | } 78 | 79 | // Get rows 80 | var MSGraphResults MSGraphResults 81 | 82 | err = json.Unmarshal([]byte(result), &MSGraphResults) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to unmarshal JSON data received from MSGraph: %w", err) 85 | } 86 | 87 | // add GraphTenantID to each row. BH requires this and it is not returned by MSGraph 88 | for i := range MSGraphResults.Results { 89 | MSGraphResults.Results[i]["GraphTenantID"] = m.Credentials.GraphTenantID 90 | } 91 | 92 | return MSGraphResults.Results, nil 93 | } 94 | 95 | func graphToken(creds internal.Credentials) string { 96 | var cred azcore.TokenCredential 97 | var err error 98 | 99 | if creds.GraphManagedIdentity == "true" { 100 | log.Printf("Using Managed Identity for Graph API") 101 | cred, err = azidentity.NewManagedIdentityCredential(nil) 102 | if err != nil { 103 | fmt.Println("Error creating ManagedIdentityCredential:", err) 104 | panic(err) 105 | } 106 | } else { 107 | cred, err = azidentity.NewClientSecretCredential(creds.GraphTenantID, creds.GraphAppID, creds.GraphAppSecret, nil) 108 | if err != nil { 109 | fmt.Println("Error creating ClientSecretCredential:", err) 110 | panic(err) 111 | } 112 | } 113 | 114 | var ctx = context.Background() 115 | policy := policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}} 116 | token, err := cred.GetToken(ctx, policy) 117 | if err != nil { 118 | fmt.Println("Error getting token:", err) 119 | panic(err) 120 | } 121 | return token.Token 122 | } 123 | -------------------------------------------------------------------------------- /input_processor/msgraphapi.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "encoding/json" 5 | "falconhound/input_processor/input_cmd" 6 | "falconhound/internal" 7 | "fmt" 8 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 9 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 10 | msgraphsdk "github.com/microsoftgraph/msgraph-beta-sdk-go" 11 | "log" 12 | "strings" 13 | ) 14 | 15 | type MsGraphApiConfig struct { 16 | } 17 | 18 | type MsGraphApiProcessor struct { 19 | *InputProcessor 20 | Config MsGraphApiConfig 21 | } 22 | 23 | type MSGraphApiSession struct { 24 | initialized bool 25 | client msgraphsdk.GraphServiceClient 26 | } 27 | 28 | var _MSGraphApiSession MSGraphApiSession 29 | 30 | type MsGraphApiResults struct { 31 | Schema []struct { 32 | Context string `json:"@odata.context"` 33 | } `json:"Schema"` 34 | Results internal.QueryResults `json:"Value"` 35 | } 36 | 37 | func (m *MsGraphApiProcessor) ExecuteQuery() (internal.QueryResults, error) { 38 | if m.Credentials.GraphAppSecret == "" && (m.Credentials.GraphManagedIdentity == "false" || m.Credentials.GraphManagedIdentity == "") { 39 | return internal.QueryResults{}, fmt.Errorf("GraphAppSecret is empty, skipping..") 40 | } 41 | 42 | if !_MSGraphApiSession.initialized { 43 | _MSGraphApiSession.client = graphClient(m.Credentials) 44 | _MSGraphApiSession.initialized = true 45 | } 46 | 47 | var MsGraphApiResults internal.QueryResults 48 | m.Query = strings.TrimSpace(m.Query) 49 | switch m.Query { 50 | case "GetMFA": 51 | results, err := input_cmd.GetMFA(_MSGraphApiSession.client) 52 | if err != nil { 53 | return internal.QueryResults{}, fmt.Errorf("GetMFA failed: %v", err) 54 | } 55 | err = json.Unmarshal([]byte(results), &MsGraphApiResults) 56 | if err != nil { 57 | return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON: %v", err) 58 | } 59 | return MsGraphApiResults, nil 60 | //case "GetUserRisk": 61 | // results, err := input_cmd.GetUserRisk(_MSGraphApiSession.client) 62 | // if err != nil { 63 | // return internal.QueryResults{}, fmt.Errorf("GetUserRisk failed: %v", err) 64 | // } 65 | // err = json.Unmarshal([]byte(results), &MsGraphApiResults) 66 | // if err != nil { 67 | // return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON: %v", err) 68 | // } 69 | // return MsGraphApiResults, nil 70 | case "GetOAuthConsent": 71 | results, err := input_cmd.GetOAuthConsent(_MSGraphApiSession.client) 72 | if err != nil { 73 | return internal.QueryResults{}, fmt.Errorf("GetOAuthConsent failed: %v", err) 74 | } 75 | err = json.Unmarshal([]byte(results), &MsGraphApiResults) 76 | if err != nil { 77 | return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON: %v", err) 78 | } 79 | return MsGraphApiResults, nil 80 | case "GetDynamicGroups": 81 | results, err := input_cmd.GetDynamicGroups(_MSGraphApiSession.client) 82 | if err != nil { 83 | return internal.QueryResults{}, fmt.Errorf("GetDynamicGroups failed: %v", err) 84 | } 85 | err = json.Unmarshal([]byte(results), &MsGraphApiResults) 86 | if err != nil { 87 | return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON: %v", err) 88 | 89 | } 90 | return MsGraphApiResults, nil 91 | } 92 | 93 | return nil, fmt.Errorf("Query not found: %s", m.Query) 94 | } 95 | 96 | func graphClient(creds internal.Credentials) msgraphsdk.GraphServiceClient { 97 | var cred azcore.TokenCredential 98 | var err error 99 | 100 | if creds.GraphManagedIdentity == "true" { 101 | log.Printf("Using Managed Identity for Graph API") 102 | cred, err = azidentity.NewManagedIdentityCredential(nil) 103 | if err != nil { 104 | fmt.Println("Error creating ManagedIdentityCredential:", err) 105 | panic(err) 106 | } 107 | } else { 108 | cred, err = azidentity.NewClientSecretCredential(creds.GraphTenantID, creds.GraphAppID, creds.GraphAppSecret, nil) 109 | if err != nil { 110 | fmt.Println("Error creating ClientSecretCredential:", err) 111 | panic(err) 112 | } 113 | } 114 | 115 | graphClient, err := msgraphsdk.NewGraphServiceClientWithCredentials(cred, []string{"https://graph.microsoft.com/.default"}) 116 | if err != nil { 117 | fmt.Println("Error creating the client:", err) 118 | panic(err) 119 | } 120 | 121 | return *graphClient 122 | } 123 | -------------------------------------------------------------------------------- /input_processor/neo4j.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "encoding/json" 5 | "falconhound/internal" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/neo4j/neo4j-go-driver/v4/neo4j" 10 | ) 11 | 12 | type Neo4jConfig struct { 13 | } 14 | 15 | type Neo4jProcessor struct { 16 | *InputProcessor 17 | Config Neo4jConfig 18 | } 19 | 20 | func (m *Neo4jProcessor) ExecuteQuery() (internal.QueryResults, error) { 21 | if m.Credentials.Neo4jPassword == "" { 22 | return internal.QueryResults{}, fmt.Errorf("Neo4jPassword is empty, skipping..") 23 | } 24 | 25 | results, err := ReadNeo4j(m.Query, m.Credentials) 26 | if err != nil { 27 | return internal.QueryResults{}, err 28 | } 29 | return results, nil 30 | } 31 | 32 | func ReadNeo4j(query string, creds internal.Credentials) (internal.QueryResults, error) { 33 | driver, err := neo4j.NewDriver(creds.Neo4jUri, neo4j.BasicAuth(creds.Neo4jUsername, creds.Neo4jPassword, "")) 34 | if err != nil { 35 | log.Printf("Error connecting to Neo4j: %v", err) 36 | panic(err) 37 | } 38 | defer driver.Close() 39 | 40 | // Create a new Neo4j session 41 | session := driver.NewSession(neo4j.SessionConfig{}) 42 | defer session.Close() 43 | // Create a new node 44 | neoresult, err := session.Run(query, map[string]interface{}{}) 45 | if err != nil { 46 | log.Printf("Error creating node: %v", err) 47 | return nil, err 48 | } 49 | 50 | // Create a slice to hold the merged JSON objects 51 | queryResults := make(internal.QueryResults, 0) 52 | 53 | // Iterate over the records returned by the query 54 | for neoresult.Next() { 55 | record := neoresult.Record() 56 | 57 | // Iterate over the fields of the record 58 | for i := 0; i < len(record.Values); i++ { 59 | // Get the value at the current index 60 | field := record.Values[i] 61 | 62 | // Convert the field to a JSON string 63 | fieldJSON, err := json.Marshal(field) 64 | if err != nil { 65 | log.Printf("Error marshalling JSON: %v", err) 66 | return nil, err 67 | } 68 | 69 | // Decode the JSON string into a map 70 | var fieldMap map[string]interface{} 71 | err = json.Unmarshal(fieldJSON, &fieldMap) 72 | if err != nil { 73 | log.Printf("Error unmarshalling JSON: %v", err) 74 | return nil, err 75 | } 76 | 77 | // Append the map to the mergedJSON slice 78 | queryResults = append(queryResults, fieldMap) 79 | } 80 | } 81 | return queryResults, nil 82 | 83 | } 84 | -------------------------------------------------------------------------------- /input_processor/sentinel.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "falconhound/internal" 8 | "fmt" 9 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" 10 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 11 | "io" 12 | "log" 13 | "net/http" 14 | "strings" 15 | 16 | auth "github.com/gamepat/azure-oauth2-token" 17 | ) 18 | 19 | type SentinelConfig struct { 20 | } 21 | 22 | type SentinelProcessor struct { 23 | *InputProcessor 24 | Config SentinelConfig 25 | } 26 | 27 | type SentinelResults struct { 28 | Tables []struct { 29 | Name string `json:"name"` 30 | Columns []struct { 31 | Name string `json:"name"` 32 | Type string `json:"type"` 33 | } `json:"columns"` 34 | Rows [][]interface{} `json:"rows"` 35 | } `json:"tables"` 36 | } 37 | 38 | func (m *SentinelProcessor) ExecuteQuery() (internal.QueryResults, error) { 39 | if m.Credentials.SentinelAppSecret == "" && (m.Credentials.SentinelManagedIdentity == "" || m.Credentials.SentinelManagedIdentity == "false") { 40 | return internal.QueryResults{}, fmt.Errorf("SentinelAppSecret is empty and no Managed Identity set, skipping..") 41 | } 42 | 43 | results, err := LArunQuery(m.Query, m.Credentials) 44 | if err != nil { 45 | if strings.Contains(err.Error(), "unexpected HTTP status code: 400") { 46 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q. Most likely there is a syntax error in the query", m.Query) 47 | } else { 48 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q: %w", m.Query, err) 49 | } 50 | } 51 | 52 | // Get rows 53 | var sentinelResults SentinelResults 54 | 55 | err = json.Unmarshal([]byte(results), &sentinelResults) 56 | if err != nil { 57 | return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON from Sentinel: %v", err) 58 | } 59 | 60 | // Sentinel data parsing 61 | rows := sentinelResults.Tables[0].Rows 62 | columns := sentinelResults.Tables[0].Columns 63 | 64 | queryResults := make(internal.QueryResults, len(rows)) 65 | for i, row := range rows { 66 | rowMap := make(map[string]interface{}) 67 | for j, column := range columns { 68 | columnName := column.Name 69 | rowValue := row[j] 70 | rowMap[columnName] = rowValue 71 | } 72 | queryResults[i] = rowMap 73 | } 74 | 75 | return queryResults, nil 76 | } 77 | 78 | func LArunQuery(query string, creds internal.Credentials) ([]byte, error) { 79 | url := fmt.Sprintf("https://api.loganalytics.io/v1/workspaces/%s/query/", creds.SentinelWorkspaceID) 80 | body := map[string]string{ 81 | "query": query, 82 | } 83 | jsonBody, err := json.Marshal(body) 84 | 85 | if err != nil { 86 | return nil, fmt.Errorf("failed to marshal JSON request body: %v", err) 87 | } 88 | 89 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) 90 | if err != nil { 91 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 92 | } 93 | 94 | req.Header.Set("Content-Type", "application/json") 95 | token, err := getToken(creds) 96 | if err != nil { 97 | return nil, fmt.Errorf("failed to get token: %v", err) 98 | } 99 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 100 | 101 | client := &http.Client{} 102 | resp, err := client.Do(req) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 105 | } 106 | defer resp.Body.Close() 107 | 108 | if resp.StatusCode != http.StatusOK { 109 | log.Printf("results: %v", resp) 110 | return nil, fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode) 111 | } 112 | 113 | respBody, err := io.ReadAll(resp.Body) 114 | if err != nil { 115 | return nil, fmt.Errorf("failed to read HTTP response body: %v", err) 116 | } 117 | 118 | return respBody, nil 119 | } 120 | 121 | func getToken(creds internal.Credentials) (string, error) { 122 | var token string 123 | var err error 124 | 125 | if creds.SentinelManagedIdentity == "true" { 126 | log.Printf("Using Managed Identity for Sentinel") 127 | cred, err := azidentity.NewManagedIdentityCredential(nil) 128 | if err != nil { 129 | return "", fmt.Errorf("error creating ManagedIdentityCredential: %v", err) 130 | } 131 | ctx := context.Background() 132 | policy := policy.TokenRequestOptions{Scopes: []string{"https://api.loganalytics.io/.default"}} 133 | tk, err := cred.GetToken(ctx, policy) 134 | if err != nil { 135 | return "", fmt.Errorf("error getting token: %v", err) 136 | } 137 | token = tk.Token 138 | } else { 139 | cfg := auth.AuthConfig{ 140 | ClientID: creds.SentinelAppID, 141 | ClientSecret: creds.SentinelAppSecret, 142 | ClientScope: "https://api.loganalytics.io/.default", 143 | } 144 | token, err = auth.RequestAccessToken(creds.SentinelTenantID, cfg) 145 | if err != nil { 146 | return "", fmt.Errorf("error requesting access token: %v", err) 147 | } 148 | } 149 | 150 | return token, nil 151 | } 152 | -------------------------------------------------------------------------------- /input_processor/splunk.go: -------------------------------------------------------------------------------- 1 | package input_processor 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "falconhound/internal" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type JobResponse struct { 17 | Sid string `json:"sid"` 18 | } 19 | 20 | type JobStatusResponse struct { 21 | Entry []struct { 22 | Content struct { 23 | IsDone bool `json:"isDone"` 24 | } `json:"content"` 25 | } `json:"entry"` 26 | } 27 | 28 | type SplunkResults struct { 29 | Schema []struct { 30 | Name string `json:"Name"` 31 | Type string `json:"Type"` 32 | } `json:"Schema"` 33 | Results internal.QueryResults `json:"results"` 34 | } 35 | 36 | type SplunkConfig struct { 37 | } 38 | 39 | type SplunkProcessor struct { 40 | *InputProcessor 41 | Config SplunkConfig 42 | } 43 | 44 | func (m *SplunkProcessor) ExecuteQuery() (internal.QueryResults, error) { 45 | if m.Credentials.SplunkApiToken == "" { 46 | return internal.QueryResults{}, fmt.Errorf("SplunkApiToken is empty, skipping..") 47 | } 48 | 49 | results, err := QuerySplunk(m.Query, m.Credentials, m.Debug) 50 | if err != nil { 51 | if strings.Contains(err.Error(), "unexpected HTTP status code: 400") { 52 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q. Most likely there is a syntax error in the query", m.Query) 53 | } else { 54 | return internal.QueryResults{}, fmt.Errorf("failed to run query %q: %w", m.Query, err) 55 | } 56 | } 57 | 58 | // Get rows 59 | var SplunkResults SplunkResults 60 | 61 | err = json.Unmarshal([]byte(results), &SplunkResults) 62 | if err != nil { 63 | return internal.QueryResults{}, fmt.Errorf("failed to unmarshal JSON from Splunk: %v", err) 64 | } 65 | 66 | queryResults := SplunkResults.Results 67 | 68 | return queryResults, nil 69 | } 70 | 71 | func QuerySplunk(query string, credentials internal.Credentials, debug bool) (string, error) { 72 | baseURL := credentials.SplunkUrl + ":" + credentials.SplunkApiPort 73 | authHeader := "Bearer " + credentials.SplunkApiToken 74 | 75 | // Create a custom HTTP client and ignore SSL errors 76 | tr := &http.Transport{ 77 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 78 | } 79 | client := &http.Client{Transport: tr} 80 | 81 | searchQuery := "search " + query 82 | // replace %s in query with the credentials.SplunkIndex 83 | searchQuery = strings.Replace(searchQuery, "%s", credentials.SplunkIndex, 1) 84 | 85 | // Start a search job and request json response 86 | data := url.Values{} 87 | data.Set("search", searchQuery) 88 | data.Set("output_mode", "json") 89 | 90 | req, err := http.NewRequest("POST", baseURL+"/services/search/jobs", strings.NewReader(data.Encode())) 91 | if err != nil { 92 | log.Fatalln(err) 93 | } 94 | req.Header.Set("Authorization", authHeader) 95 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 96 | 97 | resp, err := client.Do(req) 98 | if err != nil { 99 | log.Fatalln(err) 100 | } 101 | defer resp.Body.Close() 102 | 103 | bodyBytes, err := io.ReadAll(resp.Body) 104 | if err != nil { 105 | log.Fatalln(err) 106 | } 107 | 108 | var jobResponse JobResponse 109 | err = json.Unmarshal(bodyBytes, &jobResponse) 110 | if err != nil { 111 | log.Fatalln(err) 112 | } 113 | 114 | sid := jobResponse.Sid 115 | 116 | polldata := url.Values{} 117 | polldata.Set("output_mode", "json") 118 | 119 | // Poll for search job completion 120 | for { 121 | req, err := http.NewRequest("GET", baseURL+"/services/search/jobs/"+sid, strings.NewReader(polldata.Encode())) 122 | if err != nil { 123 | log.Fatalln(err) 124 | } 125 | req.Header.Set("Authorization", authHeader) 126 | 127 | resp, err := client.Do(req) 128 | if err != nil { 129 | log.Fatalln(err) 130 | } 131 | 132 | bodyBytes, err := io.ReadAll(resp.Body) 133 | if err != nil { 134 | log.Fatalln(err) 135 | } 136 | 137 | var jobStatusResponse JobStatusResponse 138 | err = json.Unmarshal(bodyBytes, &jobStatusResponse) 139 | if err != nil { 140 | log.Fatalln(err) 141 | } 142 | 143 | if jobStatusResponse.Entry[0].Content.IsDone { 144 | if debug { 145 | log.Printf("[i] Job %s completed, getting data\n", sid) 146 | } 147 | break 148 | } 149 | 150 | if debug { 151 | log.Printf("[»] Job %s still running, waiting 1 second\n", sid) 152 | } 153 | time.Sleep(1 * time.Second) 154 | } 155 | 156 | // Get the search results 157 | resultdata := url.Values{} 158 | resultdata.Set("output_mode", "json") 159 | req, err = http.NewRequest("GET", baseURL+"/services/search/jobs/"+sid+"/results", strings.NewReader(resultdata.Encode())) 160 | if err != nil { 161 | log.Fatalln(err) 162 | } 163 | req.Header.Set("Authorization", authHeader) 164 | 165 | resp, err = client.Do(req) 166 | if err != nil { 167 | log.Fatalln(err) 168 | } 169 | 170 | bodyBytes, err = io.ReadAll(resp.Body) 171 | if err != nil { 172 | log.Fatalln(err) 173 | } 174 | 175 | return string(bodyBytes), nil 176 | 177 | } 178 | -------------------------------------------------------------------------------- /internal/banner.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func Banner() { 8 | fmt.Println(" %????????;;'") 9 | fmt.Println(" %??++;;;;??????'''+") 10 | fmt.Println(" ++++%??%%%???;;???''+;") 11 | fmt.Println(" ???++++;??????%#%?????;;;'';") 12 | fmt.Println(" +;;+?????????.'#.......?%%??") 13 | fmt.Println(" +;;;+???????????.??.;%##%%%%%%??") 14 | fmt.Println(" ##??;;;???';;???????%%%#####%%%%#??.") 15 | fmt.Println(" #?;;;??;;;';;?????+?+?%%... %???????'") 16 | fmt.Println(" +;;';?'';''';;;;;;;???????.. +''.????'") 17 | fmt.Println(" ;##+';;;';';;'''';'''''........ .'++'") 18 | fmt.Println(" #+;;;';'''''';;;''''........' +'") 19 | fmt.Println(" ';'';;';;;;;';;..''....'.''''. ;") 20 | fmt.Println(" ;;.''''.'''''..'....''''''..") 21 | fmt.Println(" . '''..'''''''...'''....'..") 22 | fmt.Println(" ''''''.''''''..''''...... FalconForce Sentry") 23 | fmt.Println(" ' '''...'''''......... ", Version) 24 | fmt.Println(" ;''.'''.........'. ------------------") 25 | fmt.Println(" ''''.......'? Usage: FalconHound -[options]") 26 | fmt.Println(" '''.'''' https://github.com/FalconForceTeam/FalconHound") 27 | fmt.Println(" ''") 28 | } 29 | -------------------------------------------------------------------------------- /internal/credentials.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | // The `config:"name"` tags are used to identify under which key the value is stored 4 | // in the config. 5 | type Credentials struct { 6 | SentinelAppSecret string `config:"sentinel.appSecret"` 7 | SentinelAppID string `config:"sentinel.appID"` 8 | SentinelTenantID string `config:"sentinel.tenantID"` 9 | SentinelTargetTable string `config:"sentinel.targetTable"` 10 | SentinelResourceGroup string `config:"sentinel.resourceGroup"` 11 | SentinelSharedKey string `config:"sentinel.sharedKey"` 12 | SentinelSubscriptionID string `config:"sentinel.subscriptionID"` 13 | SentinelWorkspaceID string `config:"sentinel.workspaceID"` 14 | SentinelWorkspaceName string `config:"sentinel.workspaceName"` 15 | SentinelManagedIdentity string `config:"sentinel.managedIdentity"` 16 | MDETenantID string `config:"mde.tenantID"` 17 | MDEAppID string `config:"mde.appID"` 18 | MDEAppSecret string `config:"mde.appSecret"` 19 | MDEManagedIdentity string `config:"mde.managedIdentity"` 20 | GraphTenantID string `config:"graph.tenantID"` 21 | GraphAppID string `config:"graph.appID"` 22 | GraphAppSecret string `config:"graph.appSecret"` 23 | GraphManagedIdentity string `config:"graph.managedIdentity"` 24 | AdxTenantID string `config:"adx.tenantID"` 25 | AdxAppID string `config:"adx.appID"` 26 | AdxAppSecret string `config:"adx.appSecret"` 27 | AdxClusterURL string `config:"adx.clusterUrl"` 28 | AdxDatabase string `config:"adx.database"` 29 | Neo4jUri string `config:"neo4j.uri"` 30 | Neo4jUsername string `config:"neo4j.username"` 31 | Neo4jPassword string `config:"neo4j.password"` 32 | SplunkUrl string `config:"splunk.url"` 33 | SplunkIndex string `config:"splunk.index"` 34 | SplunkApiPort string `config:"splunk.apiport"` 35 | SplunkApiToken string `config:"splunk.apitoken"` 36 | SplunkHecPort string `config:"splunk.hecport"` 37 | SplunkHecToken string `config:"splunk.hectoken"` 38 | BHUrl string `config:"bloodhound.url"` 39 | BHTokenID string `config:"bloodhound.tokenID"` 40 | BHTokenKey string `config:"bloodhound.tokenKey"` 41 | LogScaleUrl string `config:"logscale.url"` 42 | LogScaleToken string `config:"logscale.token"` 43 | LogScaleRepository string `config:"logscale.repository"` 44 | LimaCharlieAPIUrl string `config:"limacharlie.apiurl"` 45 | LimaCharlieOrgId string `config:"limacharlie.orgid"` 46 | LimaCharlieIngestKey string `config:"limacharlie.ingestkey"` 47 | ElasticCloudID string `config:"elastic.cloudid"` 48 | ElasticApiKey string `config:"elastic.apikey"` 49 | } 50 | -------------------------------------------------------------------------------- /internal/query_results.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | type QueryResult map[string]interface{} 4 | type QueryResults []QueryResult 5 | -------------------------------------------------------------------------------- /internal/version.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | const Version = "FalconHound v1.4.2" 4 | -------------------------------------------------------------------------------- /output_processor/adx.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "falconhound/internal" 8 | "github.com/Azure/azure-kusto-go/kusto" 9 | "github.com/Azure/azure-kusto-go/kusto/ingest" 10 | "log" 11 | "time" 12 | ) 13 | 14 | type ADXOutputConfig struct { 15 | BHQuery string 16 | QueryName string 17 | QueryDescription string 18 | QueryEventID string 19 | Table string 20 | BatchSize int 21 | } 22 | 23 | type ADXOutputProcessor struct { 24 | *OutputProcessor 25 | Config ADXOutputConfig 26 | } 27 | 28 | func (m *ADXOutputProcessor) BatchSize() int { 29 | if m.Config.BatchSize > 0 { 30 | return m.Config.BatchSize 31 | } 32 | return 1 33 | } 34 | 35 | func (m *ADXOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 36 | jsonData, err := json.Marshal(QueryResults) 37 | if err != nil { 38 | log.Fatalf("failed to marshal data: %s", err) 39 | return err 40 | } 41 | 42 | runTime := time.Now().UTC().Format("2006-01-02T15:04:05.0000000Z07:00") 43 | // Create a data object ADXdata with data from EventData or EnrichmentData, whichever is not nil 44 | ADXData := map[string]interface{}{ 45 | "Name": m.Config.QueryName, 46 | "Description": m.Config.QueryDescription, 47 | "EventID": m.Config.QueryEventID, 48 | "BHQuery": m.Config.BHQuery, 49 | "Timestamp": runTime, 50 | "EventData": string(jsonData), 51 | } 52 | 53 | kustoConnectionStringBuilder := kusto.NewConnectionStringBuilder(m.Credentials.AdxClusterURL) 54 | kustoConnectionString := kustoConnectionStringBuilder.WithAadAppKey(m.Credentials.AdxAppID, m.Credentials.AdxAppSecret, m.Credentials.AdxTenantID) 55 | 56 | client, err := kusto.New(kustoConnectionString) 57 | if err != nil { 58 | log.Fatalf("failed to create Kusto client: %s", err) 59 | return err 60 | } 61 | 62 | // Create an ingestion client 63 | ingestor, err := ingest.New(client, m.Credentials.AdxDatabase, m.Config.Table) 64 | if err != nil { 65 | log.Fatalf("failed to create ingestor: %s", err) 66 | return err 67 | } 68 | 69 | if m.Debug { 70 | log.Printf("clusterURL: %s", m.Credentials.AdxClusterURL) 71 | log.Println("Ingesting the following data: ", ADXData) 72 | } 73 | 74 | // Ingest the data 75 | data, err := json.Marshal(ADXData) 76 | ctx := context.Background() 77 | reader := bytes.NewReader(data) 78 | _, err = ingestor.FromReader(ctx, reader, ingest.FileFormat(ingest.MultiJSON)) 79 | if err != nil { 80 | log.Fatalf("failed to create file: %s", err) 81 | return err 82 | } 83 | 84 | return client.Close() 85 | } 86 | -------------------------------------------------------------------------------- /output_processor/base.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import "falconhound/internal" 4 | 5 | type OutputProcessor struct { 6 | Type string 7 | Enabled bool 8 | Credentials internal.Credentials 9 | Debug bool 10 | } 11 | 12 | type OutputProcessorInterface interface { 13 | ProduceOutput(internal.QueryResults) error 14 | Finalize() error 15 | BatchSize() int 16 | OutputProcessorConfig() OutputProcessor 17 | } 18 | 19 | func (m *OutputProcessor) OutputProcessorConfig() OutputProcessor { 20 | return *m 21 | } 22 | 23 | // Can be used to perform any cleanup operations after inputs have been processed 24 | func (m *OutputProcessor) Finalize() error { 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /output_processor/bloodhound.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "falconhound/internal" 9 | "fmt" 10 | "io" 11 | "log" 12 | "net/http" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | type BloodHoundOutputConfig struct { 18 | Query string 19 | Parameters map[string]string 20 | } 21 | 22 | type BloodHoundOutputProcessor struct { 23 | *OutputProcessor 24 | Config BloodHoundOutputConfig 25 | } 26 | 27 | func (m *BloodHoundOutputProcessor) BatchSize() int { 28 | return 1 29 | } 30 | 31 | func (m *BloodHoundOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 32 | if len(QueryResults) == 0 { 33 | return nil 34 | } 35 | var queryResult internal.QueryResult = QueryResults[0] 36 | var params = make(map[string]interface{}) 37 | for key, value := range m.Config.Parameters { 38 | rowValue, ok := queryResult[value] 39 | if !ok { 40 | return fmt.Errorf("parameter %s not found in query results", value) 41 | } 42 | // Insert into map 43 | params[key] = rowValue 44 | } 45 | if m.Debug { 46 | fmt.Printf("Query: %#v, parameters: %#v\n", m.Config.Query, params) 47 | } 48 | 49 | return WriteBloodHound(m.Config.Query, params, m.Credentials) 50 | } 51 | 52 | // TODO also embed the driver and session in the struct 53 | //var session BloodHound.Session 54 | 55 | func WriteBloodHound(query string, params map[string]interface{}, creds internal.Credentials) error { 56 | if creds.BHTokenKey == "" { 57 | return fmt.Errorf("BHTokenKey is empty, skipping..") 58 | } 59 | 60 | // replace parameters in query 61 | //for key, value := range params { 62 | // query = strings.ReplaceAll(query, fmt.Sprintf("$%s", key), fmt.Sprintf("'%v'", value)) 63 | //} 64 | for key, value := range params { 65 | upperValue := strings.ToUpper(fmt.Sprintf("%v", value)) 66 | query = strings.ReplaceAll(query, fmt.Sprintf("$%s", key), fmt.Sprintf("'%s'", upperValue)) 67 | } 68 | 69 | // Convert query from a multiline string from the yaml to a single line string so the API can parse it 70 | query = strings.ReplaceAll(query, "\n", " ") 71 | log.Printf("Query: %s\n", query) 72 | 73 | method := "POST" 74 | uri := "/api/v2/graphs/cypher" 75 | queryBody := fmt.Sprintf("{\"query\":\"%s\"}", query) 76 | body := []byte(queryBody) 77 | 78 | // The first HMAC digest is the token key 79 | digester := hmac.New(sha256.New, []byte(creds.BHTokenKey)) 80 | 81 | // OperationKey is the first HMAC digestresource 82 | digester.Write([]byte(fmt.Sprintf("%s%s", method, uri))) 83 | 84 | // Update the digester for further chaining 85 | digester = hmac.New(sha256.New, digester.Sum(nil)) 86 | datetimeFormatted := time.Now().Format("2006-01-02T15:04:05.999999-07:00") 87 | digester.Write([]byte(datetimeFormatted[:13])) 88 | 89 | // Update the digester for further chaining 90 | digester = hmac.New(sha256.New, digester.Sum(nil)) 91 | 92 | // Body signing is the last HMAC digest link in the signature chain. This encodes the request body as part of 93 | // the signature to prevent replay attacks that seek to modify the payload of a signed request. In the case 94 | // where there is no body content the HMAC digest is computed anyway, simply with no values written to the 95 | // digester. 96 | if body != nil { 97 | digester.Write(body) 98 | } 99 | 100 | bhendpoint := fmt.Sprintf("%s%s", creds.BHUrl, uri) 101 | 102 | // Perform the request with the signed and expected headers 103 | req, err := http.NewRequest(method, bhendpoint, bytes.NewBuffer(body)) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | req.Header.Set("User-Agent", internal.Version) 109 | req.Header.Set("Authorization", fmt.Sprintf("bhesignature %s", creds.BHTokenID)) 110 | req.Header.Set("RequestDate", datetimeFormatted) 111 | req.Header.Set("Signature", base64.StdEncoding.EncodeToString(digester.Sum(nil))) 112 | req.Header.Set("Content-Type", "application/json") 113 | 114 | client := &http.Client{} 115 | resp, err := client.Do(req) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | respbody, err := io.ReadAll(resp.Body) 121 | if err != nil { 122 | fmt.Println("Error reading response body:", err) 123 | } 124 | 125 | fmt.Println("Response:", string(respbody)) 126 | // TODO parse response body into QueryResults 127 | return nil 128 | } 129 | -------------------------------------------------------------------------------- /output_processor/csv.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "encoding/csv" 5 | "falconhound/internal" 6 | "fmt" 7 | "os" 8 | "reflect" 9 | "sort" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type CSVOutputConfig struct { 15 | Path string 16 | } 17 | 18 | type CSVOutputProcessor struct { 19 | *OutputProcessor 20 | Config CSVOutputConfig 21 | } 22 | 23 | // CSV does not require batching, will write all output in one go 24 | func (m *CSVOutputProcessor) BatchSize() int { 25 | return 0 26 | } 27 | 28 | func (m *CSVOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 29 | err := WriteCSV(QueryResults, m.Config.Path) 30 | return err 31 | } 32 | 33 | func WriteCSV(results internal.QueryResults, path string) error { 34 | //replace {{date}} with the current date if it exists 35 | path = strings.Replace(path, "{{date}}", time.Now().Format("2006-01-02"), 2) 36 | // create the folder if it doesn't exist 37 | err := os.MkdirAll(path[:strings.LastIndex(path, "/")], 0755) 38 | if err != nil { 39 | return fmt.Errorf("failed creating folder: %w", err) 40 | } 41 | // Create a file for writing 42 | csvFile, err := os.Create(path) 43 | if err != nil { 44 | return fmt.Errorf("failed creating file: %w", err) 45 | } 46 | // Initialize the writer 47 | csvWriter := csv.NewWriter(csvFile) 48 | 49 | var keys []string 50 | if len(results) > 0 { 51 | for k := range results[0] { 52 | keys = append(keys, k) 53 | } 54 | } 55 | // Sort the keys for consistency between runs 56 | sort.Slice(keys, func(i, j int) bool { 57 | return keys[i] < keys[j] 58 | }) 59 | // Write the header 60 | if err := csvWriter.Write(keys); err != nil { 61 | return fmt.Errorf("failed writing header: %w", err) 62 | } 63 | for _, record := range results { 64 | var row []string 65 | for _, k := range keys { 66 | v, ok := record[k] 67 | if !ok { 68 | v = nil 69 | } 70 | // Check if the value is a slice 71 | if reflect.TypeOf(v).Kind() == reflect.Slice { 72 | // Check if the slice elements are of type string 73 | if reflect.TypeOf(v).Elem().Kind() == reflect.String { 74 | v = strings.Join(v.([]string), ", ") 75 | } else if reflect.TypeOf(v).Elem().Kind() == reflect.Interface { 76 | // Handle the case where the slice elements are of type interface{} 77 | var strSlice []string 78 | for _, elem := range v.([]interface{}) { 79 | strSlice = append(strSlice, fmt.Sprintf("%v", elem)) 80 | } 81 | v = strings.Join(strSlice, ", ") 82 | } 83 | } 84 | row = append(row, fmt.Sprintf("%v", v)) 85 | } 86 | err := csvWriter.Write(row) 87 | if err != nil { 88 | return fmt.Errorf("failed writing row: %w", err) 89 | } 90 | } 91 | // Flush memory to disk 92 | csvWriter.Flush() 93 | csvFile.Close() 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /output_processor/html.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "falconhound/internal" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type HTMLOutputConfig struct { 15 | Path string 16 | QueryName string 17 | QueryEventID string 18 | QueryDescription string 19 | QueryDate string 20 | } 21 | 22 | type HTMLOutputProcessor struct { 23 | *OutputProcessor 24 | Config HTMLOutputConfig 25 | } 26 | 27 | // HTML does not require batching, will write all output in one go 28 | func (m *HTMLOutputProcessor) BatchSize() int { 29 | return 0 30 | } 31 | 32 | func (m *HTMLOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 33 | err := WriteHTML(QueryResults, m.Config.Path) 34 | return err 35 | } 36 | 37 | func WriteHTML(results internal.QueryResults, path string) error { 38 | path = strings.Replace(path, "{{date}}", time.Now().Format("2006-01-02"), 2) 39 | dir := filepath.Dir(path) 40 | if err := os.MkdirAll(dir, 0755); err != nil { 41 | return fmt.Errorf("failed creating directories: %w", err) 42 | } 43 | 44 | htmlFile, err := os.Create(path) 45 | if err != nil { 46 | return fmt.Errorf("failed creating file: %w", err) 47 | } 48 | defer htmlFile.Close() 49 | 50 | writer := bufio.NewWriter(htmlFile) 51 | 52 | // Convert results to JSON 53 | jsonData, err := json.Marshal(results) 54 | if err != nil { 55 | return fmt.Errorf("failed to marshal results to JSON: %w", err) 56 | } 57 | 58 | // Write the HTML header 59 | htmlHeader := ` 60 | 61 | 62 | 63 | 64 | 65 | Query Results 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 | 131 | 132 | 133 | ` 134 | _, err = writer.WriteString(htmlData) 135 | if err != nil { 136 | return fmt.Errorf("failed writing HTML footer: %w", err) 137 | } 138 | 139 | writer.Flush() 140 | htmlFile.Close() 141 | 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /output_processor/json.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "encoding/json" 5 | "falconhound/internal" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type JSONOutputConfig struct { 14 | Path string 15 | } 16 | 17 | type JSONOutputProcessor struct { 18 | *OutputProcessor 19 | Config JSONOutputConfig 20 | } 21 | 22 | // JSON does not require batching, will write all output in one go 23 | func (m *JSONOutputProcessor) BatchSize() int { 24 | return 0 25 | } 26 | 27 | func (m *JSONOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 28 | err := WriteJSON(QueryResults, m.Config.Path) 29 | return err 30 | } 31 | 32 | func WriteJSON(results internal.QueryResults, path string) error { 33 | path = strings.Replace(path, "{{date}}", time.Now().Format("2006-01-02"), 2) 34 | dir := filepath.Dir(path) 35 | if err := os.MkdirAll(dir, 0755); err != nil { 36 | return fmt.Errorf("failed creating directories: %w", err) 37 | } 38 | 39 | jsonFile, err := os.Create(path) 40 | if err != nil { 41 | return fmt.Errorf("failed creating file: %w", err) 42 | } 43 | defer jsonFile.Close() 44 | 45 | jsonData, err := json.MarshalIndent(results, "", " ") 46 | if err != nil { 47 | return fmt.Errorf("failed marshalling data: %w", err) 48 | } 49 | 50 | _, err = jsonFile.Write(jsonData) 51 | if err != nil { 52 | return fmt.Errorf("failed writing data: %w", err) 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /output_processor/limacharlie.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "falconhound/internal" 8 | "fmt" 9 | "net/http" 10 | ) 11 | 12 | type LimaCharlieOutputConfig struct { 13 | BHQuery string 14 | QueryName string 15 | QueryDescription string 16 | QueryEventID string 17 | } 18 | 19 | type LimaCharlieOutputProcessor struct { 20 | *OutputProcessor 21 | Config LimaCharlieOutputConfig 22 | } 23 | 24 | func (m *LimaCharlieOutputProcessor) BatchSize() int { 25 | return 0 26 | } 27 | 28 | func (m *LimaCharlieOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 29 | jsonData, err := json.Marshal(QueryResults) 30 | if err != nil { 31 | return err 32 | } 33 | LimaCharlieData := map[string]interface{}{ 34 | "Name": m.Config.QueryName, 35 | "Description": m.Config.QueryDescription, 36 | "EventID": m.Config.QueryEventID, 37 | "BHQuery": m.Config.BHQuery, 38 | "EventData": string(jsonData), 39 | } 40 | // Wrap LimaCharlieData in JSON 41 | LimaCharlieJSONData, err := json.Marshal(LimaCharlieData) 42 | if err != nil { 43 | } 44 | 45 | url := (m.Credentials.LimaCharlieAPIUrl) 46 | fmt.Println(url) 47 | // Create HTTP request 48 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(LimaCharlieJSONData)) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | // Set headers 54 | req.SetBasicAuth(m.Credentials.LimaCharlieOrgId, m.Credentials.LimaCharlieIngestKey) 55 | req.Header.Set("Content-Type", "application/json") 56 | req.Header.Set("lc-source", "FalconHound") 57 | req.Header.Set("lc-hint", "json") 58 | 59 | // Create HTTP client with custom transport to disable SSL verification 60 | tr := &http.Transport{ 61 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 62 | } 63 | client := &http.Client{Transport: tr} 64 | 65 | // Send HTTP request 66 | resp, err := client.Do(req) 67 | if err != nil { 68 | return err 69 | } 70 | defer resp.Body.Close() 71 | 72 | // Check response status code 73 | if resp.StatusCode != http.StatusOK { 74 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /output_processor/markdown.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "bufio" 5 | "falconhound/internal" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type MDOutputConfig struct { 13 | Path string 14 | QueryName string 15 | QueryEventID string 16 | QueryDescription string 17 | QueryDate string 18 | } 19 | 20 | type MDOutputProcessor struct { 21 | *OutputProcessor 22 | Config MDOutputConfig 23 | } 24 | 25 | // MD does not require batching, will write all output in one go 26 | func (m *MDOutputProcessor) BatchSize() int { 27 | return 0 28 | } 29 | 30 | func (m *MDOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 31 | err := WriteMD(QueryResults, m.Config) 32 | return err 33 | } 34 | 35 | // WriteMD writes the results to a MD file 36 | func WriteMD(results internal.QueryResults, config MDOutputConfig) error { 37 | //replace {{date}} with the current date if it exists 38 | path := strings.Replace(config.Path, "{{date}}", time.Now().Format("2006-01-02"), 2) 39 | // create the folder if it doesn't exist 40 | err := os.MkdirAll(path[:strings.LastIndex(path, "/")], 0755) 41 | if err != nil { 42 | return fmt.Errorf("failed creating folder: %w", err) 43 | } 44 | // Create a file for writing 45 | MDFile, err := os.Create(path) 46 | if err != nil { 47 | return fmt.Errorf("failed creating file: %w", err) 48 | } 49 | MDWriter := bufio.NewWriter(MDFile) 50 | 51 | var headers []string 52 | if len(results) == 0 { 53 | return nil 54 | } 55 | for key := range results[0] { 56 | headers = append(headers, key) 57 | } 58 | 59 | table := fmt.Sprintf("# Results for query: %s\n\n", config.QueryEventID) 60 | table += fmt.Sprintf("## %s\n\n", config.QueryName) 61 | table += fmt.Sprintf("Description: %s\n", config.QueryDescription) 62 | table += fmt.Sprintf("Date: %s\n\n", config.QueryDate) 63 | table += "| " + strings.Join(headers, " | ") + " |\n" 64 | table += "| " + strings.Repeat("--- | ", len(headers)) + "\n" 65 | _, err = MDWriter.WriteString(table) 66 | if err != nil { 67 | return fmt.Errorf("failed writing to file: %w", err) 68 | } 69 | 70 | // Generate the table rows 71 | for _, row := range results { 72 | var values []string 73 | for _, header := range headers { 74 | value := fmt.Sprintf("%v", row[header]) 75 | values = append(values, value) 76 | } 77 | tablerow := "| " + strings.Join(values, " | ") + " |\n" 78 | _, err = MDWriter.WriteString(tablerow) 79 | if err != nil { 80 | return fmt.Errorf("failed writing row to file: %w", err) 81 | } 82 | } 83 | 84 | MDWriter.Flush() 85 | MDFile.Close() 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /output_processor/neo4j.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "falconhound/internal" 7 | 8 | "github.com/neo4j/neo4j-go-driver/v4/neo4j" 9 | ) 10 | 11 | type Neo4jOutputConfig struct { 12 | Query string 13 | Parameters map[string]string 14 | } 15 | 16 | type Neo4jOutputProcessor struct { 17 | *OutputProcessor 18 | Config Neo4jOutputConfig 19 | } 20 | 21 | func (m *Neo4jOutputProcessor) BatchSize() int { 22 | return 1 23 | } 24 | 25 | func (m *Neo4jOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 26 | if len(QueryResults) == 0 { 27 | return nil 28 | } 29 | var queryResult internal.QueryResult = QueryResults[0] 30 | var params = make(map[string]interface{}) 31 | for key, value := range m.Config.Parameters { 32 | rowValue, ok := queryResult[value] 33 | if !ok { 34 | return fmt.Errorf("parameter %s not found in query results", value) 35 | } 36 | // Insert into map 37 | params[key] = rowValue 38 | } 39 | if m.Debug { 40 | fmt.Printf("Query: %#v, parameters: %#v\n", m.Config.Query, params) 41 | } 42 | 43 | return WriteNeo4j(m.Config.Query, params, m.Credentials) 44 | } 45 | 46 | // TODO also embed the driver and session in the struct 47 | var session neo4j.Session 48 | 49 | func WriteNeo4j(cypher string, params map[string]interface{}, creds internal.Credentials) error { 50 | // Create a new Neo4j driver 51 | driver, err := neo4j.NewDriver(creds.Neo4jUri, neo4j.BasicAuth(creds.Neo4jUsername, creds.Neo4jPassword, "")) 52 | if err != nil { 53 | return fmt.Errorf("error connecting to Neo4j: %w", err) 54 | } 55 | 56 | // Create a new Neo4j session if there is no current one 57 | if session == nil { 58 | session = driver.NewSession(neo4j.SessionConfig{}) 59 | } 60 | 61 | // Execute the Cypher query using the session 62 | _, err = session.Run(cypher, params) 63 | if err != nil { 64 | return fmt.Errorf("error executing cypher query: %w", err) 65 | } 66 | return nil 67 | } 68 | 69 | func Finalize() error { 70 | if session == nil { 71 | return nil 72 | } 73 | err := session.Close() 74 | session = nil 75 | return err 76 | } 77 | -------------------------------------------------------------------------------- /output_processor/sentinel.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "encoding/json" 9 | "falconhound/internal" 10 | "log" 11 | "net/http" 12 | "strconv" 13 | "strings" 14 | "time" 15 | "unicode/utf8" 16 | ) 17 | 18 | type SentinelOutputConfig struct { 19 | BHQuery string 20 | QueryName string 21 | QueryDescription string 22 | QueryEventID string 23 | } 24 | 25 | type SentinelOutputProcessor struct { 26 | *OutputProcessor 27 | Config SentinelOutputConfig 28 | } 29 | 30 | func (m *SentinelOutputProcessor) BatchSize() int { 31 | return 0 32 | } 33 | 34 | func (m *SentinelOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 35 | jsonData, err := json.Marshal(QueryResults) 36 | if err != nil { 37 | return err 38 | } 39 | sentinelData := map[string]interface{}{ 40 | "Name": m.Config.QueryName, 41 | "Description": m.Config.QueryDescription, 42 | "EventID": m.Config.QueryEventID, 43 | "BHQuery": m.Config.BHQuery, 44 | "EventData": string(jsonData), 45 | } 46 | 47 | customerId := m.Credentials.SentinelWorkspaceID 48 | sharedKey := m.Credentials.SentinelSharedKey 49 | logName := m.Credentials.SentinelTargetTable 50 | timeStampField := "DateValue" 51 | 52 | data, err := json.Marshal(sentinelData) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | Senddata := string(data) 58 | 59 | dateString := time.Now().UTC().Format(time.RFC1123) 60 | dateString = strings.Replace(dateString, "UTC", "GMT", -1) 61 | 62 | stringToHash := "POST\n" + strconv.Itoa(utf8.RuneCountInString(Senddata)) + "\napplication/json\n" + "x-ms-date:" + dateString + "\n/api/logs" 63 | hashedString, err := SentinelBuildSignature(stringToHash, sharedKey) 64 | if err != nil { 65 | log.Println(err.Error()) 66 | return err 67 | } 68 | 69 | signature := "SharedKey " + customerId + ":" + hashedString 70 | url := "https://" + customerId + ".ods.opinsights.azure.com/api/logs?api-version=2016-04-01" 71 | 72 | client := &http.Client{} 73 | req, err := http.NewRequest("POST", url, bytes.NewReader([]byte(data))) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | req.Header.Add("Log-Type", logName) 79 | req.Header.Add("Authorization", signature) 80 | req.Header.Add("Content-Type", "application/json") 81 | req.Header.Add("x-ms-date", dateString) 82 | req.Header.Add("time-generated-field", timeStampField) 83 | 84 | resp, err := client.Do(req) 85 | if err != nil { 86 | log.Println("Error sending data to Sentinel: ", err.Error()) 87 | return err 88 | } 89 | return resp.Body.Close() 90 | } 91 | 92 | func SentinelBuildSignature(message, secret string) (string, error) { 93 | 94 | keyBytes, err := base64.StdEncoding.DecodeString(secret) 95 | if err != nil { 96 | return "", err 97 | } 98 | 99 | mac := hmac.New(sha256.New, keyBytes) 100 | mac.Write([]byte(message)) 101 | return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil 102 | } 103 | -------------------------------------------------------------------------------- /output_processor/splunk.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "falconhound/internal" 8 | "fmt" 9 | "net/http" 10 | ) 11 | 12 | type SplunkOutputConfig struct { 13 | } 14 | 15 | type SplunkOutputProcessor struct { 16 | *OutputProcessor 17 | Config SplunkOutputConfig 18 | } 19 | 20 | func (m *OutputProcessor) BatchSize() int { 21 | return 0 22 | } 23 | 24 | func (m *SplunkOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 25 | return PostToSplunk(QueryResults, m.Credentials) 26 | } 27 | 28 | func PostToSplunk(queryResults internal.QueryResults, creds internal.Credentials) error { 29 | // Wrap data in JSON 30 | payload := map[string]internal.QueryResults{ 31 | "event": queryResults, 32 | } 33 | jsonData, err := json.Marshal(payload) 34 | if err != nil { 35 | return err 36 | } 37 | url := fmt.Sprintf("%s:%s/services/collector/event", creds.SplunkUrl, creds.SplunkHecPort) 38 | // Create HTTP request 39 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | // Set headers 45 | req.Header.Set("Authorization", fmt.Sprintf("Splunk %s", creds.SplunkHecToken)) 46 | req.Header.Set("Content-Type", "application/json") 47 | 48 | // Create HTTP client with custom transport to disable SSL verification 49 | tr := &http.Transport{ 50 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 51 | } 52 | client := &http.Client{Transport: tr} 53 | 54 | // Send HTTP request 55 | resp, err := client.Do(req) 56 | if err != nil { 57 | return err 58 | } 59 | defer resp.Body.Close() 60 | 61 | // Check response status code 62 | if resp.StatusCode != http.StatusOK { 63 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /output_processor/watchlist.go: -------------------------------------------------------------------------------- 1 | package output_processor 2 | 3 | import ( 4 | "context" 5 | "falconhound/internal" 6 | "fmt" 7 | "log" 8 | "strings" 9 | 10 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 11 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 12 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/securityinsights/armsecurityinsights" 13 | "github.com/iancoleman/orderedmap" 14 | ) 15 | 16 | type WatchlistOutputConfig struct { 17 | WatchlistName string 18 | DisplayName string 19 | SearchKey string 20 | Overwrite bool 21 | } 22 | 23 | type WatchlistOutputProcessor struct { 24 | *OutputProcessor 25 | Config WatchlistOutputConfig 26 | } 27 | 28 | func (m *WatchlistOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { 29 | // TODO check the CreateUpdateWatchlist function and properly return errors from there 30 | CreateUpdateWatchlist(QueryResults, m.Config.WatchlistName, m.Config.DisplayName, m.Config.SearchKey, m.Config.Overwrite, m.Credentials) 31 | return nil 32 | } 33 | 34 | // Watchlist does not require batching, will write all output in one go 35 | func (m *WatchlistOutputProcessor) BatchSize() int { 36 | return 0 37 | } 38 | 39 | func CreateUpdateWatchlist(results internal.QueryResults, WatchlistName string, DisplayName string, SearchKey string, Overwrite bool, creds internal.Credentials) { 40 | cred, err := azidentity.NewClientSecretCredential(creds.SentinelTenantID, creds.SentinelAppID, creds.SentinelAppSecret, nil) 41 | if err != nil { 42 | log.Fatalf("failed to obtain a credential: %v", err) 43 | } 44 | ctx := context.Background() 45 | clientFactory, err := armsecurityinsights.NewClientFactory(creds.SentinelSubscriptionID, cred, nil) 46 | if err != nil { 47 | log.Fatalf("failed to create client: %v", err) 48 | } 49 | 50 | if len(results) == 0 { 51 | return 52 | } 53 | 54 | log.Println("[>] Creating watchlist", WatchlistName, "with", len(results), "items") 55 | 56 | var keys []string 57 | if len(results) > 0 { 58 | for k := range results[0] { 59 | keys = append(keys, k) 60 | } 61 | } 62 | // Write the header 63 | var rows [][]string 64 | rows = append(rows, keys) 65 | for _, record := range results { 66 | // Convert the record to a map using the ordered map 67 | m := make(map[string]interface{}) 68 | for _, k := range keys { 69 | v, ok := record[k] 70 | if !ok { 71 | v = nil 72 | } 73 | m[k] = v 74 | } 75 | // Convert the map to an ordered map 76 | orderedMap := orderedmap.New() 77 | for _, k := range keys { 78 | v, ok := m[k] 79 | if !ok { 80 | v = nil 81 | } 82 | orderedMap.Set(k, v) 83 | } 84 | // Convert the ordered map to a slice of strings 85 | var row []string 86 | for _, k := range keys { 87 | v, _ := orderedMap.Get(k) 88 | if k == "Resources" { 89 | v = fmt.Sprintf("%v", v) 90 | } 91 | // Replace commas with semicolons 92 | vStr := fmt.Sprintf("%v", v) 93 | vStr = strings.ReplaceAll(vStr, ",", ";") 94 | row = append(row, vStr) 95 | } 96 | rows = append(rows, row) 97 | } 98 | 99 | var rowsStr string 100 | for _, row := range rows { 101 | rowsStr += strings.Join(row, ",") + "\n" 102 | } 103 | rowsStr = strings.ReplaceAll(rowsStr, "\n", "\n") 104 | rowsStr = strings.ReplaceAll(rowsStr, "\"", "'") 105 | 106 | // Check if the watchlist already exists 107 | skipDelete := false 108 | listRes, err := clientFactory.NewWatchlistsClient().Get(ctx, creds.SentinelResourceGroup, creds.SentinelWorkspaceName, WatchlistName, nil) 109 | 110 | if err != nil { 111 | if strings.Contains(err.Error(), "404") { 112 | log.Println("[*] The watchlist", WatchlistName, "does not exist. Creating it now.") 113 | skipDelete = true 114 | } else { 115 | log.Printf("failed to finish the request: %v", err) 116 | } 117 | } 118 | _ = listRes 119 | 120 | // Delete the watchlist if overwrite is true 121 | if Overwrite && !skipDelete { 122 | _, err = clientFactory.NewWatchlistsClient().Delete(ctx, creds.SentinelResourceGroup, creds.SentinelWorkspaceName, WatchlistName, nil) 123 | if err != nil { 124 | log.Printf("failed to finish the request: %v", err) 125 | } 126 | } 127 | res, err := clientFactory.NewWatchlistsClient().CreateOrUpdate(ctx, creds.SentinelResourceGroup, creds.SentinelWorkspaceName, WatchlistName, armsecurityinsights.Watchlist{ 128 | Etag: to.Ptr("\"0300bf09-0000-0000-0000-5c37296e0000\""), 129 | Properties: &armsecurityinsights.WatchlistProperties{ 130 | Description: to.Ptr("Watchlist from FalconHound"), 131 | ContentType: to.Ptr("text/csv"), 132 | DisplayName: to.Ptr(DisplayName), 133 | ItemsSearchKey: to.Ptr(SearchKey), 134 | NumberOfLinesToSkip: to.Ptr[int32](0), 135 | Provider: to.Ptr("FalconForce"), 136 | RawContent: to.Ptr(rowsStr), 137 | Source: to.Ptr(armsecurityinsights.SourceLocalFile), 138 | }, 139 | }, nil) 140 | if err != nil { 141 | log.Printf("failed to finish the request: %v", err) 142 | } 143 | _ = res 144 | } 145 | --------------------------------------------------------------------------------