From ba44895f2d85a6c77a95a4890cfa0e7021f4c91f Mon Sep 17 00:00:00 2001 From: Olaf Hartong <8149899+olafhartong@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:54:22 +0900 Subject: [PATCH] BloodHound API support (#11) * adding limacharlie output * refinement * Added clean skip if secret or password is missing for the input processor * Added ADX batchsize configurability and ElasticCloud querying * updated template to demonstrate ADX batching * typo fix to ADX schema * added path creation and date-variable to csv * added options for 2x date-variable to csv * ignore report folder in git * added markdown table output per action * updated gitignore * updated README to add supported files * update to Markdown output * module updates * updates to docs * huge speed boost by altering write process * year update * sample report update * version bump * added msgraphapi SDK support and initial actions * improvement to array output in fields * adding dynamic groups * refined error reporting * additional stats event * disable by default due to req and speed * change to array to accommodate maintenance * adding auth device id * add log based MFA updates and new edges * default off * version bumps etc * cypher refinement to win performance * improvement to path parsing for config * more MFA based rules * update to gitignore * update to gitignore * added mfa reports * added mfa queries and add them to paths * app consent and roles * refined queries to make BHCE compatible * new actions * added max path lenght to improve performance * added support for user and azuser * added multi tenant support * removed experimental reference * small kql optimization * small query fix * many improvements and new features * many improvements and new features * clean up code * address dependency vulnerability * renamed title * add user removed from group action * added 2 actions * added 3 actions, updated one --- .../sen_get_aad_device_registration.yml | 35 +++++ .../01-Sentinel/sen_get_aad_group_create.yml | 31 ++++ .../sen_get_aad_user_addedtogroup.yml | 33 +++++ .../01-Sentinel/sen_get_aad_user_delete.yml | 28 ++++ .../sen_get_aad_user_removedfromgroup.yml | 31 ++++ .../01-Sentinel/sen_get_ad_group_removal.yml | 31 ++++ actions/01-Sentinel/sen_get_host_alerts.yml | 8 +- actions/01-Sentinel/sen_new_sessions.yml | 8 ++ actions/04-MSgraph/msgraph_dynamicgroups.yml | 4 +- actions/08-HTTP/http_entra_roles.yml | 24 ++++ actions/10-Neo4j/n4j_ad_chokepoints.yml | 36 +++++ ...4j_azure_tier0_or_tier1_assigned_roles.yml | 32 +++++ .../n4j_exploitable_device_to_high_value.yml | 5 +- ...4j_external_serviceprincipal_high_priv.yml | 10 +- ...rt-AAD_Access_to_highvalue_percentages.yml | 28 ++++ ...ort-AD_Access_to_highvalue_percentages.yml | 30 ++++ actions/action_schema.json | 6 +- go.mod | 10 +- go.sum | 21 +-- input_processor/bloodhound.go | 2 + input_processor/http.go | 135 ++++++++++++++++++ internal/version.go | 2 +- main.go | 20 +++ output_processor/bloodhound.go | 128 +++++++++++++++++ output_processor/json.go | 56 ++++++++ 25 files changed, 728 insertions(+), 26 deletions(-) create mode 100644 actions/01-Sentinel/sen_get_aad_device_registration.yml create mode 100644 actions/01-Sentinel/sen_get_aad_group_create.yml create mode 100644 actions/01-Sentinel/sen_get_aad_user_addedtogroup.yml create mode 100644 actions/01-Sentinel/sen_get_aad_user_delete.yml create mode 100644 actions/01-Sentinel/sen_get_aad_user_removedfromgroup.yml create mode 100644 actions/01-Sentinel/sen_get_ad_group_removal.yml create mode 100644 actions/08-HTTP/http_entra_roles.yml create mode 100644 actions/10-Neo4j/n4j_ad_chokepoints.yml create mode 100644 actions/10-Neo4j/n4j_azure_tier0_or_tier1_assigned_roles.yml create mode 100644 actions/11-N4J-Reporting/n4j-report-AAD_Access_to_highvalue_percentages.yml create mode 100644 actions/11-N4J-Reporting/n4j-report-AD_Access_to_highvalue_percentages.yml create mode 100644 input_processor/http.go create mode 100644 output_processor/bloodhound.go create mode 100644 output_processor/json.go diff --git a/actions/01-Sentinel/sen_get_aad_device_registration.yml b/actions/01-Sentinel/sen_get_aad_device_registration.yml new file mode 100644 index 0000000..18acfa7 --- /dev/null +++ b/actions/01-Sentinel/sen_get_aad_device_registration.yml @@ -0,0 +1,35 @@ +Name: New device registered in EntraID +ID: SEN_AAD_Device_Registration +Description: Gets all device registrations and their owners +Author: FalconForce +Version: '1.0' +Info: |- +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk +Query: | + let timeframe = 15m; + AuditLogs + | where ingestion_time() >= ago(timeframe) + | where OperationName contains "Register device" + | mv-expand TargetResources + | extend AdditionalDetails=parse_json(AdditionalDetails), InitiatedBy=parse_json(InitiatedBy) + | mv-expand AdditionalDetails + | where AdditionalDetails contains "Device Id" + | extend deviceId=AdditionalDetails.value, ownerId=InitiatedBy.user.id + | project TimeGenerated,deviceId, ownerId, TenantId +Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) + - Name: Neo4j + Enabled: true + Query: | + WITH toUpper($ownerId) AS OwnerID, toUpper($deviceId) AS DeviceID, toUpper($TenantId) AS TenantID, $TimeGenerated AS TimeGenerated + MERGE (d:AZDevice {objectid: DeviceID}) + ON CREATE SET d.tenantid = TenantID, d.lastseen = TimeGenerated, d.label = "AZBase" + MERGE (u:AZUser {objectid: OwnerID}) + MERGE (u)-[r:AZOwns]->(d) + ON CREATE SET r.added = TimeGenerated, r.source = 'falconhound' + Parameters: + ownerId: ownerId + deviceId: deviceId + TimeGenerated: TimeGenerated + TenantId: TenantId \ No newline at end of file diff --git a/actions/01-Sentinel/sen_get_aad_group_create.yml b/actions/01-Sentinel/sen_get_aad_group_create.yml new file mode 100644 index 0000000..e507760 --- /dev/null +++ b/actions/01-Sentinel/sen_get_aad_group_create.yml @@ -0,0 +1,31 @@ +Name: New groups added to EntraID +ID: SEN_AAD_Group_Creations +Description: Gets all group creation events +Author: FalconForce +Version: '1.0' +Info: |- +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk +Query: | + let timeframe = 15m; + AuditLogs + | where ingestion_time() >= ago(timeframe) + | where OperationName contains "Add group" + | mv-expand TargetResources + | extend TargetResources=parse_json(TargetResources), InitiatedBy=parse_json(InitiatedBy) + | extend ObjectId = TargetResources.id, displayName=TargetResources.displayName, createdBy=InitiatedBy.user.id,creatorUserPrincipalName=InitiatedBy.user.userPrincipalName + | project TimeGenerated,ObjectId,displayName,createdBy, creatorUserPrincipalName +Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) + - Name: Neo4j + Enabled: true + Query: | + WITH toUpper($ObjectId) AS ObjectId, toUpper($displayName) AS DisplayName, toUpper($createdBy) AS CreatedBy, toUpper($creatorUserPrincipalName) AS CreatorUserPrincipalName, $TimeGenerated AS TimeGenerated + MERGE (g:AZGroup {objectid: ObjectId}) + ON CREATE SET g.displayname = DisplayName, g.createdby = CreatedBy, g.creatoruserprincipalname = CreatorUserPrincipalName, g.source = 'falconhound', g.whencreated = TimeGenerated, g.label = 'AZBase' + Parameters: + displayName: displayName + ObjectId: ObjectId + TimeGenerated: TimeGenerated + createdBy: createdBy + creatorUserPrincipalName: creatorUserPrincipalName \ No newline at end of file diff --git a/actions/01-Sentinel/sen_get_aad_user_addedtogroup.yml b/actions/01-Sentinel/sen_get_aad_user_addedtogroup.yml new file mode 100644 index 0000000..adae147 --- /dev/null +++ b/actions/01-Sentinel/sen_get_aad_user_addedtogroup.yml @@ -0,0 +1,33 @@ +Name: AAD user added to group +ID: SEN_AAD_User_Added_To_Group +Description: Collects AAD / EntraId user accounts added to a group. +Author: FalconForce +Version: '1.0' +Info: |- +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk +Query: | + let timeframe = 15m; + AuditLogs + | where ingestion_time() >= ago(timeframe) + | where OperationName =~ "Add member to group" + | mv-expand TargetResources + | extend TargetResources=parse_json(TargetResources) + | extend ObjectId = TargetResources.id, userPrincipalName=TargetResources.userPrincipalName + | where TargetResources.modifiedProperties contains "Group.ObjectId" + | extend groupObjectId=trim('\"',tostring(TargetResources.modifiedProperties[0].newValue)) + | project TimeGenerated,ObjectId,userPrincipalName,groupObjectId +Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) + - Name: Neo4j + Enabled: true + Query: | + WITH toUpper($ObjectId) AS ObjectId, toUpper($userPrincipalName) AS UserPrincipalName, toUpper($groupObjectId) AS GroupObjectId, $TimeGenerated AS TimeGenerated + MERGE (u:AZUser {objectid: ObjectId}) ON CREATE SET u.userPrincipalName = UserPrincipalName, u.displayName = UserPrincipalName + MERGE (g:AZGroup {objectid: GroupObjectId}) ON CREATE SET g.displayName = GroupObjectId + MERGE (u)-[r:AZMemberOf]->(g) SET r.added = TimeGenerated, r.source = 'falconhound' + Parameters: + ObjectId: ObjectId + userPrincipalName: userPrincipalName + GroupObjectId: groupObjectId + TimeGenerated: TimeGenerated diff --git a/actions/01-Sentinel/sen_get_aad_user_delete.yml b/actions/01-Sentinel/sen_get_aad_user_delete.yml new file mode 100644 index 0000000..768bda4 --- /dev/null +++ b/actions/01-Sentinel/sen_get_aad_user_delete.yml @@ -0,0 +1,28 @@ +Name: New AAD user deletions +ID: SEN_AAD_New_User_Deletions +Description: Collects deleted AAD / EntraId user accounts created in the last 15 minutes and removes them from the graph. +Author: FalconForce +Version: '1.0' +Info: |- +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk +Query: | + let timeframe = 15m; + AuditLogs + | where ingestion_time() >= ago(timeframe) + | where OperationName =~ "Delete user" + | extend TargetResources=parse_json(TargetResources) + | extend + ObjectId = TargetResources.[0].id, + userPrincipalName=TargetResources.[0].userPrincipalName + | project ObjectId, userPrincipalName +Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) + - Name: Neo4j + Enabled: true + Query: | + WITH toUpper($ObjectId) AS ObjectId + MATCH (x:AZUser {objectid:ObjectId}) + DELETE x + Parameters: + ObjectId: ObjectId diff --git a/actions/01-Sentinel/sen_get_aad_user_removedfromgroup.yml b/actions/01-Sentinel/sen_get_aad_user_removedfromgroup.yml new file mode 100644 index 0000000..3459939 --- /dev/null +++ b/actions/01-Sentinel/sen_get_aad_user_removedfromgroup.yml @@ -0,0 +1,31 @@ +Name: AAD user removed from group +ID: SEN_AAD_User_Removed_from_Group +Description: Collects AAD / EntraId user accounts removed from a group. +Author: FalconForce +Version: '1.0' +Info: |- +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk +Query: | + let timeframe = 15m; + AuditLogs + | where ingestion_time() >= ago(timeframe) + | where OperationName contains "Remove member from group" + | mv-expand TargetResources + | extend TargetResources=parse_json(TargetResources) + | extend ObjectId = TargetResources.id, userPrincipalName=TargetResources.userPrincipalName + | where TargetResources.modifiedProperties contains "Group.ObjectID" + | extend groupObjectId=trim('\"',tostring(TargetResources.modifiedProperties[0].oldValue)) + | project TimeGenerated,ObjectId,userPrincipalName,groupObjectId +Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) + - Name: Neo4j + Enabled: true + Query: | + WITH toUpper($ObjectId) AS ObjectId, toUpper($userPrincipalName) AS UserPrincipalName, toUpper($groupObjectId) AS GroupObjectId + MATCH (u:AZUser {objectid: ObjectId})-[r:AZMemberOf]->(g:AZGroup {objectid: GroupObjectId}) + DELETE r + Parameters: + ObjectId: ObjectId + userPrincipalName: userPrincipalName + GroupObjectId: groupObjectId \ No newline at end of file diff --git a/actions/01-Sentinel/sen_get_ad_group_removal.yml b/actions/01-Sentinel/sen_get_ad_group_removal.yml new file mode 100644 index 0000000..12c2774 --- /dev/null +++ b/actions/01-Sentinel/sen_get_ad_group_removal.yml @@ -0,0 +1,31 @@ +Name: All remove events from AD groups +ID: SEN_AD_Group_Removal +Description: Gets all group removals from the Security logs, including local groups +Author: FalconForce +Version: '1.0' +Info: |- +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Sentinel # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk +Query: | + let timeframe = 15m; + let targetEvent = dynamic([4729,4733,4757]); // 4733 Domain Local, 4729 >> Global, 4757 >> Universal + SecurityEvent + | where ingestion_time() >= ago(timeframe) + | where EventID in (targetEvent) + | where MemberName != '-' + | project TargetAccount, TargetDomainName, GroupSid=TargetSid, MemberSid, Actor=SubjectUserName, TimeGenerated +Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) + - Name: Neo4j + Enabled: true + Query: | + WITH $MemberSid AS MemberSid, $GroupSid AS GroupSid, $TimeGenerated AS Timestamp + MATCH (u:User) WHERE u.objectid = MemberSid + WITH u, GroupSid + MATCH (g:Group) WHERE g.objectid = GroupSid + MATCH (u)-[r:MemberOf]->(g) + DELETE r + Parameters: + MemberSid: MemberSid + GroupSid: GroupSid + TimeGenerated: TimeGenerated \ No newline at end of file diff --git a/actions/01-Sentinel/sen_get_host_alerts.yml b/actions/01-Sentinel/sen_get_host_alerts.yml index bdbe31d..b5203d7 100644 --- a/actions/01-Sentinel/sen_get_host_alerts.yml +++ b/actions/01-Sentinel/sen_get_host_alerts.yml @@ -18,9 +18,9 @@ Query: | | extend FQDN2=strcat(HostName,".",DnsDomain) // Sometimes the FQDN is not populated for some reason, so we can fix most this way. | extend EntityName=iff(isnotempty(FQDN),FQDN,FQDN2) | project EntityName,AlertId=VendorOriginalId,Entitytype,ProviderName, Status - | summarize make_set(AlertId) by Entitytype,EntityName,Status, ProviderName - // | where not((Entitytype == 'host' and EntityName !contains '.') or EntityName endswith ".") // Optional. Filters non-domain joined hosts and incomplete hostnames. - | mv-expand set_AlertId + | summarize make_set(AlertId) by Entitytype,DeviceName=EntityName,Status, ProviderName + //| where not((Entitytype == 'host' and EntityName !contains '.') or EntityName endswith ".") // Optional. Filters non-domain joined hosts and incomplete hostnames. + | mv-expand set_AlertId Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) - Name: Neo4j Enabled: true @@ -32,5 +32,5 @@ Targets: # Targets are the pla c.owned = True Parameters: set_AlertId: set_AlertId - EntityName: EntityName + EntityName: DeviceName Status: Status \ No newline at end of file diff --git a/actions/01-Sentinel/sen_new_sessions.yml b/actions/01-Sentinel/sen_new_sessions.yml index bdb9f47..2aae19a 100644 --- a/actions/01-Sentinel/sen_new_sessions.yml +++ b/actions/01-Sentinel/sen_new_sessions.yml @@ -27,6 +27,14 @@ Targets: # Targets are the pla Query: | WITH toUpper($Computer) as Computer, toUpper($TargetUserSid) as TargetUserSid, $Timestamp as Timestamp MATCH (x:Computer {name:Computer}) MATCH (y:User {objectid:TargetUserSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=Timestamp SET r.source='falconhound' + Parameters: + Computer: Computer + TargetUserSid: TargetUserSid + Timestamp: Timestamp + - Name: BloodHound + Enabled: false + Query: | + MATCH (x:Computer {name:$Computer}) MATCH (y:User {objectid:$TargetUserSid}) MERGE (x)-[r:HasSession]->(y) SET r.since=$Timestamp SET r.source='falconhound' Parameters: Computer: Computer TargetUserSid: TargetUserSid diff --git a/actions/04-MSgraph/msgraph_dynamicgroups.yml b/actions/04-MSgraph/msgraph_dynamicgroups.yml index b57fda5..2d11b8a 100644 --- a/actions/04-MSgraph/msgraph_dynamicgroups.yml +++ b/actions/04-MSgraph/msgraph_dynamicgroups.yml @@ -5,7 +5,7 @@ Author: FalconForce Version: '1.0' Info: |- Active: true # Enable to run this action -Debug: true # Enable to see query results in the console +Debug: false # Enable to see query results in the console SourcePlatform: MSGraphApi # Sentinel, Watchlist, Neo4j, MDE, Graph, Splunk Query: | GetDynamicGroups @@ -21,7 +21,7 @@ Targets: # Targets are the platforms that this action will push to (CSV, Neo4j objectid: objectid, displayname: displayname, falconhound:True, - memebershiprule: membershiprule, + membershiprule: membershiprule, membershiprulestate: membershiprulestate, grouptype: grouptype } diff --git a/actions/08-HTTP/http_entra_roles.yml b/actions/08-HTTP/http_entra_roles.yml new file mode 100644 index 0000000..f0d32ae --- /dev/null +++ b/actions/08-HTTP/http_entra_roles.yml @@ -0,0 +1,24 @@ +Name: Get Entra admin tier roles +ID: HTTP_Entra_Roles +Description: Get all roles from Entra and create a relationship between the role and the user in Neo4j. +Author: FalconForce +Version: '1.0' +Info: | + Based on a tweet by martinsohndk https://twitter.com/martinsohndk/status/1768065960148136277 + and references the project by Thomas Naunheim https://github.com/Cloud-Architekt/AzurePrivilegedIAM +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: HTTP # Sentinel, Watchlist, Neo4j, CSV, MDE, Graph, Splunk +Query: | # Splunk index can be hardcoded or a variable set in the config.yml file + EntraRoles +Targets: # Targets are the platforms that this action will push to (CSV, Neo4j, Sentinel, Wachlist, Slack, Teams, Splunk, Markdown) + - Name: Neo4j + Enabled: true + Query: | + WITH $AdminTierLevel AS AdminTierLevel, toUpper($RoleObjectId + '@' + $TenantId) AS TargetObjectId + MATCH (x:AZRole {objectid: TargetObjectId}) + SET x.system_tags = AdminTierLevel + Parameters: + AdminTierLevel: AdminTierLevel + RoleObjectId: RoleId + TenantId: TenantId \ No newline at end of file diff --git a/actions/10-Neo4j/n4j_ad_chokepoints.yml b/actions/10-Neo4j/n4j_ad_chokepoints.yml new file mode 100644 index 0000000..db2bf95 --- /dev/null +++ b/actions/10-Neo4j/n4j_ad_chokepoints.yml @@ -0,0 +1,36 @@ +Name: N4J AD Chokepoints +ID: N4J_AD_Chokepoints +Description: This action collects all paths to groups that can control many resources +Author: FalconForce +Version: '1.0' +Info: | + Based on a blog by sadprocess0r (https://falconforce.nl/bloodhound-calculating-ad-metrics-0x02/) +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Neo4j +Query: | + CALL {MATCH (allU:User) RETURN COUNT(allU) AS TotalU} + CALL {MATCH (allC:Computer) RETURN COUNT(allC) AS TotalC} + MATCH (y:Group) + CALL {WITH y + OPTIONAL MATCH pIN=shortestPath((x:User)-[*1..]->(y)) RETURN x + } + CALL {WITH y + OPTIONAL MATCH pOUT=shortestPath((y)-[*1..]->(z:Computer)) RETURN z + } + WITH DISTINCT y.name AS Target, TotalU, TotalC, + COUNT(DISTINCT(x)) AS CountIN, + COUNT(DISTINCT(z)) AS CountOUT + WHERE CountOUT > 0 AND CountIN > 0 + RETURN {Target:Target, + CountIn:CountIN, TotalUsers:TotalU, PercentIn:round(CountIN/toFloat(TotalU)*100,1), + CountOut:CountOUT,TotalCount:TotalC, PercentOut:round(CountOUT/toFloat(TotalC)*100,1)} as info +Targets: + - Name: Sentinel + Enabled: true + - Name: Watchlist + Enabled: true + WatchlistName: FH_AD_Chokeopoints + DisplayName: AD Chokepoints + SearchKey: Target + Overwrite: true \ No newline at end of file diff --git a/actions/10-Neo4j/n4j_azure_tier0_or_tier1_assigned_roles.yml b/actions/10-Neo4j/n4j_azure_tier0_or_tier1_assigned_roles.yml new file mode 100644 index 0000000..f24f43d --- /dev/null +++ b/actions/10-Neo4j/n4j_azure_tier0_or_tier1_assigned_roles.yml @@ -0,0 +1,32 @@ +Name: N4J Azure Tier0 or Tier1 Assigned Roles +ID: N4j_Azure_Tier0_or_Tier1_Assigned_Roles +Description: Collects all Azure roles assigned to Tier0 or Tier1 groups and creates a relationship between the role and the entity in Neo4j. +Author: FalconForce +Version: '1.0' +Info: |- +Active: true # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Neo4j +Query: | + MATCH (a:AZRole)-[r:HasConsent|AZRunsAs|AZHasRole|AZGlobalAdmin|AZPrivilegedRoleAdmin|AZOwns]-(b) + WHERE (a.system_tags CONTAINS "admin_tier_0" or a.system_tags CONTAINS "admin_tier_1") + RETURN {Entity: b.name, Role: a.name, Relation: type(r)} as info +Targets: + - Name: CSV + Enabled: false + Path: output/azure_tier0_and_tier1_assigned.csv + - Name: Sentinel + BHQuery: | + MATCH b=(a:AZRole)-[r:HasConsent|AZRunsAs|AZHasRole|AZGlobalAdmin|AZPrivilegedRoleAdmin|AZOwns]-() + WHERE (a.system_tags CONTAINS "admin_tier_0" or a.system_tags CONTAINS "admin_tier_1") + RETURN b + Enabled: true + - Name: Watchlist + Enabled: true + WatchlistName: FH_Azure_Tier0_or_Tier1_Assigned_Roles + DisplayName: Azure Tier0 or Tier1 Assigned Roles + SearchKey: Entity + Overwrite: true + - Name: Markdown + Enabled: true + Path: report/{{date}}/Azure_Tier0_or_Tier1_Assigned_Roles.md \ No newline at end of file diff --git a/actions/10-Neo4j/n4j_exploitable_device_to_high_value.yml b/actions/10-Neo4j/n4j_exploitable_device_to_high_value.yml index 9c7da22..3355692 100644 --- a/actions/10-Neo4j/n4j_exploitable_device_to_high_value.yml +++ b/actions/10-Neo4j/n4j_exploitable_device_to_high_value.yml @@ -37,4 +37,7 @@ Targets: Overwrite: true - Name: ADX Enabled: false - Table: FalconHound \ No newline at end of file + Table: FalconHound + - Name: Markdown + Enabled: true + Path: report/{{date}}/exploitable_to_highvaluecount.md \ No newline at end of file diff --git a/actions/10-Neo4j/n4j_external_serviceprincipal_high_priv.yml b/actions/10-Neo4j/n4j_external_serviceprincipal_high_priv.yml index c75ad86..0d0ed88 100644 --- a/actions/10-Neo4j/n4j_external_serviceprincipal_high_priv.yml +++ b/actions/10-Neo4j/n4j_external_serviceprincipal_high_priv.yml @@ -4,7 +4,7 @@ Description: This action lists all externally owned Service Principals with high Author: FalconForce Version: '1.0' Info: More information about the potential impact here > https://posts.specterops.io/microsoft-breach-how-can-i-see-this-in-bloodhound-33c92dca4c65 -Active: false # Enable to run this action +Active: true # Enable to run this action Debug: false # Enable to see query results in the console SourcePlatform: Neo4j Query: | @@ -25,4 +25,10 @@ Targets: WHERE (coalesce(s.system_tags,"") CONTAINS "admin_tier_0" or s.highvalue=true) AND NOT toUpper(s.appownerorganizationid) = TENANTID AND s.appownerorganizationid CONTAINS "-" - RETURN * \ No newline at end of file + RETURN p + - Name: Watchlist + Enabled: true + WatchlistName: FH_Azure_EXT_SP_HIGH_PRIV + DisplayName: Azure External SP High Privileges + SearchKey: SPName + Overwrite: true diff --git a/actions/11-N4J-Reporting/n4j-report-AAD_Access_to_highvalue_percentages.yml b/actions/11-N4J-Reporting/n4j-report-AAD_Access_to_highvalue_percentages.yml new file mode 100644 index 0000000..04cd7aa --- /dev/null +++ b/actions/11-N4J-Reporting/n4j-report-AAD_Access_to_highvalue_percentages.yml @@ -0,0 +1,28 @@ +Name: Get the amount of users with a path to Tier 0 and High Value Roles +ID: N4J_REPORT_AAD_Access_to_highvalue_percentages +Description: Gets the amount of users with a path to Tier 0 and High Value Roles +Author: FalconForce +Version: '1.0' +Info: |- + Based on a blog by @sadprocess0r. (https://falconforce.nl/bloodhound-calculating-ad-metrics-0x02/) +Active: false # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Neo4j +Query: | + CALL {MATCH (all:AZUser) RETURN COUNT(all) AS Total} + MATCH (x:AZUser) + MATCH (y:AZRole) + WHERE (coalesce(y.system_tags,"") CONTAINS "admin_tier_0" or y.highvalue=true) + MATCH p=shortestPath((x)-[*1..]->(y)) + WITH y.name AS Target, COUNT(p) AS Count, Total, + COLLECT(length(p)) AS lengthList + RETURN {Target:Target, Count:Count, Total:Total, + Percentage:round(Count/toFloat(Total)*100,2), + avgHops:round(reduce(s=0,l in lengthList|s+l)/toFloat(SIZE(lengthList)),2)} as info +Targets: + - Name: CSV + Enabled: true + Path: report/{{date}}/BH-AccessToAADHighValue_{{date}}.csv + - Name: Markdown + Enabled: true + Path: report/{{date}}/BH-AccessToAADHighValue_{{date}}.md \ No newline at end of file diff --git a/actions/11-N4J-Reporting/n4j-report-AD_Access_to_highvalue_percentages.yml b/actions/11-N4J-Reporting/n4j-report-AD_Access_to_highvalue_percentages.yml new file mode 100644 index 0000000..0e4e3bf --- /dev/null +++ b/actions/11-N4J-Reporting/n4j-report-AD_Access_to_highvalue_percentages.yml @@ -0,0 +1,30 @@ +Name: Get the amount of users with a path to Tier 0 and High Value Groups +ID: N4J_REPORT_AD_Access_to_highvalue_percentages +Description: Gets the amount of users with a path to Tier 0 and High Value Groups +Author: FalconForce +Version: '1.0' +Info: |- + 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. + The action also calculates the average distance of the paths to the Domain Admins. + Based on a blog by @sadprocess0r. (https://falconforce.nl/bloodhound-calculating-ad-metrics-0x02/) +Active: false # Enable to run this action +Debug: false # Enable to see query results in the console +SourcePlatform: Neo4j +Query: | + CALL {MATCH (all:User) RETURN COUNT(all) AS Total} + MATCH (x:User) + MATCH (y:Group) + WHERE (coalesce(y.system_tags,"") CONTAINS "admin_tier_0" or y.highvalue=true) + MATCH p=shortestPath((x)-[*1..]->(y)) + WITH y.name AS Target, COUNT(p) AS Count, Total, + COLLECT(length(p)) AS lengthList + RETURN {Target:Target, Count:Count, Total:Total, + Percentage:round(Count/toFloat(Total)*100,2), + avgHops:round(reduce(s=0,l in lengthList|s+l)/toFloat(SIZE(lengthList)),2)} as info +Targets: + - Name: CSV + Enabled: true + Path: report/{{date}}/BH-AccessToADHighValue_{{date}}.csv + - Name: Markdown + Enabled: true + Path: report/{{date}}/BH-AccessToADHighValue_{{date}}.md \ No newline at end of file diff --git a/actions/action_schema.json b/actions/action_schema.json index aaa0d3e..70dab54 100644 --- a/actions/action_schema.json +++ b/actions/action_schema.json @@ -36,7 +36,8 @@ "MSGraphApi", "Splunk", "LogScale", - "Elastic" + "Elastic", + "HTTP" ] }, "Version": { @@ -61,7 +62,8 @@ "LogScale", "ADX", "LimaCharlie", - "Markdown" + "Markdown", + "JSON" ] }, "Enabled": { diff --git a/go.mod b/go.mod index f8981d4..cba3233 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( require ( github.com/Azure/azure-pipeline-go v0.2.3 // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.6.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.0 // indirect github.com/Azure/azure-storage-queue-go v0.0.0-20230927153703-648530c9aaf2 // indirect @@ -68,16 +68,16 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/std-uritemplate/std-uritemplate/go v0.0.50 // indirect - github.com/stretchr/testify v1.8.4 // indirect + github.com/stretchr/testify v1.9.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.opentelemetry.io/otel v1.23.1 // indirect go.opentelemetry.io/otel/metric v1.23.1 // indirect go.opentelemetry.io/otel/trace v1.23.1 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.19.0 // indirect + golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/sys v0.17.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 9caaf19..86c6d04 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2 h1:c4k2FIYIh4xtwqrQwV0Ct1v5+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2/go.mod h1:5FDJtLEO/GxwNgUxbwrY3LP0pEoThTQJtk2oysdXHxM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.6.0 h1:sUFnFjzDUie80h24I7mrKtwCKgLY9L8h5Tp2x9+TWqk= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.6.0/go.mod h1:52JbnQTp15qg5mRkMBHwp0j0ZFwHJ42Sx3zVV5RE9p0= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 h1:xnO4sFyG8UH2fElBkcqLTOZsAajvKfnSlgBBW8dXYjw= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0/go.mod h1:XD3DIOOVgBCO03OleB1fHjgktVRFxlT++KwKgIOewdM= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= @@ -175,8 +175,9 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= @@ -197,8 +198,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -215,8 +216,10 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -240,8 +243,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/input_processor/bloodhound.go b/input_processor/bloodhound.go index 5dc66af..6741f08 100644 --- a/input_processor/bloodhound.go +++ b/input_processor/bloodhound.go @@ -8,6 +8,7 @@ import ( "falconhound/internal" "fmt" "io" + "log" "net/http" "strings" "time" @@ -40,6 +41,7 @@ func BHRequest(query string, creds internal.Credentials) (internal.QueryResults, method := "POST" uri := "/api/v2/graphs/cypher" queryBody := fmt.Sprintf("{\"query\":\"%s\"}", query) + log.Println("Query body:", queryBody) body := []byte(queryBody) // The first HMAC digest is the token key diff --git a/input_processor/http.go b/input_processor/http.go new file mode 100644 index 0000000..19278f7 --- /dev/null +++ b/input_processor/http.go @@ -0,0 +1,135 @@ +package input_processor + +import ( + "encoding/json" + "falconhound/internal" + "fmt" + "io/ioutil" + "net/http" +) + +type Role struct { + RoleId string `json:"RoleId"` + RoleName string `json:"RoleName"` + IsPrivilegedRole bool `json:"isPrivileged"` + Classification struct { + EAMTierLevelTagValue string `json:"EAMTierLevelTagValue,omitempty"` + EAMTierLevelName string `json:"EAMTierLevelName,omitempty"` + } `json:"Classification"` + RolePermissions []RolePermissions `json:"RolePermissions"` +} + +type RolePermissions struct { + AuthorizedResourceAction string `json:"AuthorizedResourceAction,omitempty"` + //Category []string `json:"Category,omitempty"` + EAMTierLevelTagValue string `json:"EAMTierLevelTagValue,omitempty"` + EAMTierLevelName string `json:"EAMTierLevelName,omitempty"` +} + +type RoleMap struct { + RoleId string `json:"RoleId"` + EAMTierLevelTagValue string `json:"EAMTierLevelTagValue"` + AdminTierLevel string `json:"AdminTierLevel"` + TenantId string `json:"TenantId"` +} + +type HTTPConfig struct { +} + +type HTTPProcessor struct { + *InputProcessor + Config HTTPConfig +} + +type HTTPResults struct { + Results internal.QueryResults `json:"Results"` +} + +func (m *HTTPProcessor) ExecuteQuery() (internal.QueryResults, error) { + // + //func GetRoleJson() { + resp, err := http.Get("https://github.com/Cloud-Architekt/AzurePrivilegedIAM/raw/main/Classification/Classification_EntraIdDirectoryRoles.json") + if err != nil { + fmt.Println(err) + return nil, fmt.Errorf("failed to get role json") + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + fmt.Println(err) + return nil, fmt.Errorf("failed to read role json") + } + + var roles []Role + err = json.Unmarshal(body, &roles) + if err != nil { + fmt.Println(err) + return nil, fmt.Errorf("failed to unmarshal role json") + } + + var EAMTierLevelTagValueAlias = map[string]string{ + "0": "admin_tier_0", + "1": "admin_tier_1", + "2": "admin_tier_2", + } + + roleMaps := make([]RoleMap, 0) + + for _, role := range roles { + roleMaps = append(roleMaps, RoleMap{ + RoleId: role.RoleId, + EAMTierLevelTagValue: role.Classification.EAMTierLevelTagValue, + AdminTierLevel: EAMTierLevelTagValueAlias[role.Classification.EAMTierLevelTagValue], + TenantId: m.Credentials.SentinelTenantID, + }) + } + + results := internal.QueryResults{} + + for _, roleMap := range roleMaps { + roleMapJson, err := json.Marshal(roleMap) + if err != nil { + fmt.Println(err) + return nil, fmt.Errorf("failed to marshal role map") + } + + var roleMapInterface map[string]interface{} + err = json.Unmarshal(roleMapJson, &roleMapInterface) + if err != nil { + fmt.Println(err) + return nil, fmt.Errorf("failed to unmarshal role map json") + } + + results = append(results, roleMapInterface) + } + + return results, nil +} + +func (r *Role) UnmarshalJSON(data []byte) error { + type Alias Role + aux := &struct { + RolePermissions json.RawMessage `json:"RolePermissions"` + *Alias + }{ + Alias: (*Alias)(r), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + var single RolePermissions + if err := json.Unmarshal(aux.RolePermissions, &single); err == nil { + r.RolePermissions = []RolePermissions{single} + return nil + } + + var multiple []RolePermissions + if err := json.Unmarshal(aux.RolePermissions, &multiple); err != nil { + return fmt.Errorf("RolePermissions could not be unmarshalled as an array: %v", err) + } + + r.RolePermissions = multiple + return nil +} diff --git a/internal/version.go b/internal/version.go index 2e80e99..b1270ed 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,3 +1,3 @@ package internal -const Version = "FalconHound v1.3.0" +const Version = "FalconHound v1.4.0" diff --git a/main.go b/main.go index 05ecfd7..a141233 100644 --- a/main.go +++ b/main.go @@ -184,6 +184,13 @@ func makeOutputProcessor(target Target, query Query, credentials internal.Creden Path: target.Path, }, }, nil + case "JSON": + return &output_processor.JSONOutputProcessor{ + OutputProcessor: &baseOutput, + Config: output_processor.JSONOutputConfig{ + Path: target.Path, + }, + }, nil case "Markdown": return &output_processor.MDOutputProcessor{ OutputProcessor: &baseOutput, @@ -240,6 +247,14 @@ func makeOutputProcessor(target Target, query Query, credentials internal.Creden Query: target.Query, }, }, nil + case "BloodHound": + return &output_processor.BloodHoundOutputProcessor{ + OutputProcessor: &baseOutput, + Config: output_processor.BloodHoundOutputConfig{ + Parameters: target.Parameters, + Query: target.Query, + }, + }, nil case "Watchlist": return &output_processor.WatchlistOutputProcessor{ OutputProcessor: &baseOutput, @@ -319,6 +334,11 @@ func makeInputProcessor(query Query, credentials internal.Credentials, outputs [ InputProcessor: &baseProcessor, Config: input_processor.ElasticConfig{}, }, nil + case "HTTP": + return &input_processor.HTTPProcessor{ + InputProcessor: &baseProcessor, + Config: input_processor.HTTPConfig{}, + }, nil default: return nil, fmt.Errorf("source platform %q not supported", query.SourcePlatform) } diff --git a/output_processor/bloodhound.go b/output_processor/bloodhound.go new file mode 100644 index 0000000..26d3159 --- /dev/null +++ b/output_processor/bloodhound.go @@ -0,0 +1,128 @@ +package output_processor + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "falconhound/internal" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +type BloodHoundOutputConfig struct { + Query string + Parameters map[string]string +} + +type BloodHoundOutputProcessor struct { + *OutputProcessor + Config BloodHoundOutputConfig +} + +func (m *BloodHoundOutputProcessor) BatchSize() int { + return 1 +} + +func (m *BloodHoundOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { + if len(QueryResults) == 0 { + return nil + } + var queryResult internal.QueryResult = QueryResults[0] + var params = make(map[string]interface{}) + for key, value := range m.Config.Parameters { + rowValue, ok := queryResult[value] + if !ok { + return fmt.Errorf("parameter %s not found in query results", value) + } + // Insert into map + params[key] = rowValue + } + if m.Debug { + fmt.Printf("Query: %#v, parameters: %#v\n", m.Config.Query, params) + } + + return WriteBloodHound(m.Config.Query, params, m.Credentials) +} + +// TODO also embed the driver and session in the struct +//var session BloodHound.Session + +func WriteBloodHound(query string, params map[string]interface{}, creds internal.Credentials) error { + if creds.BHTokenKey == "" { + return fmt.Errorf("BHTokenKey is empty, skipping..") + } + + // replace parameters in query + //for key, value := range params { + // query = strings.ReplaceAll(query, fmt.Sprintf("$%s", key), fmt.Sprintf("'%v'", value)) + //} + for key, value := range params { + upperValue := strings.ToUpper(fmt.Sprintf("%v", value)) + query = strings.ReplaceAll(query, fmt.Sprintf("$%s", key), fmt.Sprintf("'%s'", upperValue)) + } + + // Convert query from a multiline string from the yaml to a single line string so the API can parse it + query = strings.ReplaceAll(query, "\n", " ") + log.Printf("Query: %s\n", query) + + method := "POST" + uri := "/api/v2/graphs/cypher" + queryBody := fmt.Sprintf("{\"query\":\"%s\"}", query) + body := []byte(queryBody) + + // The first HMAC digest is the token key + digester := hmac.New(sha256.New, []byte(creds.BHTokenKey)) + + // OperationKey is the first HMAC digestresource + digester.Write([]byte(fmt.Sprintf("%s%s", method, uri))) + + // Update the digester for further chaining + digester = hmac.New(sha256.New, digester.Sum(nil)) + datetimeFormatted := time.Now().Format("2006-01-02T15:04:05.999999-07:00") + digester.Write([]byte(datetimeFormatted[:13])) + + // Update the digester for further chaining + digester = hmac.New(sha256.New, digester.Sum(nil)) + + // Body signing is the last HMAC digest link in the signature chain. This encodes the request body as part of + // the signature to prevent replay attacks that seek to modify the payload of a signed request. In the case + // where there is no body content the HMAC digest is computed anyway, simply with no values written to the + // digester. + if body != nil { + digester.Write(body) + } + + bhendpoint := fmt.Sprintf("%s%s", creds.BHUrl, uri) + + // Perform the request with the signed and expected headers + req, err := http.NewRequest(method, bhendpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("User-Agent", internal.Version) + req.Header.Set("Authorization", fmt.Sprintf("bhesignature %s", creds.BHTokenID)) + req.Header.Set("RequestDate", datetimeFormatted) + req.Header.Set("Signature", base64.StdEncoding.EncodeToString(digester.Sum(nil))) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + respbody, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Println("Error reading response body:", err) + } + + fmt.Println("Response:", string(respbody)) + // TODO parse response body into QueryResults + return nil +} diff --git a/output_processor/json.go b/output_processor/json.go new file mode 100644 index 0000000..0ca1480 --- /dev/null +++ b/output_processor/json.go @@ -0,0 +1,56 @@ +package output_processor + +import ( + "encoding/json" + "falconhound/internal" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type JSONOutputConfig struct { + Path string +} + +type JSONOutputProcessor struct { + *OutputProcessor + Config JSONOutputConfig +} + +// JSON does not require batching, will write all output in one go +func (m *JSONOutputProcessor) BatchSize() int { + return 0 +} + +func (m *JSONOutputProcessor) ProduceOutput(QueryResults internal.QueryResults) error { + err := WriteJSON(QueryResults, m.Config.Path) + return err +} + +func WriteJSON(results internal.QueryResults, path string) error { + path = strings.Replace(path, "{{date}}", time.Now().Format("2006-01-02"), 2) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed creating directories: %w", err) + } + + jsonFile, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed creating file: %w", err) + } + defer jsonFile.Close() + + jsonData, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("failed marshalling data: %w", err) + } + + _, err = jsonFile.Write(jsonData) + if err != nil { + return fmt.Errorf("failed writing data: %w", err) + } + + return nil +}