diff --git a/Packs/soc-framework-nist-ir/Playbooks/playbook-Compromised_User_Kill_Switch_Containment_-_Entra_and_Active_Directory.yml b/Packs/soc-framework-nist-ir/Playbooks/playbook-Compromised_User_Kill_Switch_Containment_-_Entra_and_Active_Directory.yml new file mode 100644 index 00000000..4db3479f --- /dev/null +++ b/Packs/soc-framework-nist-ir/Playbooks/playbook-Compromised_User_Kill_Switch_Containment_-_Entra_and_Active_Directory.yml @@ -0,0 +1,1486 @@ +id: Compromised User Kill Switch Containment - Entra and Active Directory +version: -1 +name: Compromised User Kill Switch Containment - Entra and Active Directory +description: |2 + Manual kill-switch containment playbook for a confirmed compromised user. + This playbook is intentionally opinionated: after validation, protected-account checks, + optional approval, and dry-run handling, it attempts the standard containment bundle + instead of exposing per-action toggles. + + Containment bundle: + - Disable the on-prem Active Directory account when an AD identity is found. + - Expire the on-prem Active Directory password when an AD identity is found. + - Disable the Microsoft Entra account when an Entra identity is found. + - Revoke Microsoft Entra refresh tokens / cloud sessions when an Entra identity is found. + - Reset/remove supported Entra authentication methods when an Entra identity is found. + - Verify final state and generate a single analyst-facing containment summary. + + Contribution cleanup notes: + - Default execution is safe for shared framework review: DryRun defaults to true and RequireApproval defaults to true. + - Upload referenced custom automations with this playbook or ensure they already exist in the target pack. +starttaskid: '0' +tasks: + '0': + id: '0' + taskid: b1dfdb7d-3d74-465f-860e-84e7fe374d8e + type: start + task: + id: b1dfdb7d-3d74-465f-860e-84e7fe374d8e + version: -1 + name: '' + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '1' + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 827.5, + "y": 50 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '1': + id: '1' + taskid: deb80f3c-744f-428c-816f-5d8d426af95d + type: regular + task: + id: deb80f3c-744f-428c-816f-5d8d426af95d + version: -1 + name: Validate required kill-switch inputs + description: Validates UserIdentifier and Reason before the containment workflow continues. + scriptName: ValidateCompromisedUserContainmentInputs + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '2' + scriptarguments: + reason: + simple: ${inputs.Reason} + user_identifier: + simple: ${inputs.UserIdentifier} + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 827.5, + "y": 220 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '2': + id: '2' + taskid: f2d06709-e01a-4569-8282-58f1e8190ae7 + type: regular + task: + id: f2d06709-e01a-4569-8282-58f1e8190ae7 + version: -1 + name: Normalize user identifier + description: Normalizes email, UPN, SAMAccountName, domain\\username, username, or DN into lookup values for AD and + Entra. + scriptName: NormalizeCompromisedUserIdentifier + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '3' + scriptarguments: + user_identifier: + simple: ${CompromisedUser.InputValidation.UserIdentifier} + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 827.5, + "y": 405 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '3': + id: '3' + taskid: 947c6dc9-05fe-48e8-856c-c7436872ce54 + type: regular + task: + id: 947c6dc9-05fe-48e8-856c-c7436872ce54 + version: -1 + name: Look up user in Active Directory + description: Attempts to retrieve the matching on-prem Active Directory account. Failure does not stop Entra containment. + script: Active Directory Query v2|||ad-get-user + type: regular + iscommand: true + brand: Active Directory Query v2 + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '4' + scriptarguments: + email: + simple: ${AccountContainment.NormalizedUser.UPN} + sAMAccountName: + simple: ${AccountContainment.NormalizedUser.SAMAccountName} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 827.5, + "y": 590 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '4': + id: '4' + taskid: 1fa4403d-1d6a-4eba-8774-c13f1dfe466e + type: regular + task: + id: 1fa4403d-1d6a-4eba-8774-c13f1dfe466e + version: -1 + name: Look up user in Entra ID + description: Attempts to retrieve the matching Microsoft Entra user. Failure does not stop AD containment. + script: Microsoft Graph User|||msgraph-user-get + type: regular + iscommand: true + brand: Microsoft Graph User + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '5' + scriptarguments: + user: + simple: ${AccountContainment.NormalizedUser.UPN} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 827.5, + "y": 775 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '5': + id: '5' + taskid: 287f1d83-c9dc-4dd9-8dce-8f1d94567247 + type: condition + task: + id: 287f1d83-c9dc-4dd9-8dce-8f1d94567247 + version: -1 + name: Was the user found in AD or Entra? + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + 'no': + - '29' + 'yes': + - '6' + separatecontext: false + conditions: + - label: 'yes' + condition: + - - operator: isNotEmpty + left: + value: + simple: ActiveDirectory.Users.sAMAccountName + iscontext: true + - - operator: isNotEmpty + left: + value: + simple: MSGraphUser.ID + iscontext: true + - label: 'no' + condition: + - - operator: isEmpty + left: + value: + simple: ActiveDirectory.Users.sAMAccountName + iscontext: true + - operator: isEmpty + left: + value: + simple: MSGraphUser.ID + iscontext: true + continueonerrortype: '' + view: |- + { + "position": { + "x": 827.5, + "y": 960 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '6': + id: '6' + taskid: a17324c7-3982-4eb9-8461-2cb4c0e422aa + type: title + task: + id: a17324c7-3982-4eb9-8461-2cb4c0e422aa + version: -1 + name: Enrichment and safety checks + type: title + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '7' + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 1042.5, + "y": 1145 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '7': + id: '7' + taskid: 4035dc1b-4bc7-4fb1-aef6-4ad2852912cd + type: regular + task: + id: 4035dc1b-4bc7-4fb1-aef6-4ad2852912cd + version: -1 + name: Collect user context for approval and notes + description: Collects Entra and AD enrichment context for a confirmed compromised user before containment. + scriptName: CollectCompromisedUserContext + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '8' + scriptarguments: + ad_sam_account_name: + simple: ${ActiveDirectory.Users.sAMAccountName} + entra_user_id: + simple: ${MSGraphUser.ID} + separatecontext: false + continueonerror: true + continueonerrortype: '' + view: |- + { + "position": { + "x": 1042.5, + "y": 1315 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '8': + id: '8' + taskid: 2bb147f4-63f1-4319-80d5-7ff361754bc8 + type: regular + task: + id: 2bb147f4-63f1-4319-80d5-7ff361754bc8 + version: -1 + name: Check protected account guardrails + description: Detects privileged, break-glass, service, VIP, executive, admin, and other protected-account indicators + before containment. + scriptName: ProtectedAccountContainmentGuard + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '9' + scriptarguments: + ad_user_json: + simple: ${ActiveDirectory.Users} + entra_user_json: + simple: ${MSGraphUser} + user_identifier: + simple: ${AccountContainment.NormalizedUser.UPN} + vip_list: + simple: ${lists.LKSD_VIPs} + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 1042.5, + "y": 1500 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '9': + id: '9' + taskid: e5f9073b-2cc1-45b6-856b-d25bc66ec35d + type: condition + task: + id: e5f9073b-2cc1-45b6-856b-d25bc66ec35d + version: -1 + name: Is this a protected account? + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + 'no': + - '11' + 'yes': + - '10' + separatecontext: false + conditions: + - label: 'yes' + condition: + - - operator: isEqualString + left: + value: + simple: ProtectedAccountGuard.is_protected_account + iscontext: true + right: + value: + simple: 'true' + - label: 'no' + condition: + - - operator: isEqualString + left: + value: + simple: ProtectedAccountGuard.is_protected_account + iscontext: true + right: + value: + simple: 'false' + continueonerrortype: '' + view: |- + { + "position": { + "x": 1042.5, + "y": 1685 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '10': + id: '10' + taskid: 777d3216-c18c-450b-8982-a9ffb6546cab + type: condition + task: + id: 777d3216-c18c-450b-8982-a9ffb6546cab + version: -1 + name: Protected account approval required + description: Analyst approval is required for protected accounts. Deny if the target is a break-glass, service, or executive + account and containment requires an alternate path. + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#default#': + - '31' + 'Yes': + - '11' + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 1492.5, + "y": 1870 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '11': + id: '11' + taskid: 156643cb-ff82-4450-8935-08a41286b6b2 + type: condition + task: + id: 156643cb-ff82-4450-8935-08a41286b6b2 + version: -1 + name: Is dry run enabled? + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + 'no': + - '12' + 'yes': + - '30' + separatecontext: false + conditions: + - label: 'yes' + condition: + - - operator: isEqualString + left: + value: + simple: inputs.DryRun + iscontext: true + right: + value: + simple: 'true' + - label: 'no' + condition: + - - operator: isEqualString + left: + value: + simple: inputs.DryRun + iscontext: true + right: + value: + simple: 'false' + continueonerrortype: '' + view: |- + { + "position": { + "x": 920, + "y": 2055 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '12': + id: '12' + taskid: 824134ed-fd53-4280-83e9-f0348fa6a4c4 + type: condition + task: + id: 824134ed-fd53-4280-83e9-f0348fa6a4c4 + version: -1 + name: Is analyst approval required? + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + 'no': + - '14' + 'yes': + - '13' + separatecontext: false + conditions: + - label: 'yes' + condition: + - - operator: isEqualString + left: + value: + simple: inputs.RequireApproval + iscontext: true + right: + value: + simple: 'true' + - label: 'no' + condition: + - - operator: isEqualString + left: + value: + simple: inputs.RequireApproval + iscontext: true + right: + value: + simple: 'false' + continueonerrortype: '' + view: |- + { + "position": { + "x": 592.5, + "y": 2240 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '13': + id: '13' + taskid: ffb668c5-3316-4a34-8faa-dcad7ea6ebe8 + type: condition + task: + id: ffb668c5-3316-4a34-8faa-dcad7ea6ebe8 + version: -1 + name: Approve compromised user kill switch + description: |- + Manual approval gate. Review the normalized identity, AD result, Entra result, + protected-account findings, business context, and reason. Complete this task only + when the user is confirmed compromised and immediate account containment is intended. + + If containment should not proceed, stop or close the playbook manually and document + the reason in the incident. + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#default#': + - '31' + 'Yes': + - '14' + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 1135, + "y": 2425 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '14': + id: '14' + taskid: 26ca1993-341b-44ab-8582-4f5ec3b9c05a + type: title + task: + id: 26ca1993-341b-44ab-8582-4f5ec3b9c05a + version: -1 + name: Kill-switch containment bundle + type: title + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '15' + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 582.5, + "y": 2617.5 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '15': + id: '15' + taskid: 6a6a6567-f185-4c82-8862-671b2da73aab + type: condition + task: + id: 6a6a6567-f185-4c82-8862-671b2da73aab + version: -1 + name: Was an AD account found? + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + 'no': + - '19' + 'yes': + - '16' + separatecontext: false + conditions: + - label: 'yes' + condition: + - - operator: isNotEmpty + left: + value: + simple: ActiveDirectory.Users.sAMAccountName + iscontext: true + - label: 'no' + condition: + - - operator: isEmpty + left: + value: + simple: ActiveDirectory.Users.sAMAccountName + iscontext: true + continueonerrortype: '' + view: |- + { + "position": { + "x": 582.5, + "y": 2795 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '16': + id: '16' + taskid: 447e2e63-5710-4304-8fbd-c00aa68adaed + type: regular + task: + id: 447e2e63-5710-4304-8fbd-c00aa68adaed + version: -1 + name: Disable AD account + description: Disables the on-prem Active Directory account. + script: Active Directory Query v2|||ad-disable-account + type: regular + iscommand: true + brand: Active Directory Query v2 + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '17' + scriptarguments: + username: + simple: ${ActiveDirectory.Users.sAMAccountName} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 807.5, + "y": 2980 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '17': + id: '17' + taskid: 6bea4275-6730-4ee3-83f0-73134fbee5ce + type: regular + task: + id: 6bea4275-6730-4ee3-83f0-73134fbee5ce + version: -1 + name: Expire AD password + description: Expires the on-prem Active Directory password to reduce reuse risk. + script: Active Directory Query v2|||ad-expire-password + type: regular + iscommand: true + brand: Active Directory Query v2 + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '18' + scriptarguments: + username: + simple: ${ActiveDirectory.Users.sAMAccountName} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 807.5, + "y": 3165 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '18': + id: '18' + taskid: b1124a84-bec8-453b-885a-e456972d5170 + type: regular + task: + id: b1124a84-bec8-453b-885a-e456972d5170 + version: -1 + name: Verify AD containment result + description: Re-queries AD and normalizes AD containment status as Success, Failed, Partial, or Unknown. + scriptName: VerifyADCompromisedUserContainment + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '20' + scriptarguments: + sam_account_name: + simple: ${ActiveDirectory.Users.sAMAccountName} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 807.5, + "y": 3350 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '19': + id: '19' + taskid: 259dc3f7-fae7-41a1-86ec-c5142f3b8a82 + type: regular + task: + id: 259dc3f7-fae7-41a1-86ec-c5142f3b8a82 + version: -1 + name: Mark AD containment skipped + description: Records that AD containment was skipped because no AD account was found. + scriptName: SetCompromisedUserContainmentActionStatus + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '20' + scriptarguments: + action: + simple: ad_containment + detail: + simple: No matching Active Directory account was found. + status: + simple: Skipped + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 377.5, + "y": 3350 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '20': + id: '20' + taskid: 72be4660-eba0-4a60-8505-ea5c09633f8f + type: condition + task: + id: 72be4660-eba0-4a60-8505-ea5c09633f8f + version: -1 + name: Was an Entra account found? + type: condition + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + 'no': + - '25' + 'yes': + - '21' + separatecontext: false + conditions: + - label: 'yes' + condition: + - - operator: isNotEmpty + left: + value: + simple: MSGraphUser.ID + iscontext: true + - label: 'no' + condition: + - - operator: isEmpty + left: + value: + simple: MSGraphUser.ID + iscontext: true + continueonerrortype: '' + view: |- + { + "position": { + "x": 582.5, + "y": 3535 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '21': + id: '21' + taskid: eeff7620-901e-4af0-8264-2f610d2c6601 + type: regular + task: + id: eeff7620-901e-4af0-8264-2f610d2c6601 + version: -1 + name: Disable Entra account + description: Disables Microsoft Entra sign-in for the compromised user. + script: Microsoft Graph User|||msgraph-user-account-disable + type: regular + iscommand: true + brand: Microsoft Graph User + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '22' + scriptarguments: + user: + simple: ${MSGraphUser.UserPrincipalName} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 807.5, + "y": 3720 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '22': + id: '22' + taskid: 54b14171-db56-4e30-82e3-054a62e4d4ea + type: regular + task: + id: 54b14171-db56-4e30-82e3-054a62e4d4ea + version: -1 + name: Revoke Entra sessions + description: Invalidates refresh tokens issued to applications for the compromised user. + script: Microsoft Graph User|||msgraph-user-session-revoke + type: regular + iscommand: true + brand: Microsoft Graph User + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '23' + scriptarguments: + user: + simple: ${MSGraphUser.UserPrincipalName} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 807.5, + "y": 3905 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '23': + id: '23' + taskid: 61500bb7-16e5-4ceb-8427-3d7744a403c1 + type: regular + task: + id: 61500bb7-16e5-4ceb-8427-3d7744a403c1 + version: -1 + name: Reset Entra authentication methods + description: Deletes or resets supported authentication methods for the compromised user. Partial failures are captured + for the final summary. + scriptName: ResetEntraAuthenticationMethods + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '24' + scriptarguments: + dry_run: + simple: 'false' + user_id: + simple: ${MSGraphUser.ID} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 807.5, + "y": 4090 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '24': + id: '24' + taskid: a2985f25-fae3-40b0-8841-04ca7d88b187 + type: regular + task: + id: a2985f25-fae3-40b0-8841-04ca7d88b187 + version: -1 + name: Verify Entra containment result + description: Re-queries Entra and normalizes Entra containment status as Success, Failed, Partial, Submitted, or Unknown. + scriptName: VerifyEntraCompromisedUserContainment + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '26' + scriptarguments: + user_id: + simple: ${MSGraphUser.ID} + user_principal_name: + simple: ${MSGraphUser.UserPrincipalName} + separatecontext: false + continueonerror: true + continueonerrortype: errorPath + view: |- + { + "position": { + "x": 807.5, + "y": 4275 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '25': + id: '25' + taskid: 34762ed2-9aa0-4eb5-80ad-fb4e3a9d16c6 + type: regular + task: + id: 34762ed2-9aa0-4eb5-80ad-fb4e3a9d16c6 + version: -1 + name: Mark Entra containment skipped + description: Records that Entra containment was skipped because no Entra account was found. + scriptName: SetCompromisedUserContainmentActionStatus + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '26' + scriptarguments: + action: + simple: entra_containment + detail: + simple: No matching Microsoft Entra account was found. + status: + simple: Skipped + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 377.5, + "y": 4275 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '26': + id: '26' + taskid: bddb75ad-e1b6-41f6-8cbe-764dee6b8daf + type: regular + task: + id: bddb75ad-e1b6-41f6-8cbe-764dee6b8daf + version: -1 + name: Append AD containment description note + description: Appends a timestamped containment note to the AD description field when an AD account exists. + scriptName: AppendADContainmentDescriptionNote + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '27' + scriptarguments: + containment_reason: + simple: ${inputs.Reason} + user_identifier: + simple: ${ActiveDirectory.Users.sAMAccountName} + separatecontext: false + continueonerror: true + continueonerrortype: '' + view: |- + { + "position": { + "x": 582.5, + "y": 4460 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '27': + id: '27' + taskid: 7d63c00f-5c76-46c2-8a44-1abcae588a7f + type: regular + task: + id: 7d63c00f-5c76-46c2-8a44-1abcae588a7f + version: -1 + name: Build final containment verdict + description: Combines AD, Entra, session, password, MFA, and note outcomes into a single containment verdict and residual-risk + statement. + scriptName: BuildCompromisedUserContainmentVerdict + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '28' + scriptarguments: + action_status_json: + simple: ${CompromisedUserContainment.Actions} + ad_status_json: + simple: ${CompromisedUserContainment.AD} + entra_status_json: + simple: ${CompromisedUserContainment.Entra} + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 582.5, + "y": 4645 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '28': + id: '28' + taskid: 8e7e47a4-dcde-42e9-8ee4-1e86169f1d56 + type: regular + task: + id: 8e7e47a4-dcde-42e9-8ee4-1e86169f1d56 + version: -1 + name: Generate containment case notes + description: Generates final analyst-facing Markdown notes from actual action and verification results. + scriptName: GenerateCompromisedAccountContainmentCaseNotes + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '32' + scriptarguments: + ad_user_json: + simple: ${ActiveDirectory.Users} + entra_user_json: + simple: ${MSGraphUser} + reason: + simple: ${inputs.Reason} + residual_risk: + simple: ${CompromisedUserContainment.Verdict.residual_risk} + user_identifier: + simple: ${AccountContainment.NormalizedUser.UPN} + verification_results_json: + simple: ${CompromisedUserContainment.Verdict} + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 582.5, + "y": 4830 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '29': + id: '29' + taskid: 566a5d24-6ff8-4610-8996-945182c1fe3e + type: regular + task: + id: 566a5d24-6ff8-4610-8996-945182c1fe3e + version: -1 + name: Generate not-found containment notes + description: Documents that no matching AD or Entra user was found and no containment actions were attempted. + scriptName: GenerateCompromisedAccountContainmentCaseNotes + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '32' + scriptarguments: + reason: + simple: ${inputs.Reason} + residual_risk: + simple: User identity could not be confirmed. Validate identifier manually. + user_identifier: + simple: ${inputs.UserIdentifier} + verification_results_json: + simple: User not found in AD or Entra. No containment actions were attempted. + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 50, + "y": 4830 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '30': + id: '30' + taskid: 305edd72-06b5-4c90-8b44-dfd9f4923277 + type: regular + task: + id: 305edd72-06b5-4c90-8b44-dfd9f4923277 + version: -1 + name: Generate dry-run containment notes + description: Documents the actions that would have run without performing containment. + scriptName: GenerateCompromisedAccountContainmentCaseNotes + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '32' + scriptarguments: + ad_user_json: + simple: ${ActiveDirectory.Users} + entra_user_json: + simple: ${MSGraphUser} + reason: + simple: ${inputs.Reason} + residual_risk: + simple: Account remains active because dry run was enabled. + user_identifier: + simple: ${AccountContainment.NormalizedUser.UPN} + verification_results_json: + simple: DryRun=true. No containment actions were executed. + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 1360, + "y": 4830 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '31': + id: '31' + taskid: 73ba3099-52fb-41af-8cb8-36a8e6208f2d + type: regular + task: + id: 73ba3099-52fb-41af-8cb8-36a8e6208f2d + version: -1 + name: Generate approval-denied notes + description: Documents that analyst approval was denied and no containment actions were attempted. + scriptName: GenerateCompromisedAccountContainmentCaseNotes + type: regular + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + nexttasks: + '#none#': + - '32' + scriptarguments: + reason: + simple: ${inputs.Reason} + residual_risk: + simple: Account remains active because containment was not approved. + user_identifier: + simple: ${AccountContainment.NormalizedUser.UPN} + verification_results_json: + simple: Analyst approval denied. No containment actions were executed. + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 1135, + "y": 2610 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false + '32': + id: '32' + taskid: 5c30362a-3440-4871-8664-c46d59db3a6d + type: title + task: + id: 5c30362a-3440-4871-8664-c46d59db3a6d + version: -1 + name: Done + type: title + iscommand: false + brand: '' + playbooktaskmissingcomponent: null + istaskmissingcomponenterrordismissed: false + separatecontext: false + continueonerrortype: '' + view: |- + { + "position": { + "x": 797.5, + "y": 5015 + } + } + note: false + timertriggers: [] + ignoreworker: false + skipunavailable: false + quietmode: 0 + isoversize: false + isautoswitchedtoquietmode: false +view: |- + { + "linkLabelsPosition": {}, + "paper": { + "dimensions": { + "height": 5025, + "width": 1822.5, + "x": 50, + "y": 50 + } + } + } +inputs: +- key: UserIdentifier + value: {} + required: true + description: Email, UPN, SAMAccountName, domain\\username, username, or distinguished name for the confirmed compromised + user. + playbookInputQuery: null +- key: Reason + value: {} + required: true + description: Short containment reason to include in case notes and account annotations. + playbookInputQuery: null +- key: DryRun + value: + simple: 'true' + required: true + description: When true, performs validation, lookup, enrichment, safety checks, Defaults to true for safe framework review. + playbookInputQuery: null +- key: RequireApproval + value: + simple: 'true' + required: false + description: |- + When true, requires analyst approval before the kill-switch containment + bundle executes. Defaults to true for high-impact containment. + playbookInputQuery: null +- key: TicketOrIncidentId + value: {} + required: false + description: |- + Optional external ticket, incident, or case reference for the containment + record. + playbookInputQuery: null +- key: NotifyManager + value: {} + required: false + description: |- + Optional documentation-only flag for downstream notification handling. + No notification task is included in this scaffold. + playbookInputQuery: null +inputSections: +- inputs: + - UserIdentifier + - Reason + - DryRun + - RequireApproval + - TicketOrIncidentId + - NotifyManager + name: General (Inputs group) + description: Generic group for inputs +outputs: +- contextPath: CompromisedUserContainment.Verdict.containment_success + description: True only when at least one identity platform was contained successfully and no critical containment action + failed. + type: Boolean +- contextPath: CompromisedUserContainment.Verdict.residual_risk + description: Residual risk statement built from failed, skipped, or unknown actions. + type: String +- contextPath: CompromisedUserContainment.AD.status + description: Success, Partial, Failed, or Unknown. + type: String +- contextPath: CompromisedUserContainment.Entra.status + description: Overall verification result. One of Success, Partial, Failed, Submitted, or Unknown. + type: String +- contextPath: CompromisedUserContainment.Actions + type: unknown +outputSections: +- outputs: + - CompromisedUserContainment.Verdict.containment_success + - CompromisedUserContainment.Verdict.residual_risk + - CompromisedUserContainment.AD.status + - CompromisedUserContainment.Entra.status + - CompromisedUserContainment.Actions + name: General (Outputs group) + description: Generic group for outputs diff --git a/Packs/soc-framework-nist-ir/Scripts/AppendADContainmentDescriptionNote/AppendADContainmentDescriptionNote.yml b/Packs/soc-framework-nist-ir/Scripts/AppendADContainmentDescriptionNote/AppendADContainmentDescriptionNote.yml new file mode 100644 index 00000000..8904810b --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/AppendADContainmentDescriptionNote/AppendADContainmentDescriptionNote.yml @@ -0,0 +1,122 @@ +commonfields: + id: AppendADContainmentDescriptionNote + version: -1 +name: AppendADContainmentDescriptionNote +script: "import traceback\nfrom datetime import datetime\nfrom typing import Any\n\n\nOUTPUT_PREFIX = \"CompromisedUser.ADDescriptionUpdate\"\ + \nSEPARATOR = \" | \"\n\n\ndef str_arg(args: dict[str, Any], name: str, default: str = \"\") -> str:\n value = args.get(name,\ + \ default)\n if value is None:\n return default\n return str(value).strip()\n\n\ndef bool_arg(args: dict[str,\ + \ Any], name: str, default: bool = False) -> bool:\n value = args.get(name)\n if value is None:\n return default\n\ + \ if isinstance(value, bool):\n return value\n return str(value).strip().lower() in (\"true\", \"yes\", \"\ + y\", \"1\")\n\n\ndef parse_args(args: dict[str, Any]) -> dict[str, Any]:\n user_identifier = str_arg(args, \"user_identifier\"\ + )\n containment_reason = str_arg(args, \"containment_reason\")\n\n if not user_identifier:\n raise DemistoException(\"\ + user_identifier is required and cannot be blank.\")\n if not containment_reason:\n raise DemistoException(\"containment_reason\ + \ is required and cannot be blank.\")\n\n return {\n \"user_identifier\": user_identifier,\n \"existing_description\"\ + : str_arg(args, \"existing_description\"),\n \"containment_reason\": containment_reason,\n \"case_id\": str_arg(args,\ + \ \"case_id\"),\n \"analyst\": str_arg(args, \"analyst\"),\n \"dry_run\": bool_arg(args, \"dry_run\", False),\n\ + \ \"prevent_duplicate_note\": bool_arg(args, \"prevent_duplicate_note\", True),\n }\n\n\ndef build_note(containment_reason:\ + \ str, case_id: str, analyst: str) -> str:\n timestamp = datetime.utcnow().strftime(\"%Y-%m-%d %H:%M UTC\")\n note\ + \ = f\"XSIAM containment {timestamp}: Account marked compromised. Reason: {containment_reason}.\"\n if case_id:\n \ + \ note += f\" Case: {case_id}.\"\n if analyst:\n note += f\" Analyst: {analyst}.\"\n return note\n\n\n\ + def is_duplicate(existing_description: str, note: str, case_id: str) -> bool:\n if not existing_description:\n \ + \ return False\n\n if note in existing_description:\n return True\n\n if case_id and f\"Case: {case_id}.\"\ + \ in existing_description and \"XSIAM containment\" in existing_description:\n return True\n\n return False\n\n\ + \ndef append_description(existing_description: str, note: str) -> str:\n if existing_description:\n return f\"\ + {existing_description}{SEPARATOR}{note}\"\n return note\n\n\ndef update_ad_description(user_identifier: str, new_description:\ + \ str) -> str:\n result = demisto.executeCommand(\n \"ad-update-user\",\n {\n \"username\":\ + \ user_identifier,\n \"attribute-name\": \"description\",\n \"attribute-value\": new_description,\n\ + \ },\n )\n\n if is_error(result):\n raise DemistoException(get_error(result))\n\n return \"Updated\"\ + \n\n\ndef main():\n try:\n args = parse_args(demisto.args())\n\n note = build_note(\n args[\"\ + containment_reason\"],\n args[\"case_id\"],\n args[\"analyst\"],\n )\n\n duplicate_prevented\ + \ = False\n if args[\"prevent_duplicate_note\"] and is_duplicate(args[\"existing_description\"], note, args[\"case_id\"\ + ]):\n new_description = args[\"existing_description\"]\n duplicate_prevented = True\n update_status\ + \ = \"Skipped - duplicate containment note detected\"\n else:\n new_description = append_description(args[\"\ + existing_description\"], note)\n update_status = \"Dry run - AD not updated\" if args[\"dry_run\"] else update_ad_description(\n\ + \ args[\"user_identifier\"],\n new_description,\n )\n\n outputs = {\n \ + \ \"UserIdentifier\": args[\"user_identifier\"],\n \"OldDescription\": args[\"existing_description\"\ + ],\n \"AppendedNote\": \"\" if duplicate_prevented else note,\n \"NewDescription\": new_description,\n\ + \ \"DryRun\": args[\"dry_run\"],\n \"PreventDuplicateNote\": args[\"prevent_duplicate_note\"],\n \ + \ \"DuplicatePrevented\": duplicate_prevented,\n \"UpdateStatus\": update_status,\n \"CaseID\"\ + : args[\"case_id\"],\n \"Analyst\": args[\"analyst\"],\n }\n\n readable = tableToMarkdown(\n \ + \ \"AD Description Containment Note Update\",\n outputs,\n headers=[\n \"\ + UserIdentifier\",\n \"OldDescription\",\n \"AppendedNote\",\n \"NewDescription\"\ + ,\n \"DryRun\",\n \"DuplicatePrevented\",\n \"UpdateStatus\",\n \ + \ ],\n )\n\n return_results(CommandResults(\n readable_output=readable,\n outputs_prefix=OUTPUT_PREFIX,\n\ + \ outputs_key_field=\"UserIdentifier\",\n outputs=outputs,\n ))\n\n except Exception as\ + \ ex:\n demisto.error(traceback.format_exc())\n return_error(f\"Failed to append AD containment note. Error:\ + \ {ex}\")\n\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"):\n main()" +type: python +tags: +- active directory +- containment +- compromised user +comment: Appends a timestamped XSIAM containment note to an Active Directory user's description attribute using ad-update-user. +enabled: true +args: +- supportedModules: [] + name: user_identifier + required: true + description: AD user identifier. Passed to ad-update-user as username; use sAMAccountName when required by the AD integration. + type: String +- supportedModules: [] + name: existing_description + description: Current AD description value, usually from ad-get-user. + type: String +- supportedModules: [] + name: containment_reason + required: true + description: Reason the account is being contained. + type: String +- supportedModules: [] + name: case_id + description: XSIAM incident or case ID to include in the containment note. + type: String +- supportedModules: [] + name: analyst + description: Analyst name or username to include in the containment note. + type: String +- supportedModules: [] + name: dry_run + description: When true, do not update AD. Return only the planned description. + defaultValue: 'true' + type: Boolean +- supportedModules: [] + name: prevent_duplicate_note + description: Avoid appending the same case ID or containment note more than once. + defaultValue: 'true' + type: Boolean +outputs: +- contextPath: CompromisedUser.ADDescriptionUpdate.UserIdentifier + description: AD user identifier supplied to the automation. + type: string +- contextPath: CompromisedUser.ADDescriptionUpdate.OldDescription + description: Original AD description value. + type: string +- contextPath: CompromisedUser.ADDescriptionUpdate.AppendedNote + description: Containment note appended to the AD description. + type: string +- contextPath: CompromisedUser.ADDescriptionUpdate.NewDescription + description: Final AD description value or planned value during dry run. + type: string +- contextPath: CompromisedUser.ADDescriptionUpdate.DryRun + description: Whether dry-run mode was used. + type: boolean +- contextPath: CompromisedUser.ADDescriptionUpdate.PreventDuplicateNote + description: Whether duplicate prevention was enabled. + type: boolean +- contextPath: CompromisedUser.ADDescriptionUpdate.DuplicatePrevented + description: Whether an existing containment note prevented a duplicate append. + type: boolean +- contextPath: CompromisedUser.ADDescriptionUpdate.UpdateStatus + description: AD update status. + type: string +- contextPath: CompromisedUser.ADDescriptionUpdate.CaseID + description: Case ID included in the note. + type: string +- contextPath: CompromisedUser.ADDescriptionUpdate.Analyst + description: Analyst included in the note. + type: string +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.11.10.113941 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/BuildCompromisedUserContainmentVerdict/BuildCompromisedUserContainmentVerdict.yml b/Packs/soc-framework-nist-ir/Scripts/BuildCompromisedUserContainmentVerdict/BuildCompromisedUserContainmentVerdict.yml new file mode 100644 index 00000000..14853f69 --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/BuildCompromisedUserContainmentVerdict/BuildCompromisedUserContainmentVerdict.yml @@ -0,0 +1,102 @@ +commonfields: + id: BuildCompromisedUserContainmentVerdict + version: -1 +name: BuildCompromisedUserContainmentVerdict +script: "import json\nimport traceback\nfrom typing import Any\n\n\nOUTPUT_PREFIX = \"CompromisedUserContainment.Verdict\"\ + \n\nSUCCESS_VALUES = {\"success\", \"succeeded\", \"successful\", \"contained\", \"completed\", \"done\", \"true\"}\nFAILED_VALUES\ + \ = {\"failed\", \"failure\", \"error\", \"critical_failed\", \"false\"}\nSKIPPED_VALUES = {\"skipped\", \"not_found\",\ + \ \"no_account\", \"not_applicable\", \"n/a\", \"none\"}\nUNKNOWN_VALUES = {\"unknown\", \"missing\", \"pending\", \"\"\ + }\n\n\ndef parse_json_arg(value: Any) -> Any:\n if value in (None, \"\", \"null\"):\n return {}\n if isinstance(value,\ + \ (dict, list)):\n return value\n if isinstance(value, str):\n try:\n return json.loads(value)\n\ + \ except Exception:\n return {\"status\": value}\n return {\"status\": str(value)}\n\n\ndef normalize_status(value:\ + \ Any) -> str:\n if isinstance(value, bool):\n return \"success\" if value else \"failed\"\n text = str(value\ + \ or \"\").strip().lower().replace(\" \", \"_\")\n if text in SUCCESS_VALUES:\n return \"success\"\n if text\ + \ in FAILED_VALUES:\n return \"failed\"\n if text in SKIPPED_VALUES:\n return \"skipped\"\n if text\ + \ in UNKNOWN_VALUES:\n return \"unknown\"\n return \"unknown\"\n\n\ndef extract_status(obj: Any) -> str:\n \ + \ if not isinstance(obj, dict):\n return normalize_status(obj)\n\n for key in (\"status\", \"containment_status\"\ + , \"result\", \"state\"):\n if key in obj:\n return normalize_status(obj.get(key))\n\n for key in (\"\ + success\", \"succeeded\", \"contained\"):\n if key in obj:\n return normalize_status(obj.get(key))\n\n\ + \ if obj.get(\"skipped\") is True or obj.get(\"account_found\") is False or obj.get(\"exists\") is False:\n return\ + \ \"skipped\"\n\n return \"unknown\"\n\n\ndef action_name(default_name: str, obj: Any) -> str:\n if isinstance(obj,\ + \ dict):\n return str(obj.get(\"action\") or obj.get(\"name\") or obj.get(\"platform\") or default_name)\n return\ + \ default_name\n\n\ndef is_critical(obj: Any) -> bool:\n if not isinstance(obj, dict):\n return True\n return\ + \ bool(obj.get(\"critical\", True))\n\n\ndef collect_action(default_name: str, obj: Any) -> dict[str, Any]:\n return\ + \ {\n \"name\": action_name(default_name, obj),\n \"status\": extract_status(obj),\n \"critical\":\ + \ is_critical(obj),\n }\n\n\ndef collect_actions(default_name: str, obj: Any) -> list[dict[str, Any]]:\n if isinstance(obj,\ + \ list):\n return [collect_action(default_name, item) for item in obj]\n if isinstance(obj, dict):\n for\ + \ key in (\"actions\", \"results\", \"statuses\"):\n if isinstance(obj.get(key), list):\n return\ + \ [collect_action(default_name, item) for item in obj[key]]\n return [collect_action(default_name, obj)]\n\n\ndef build_verdict(ad_obj:\ + \ Any, entra_obj: Any, action_obj: Any) -> dict[str, Any]:\n identity_actions = [\n collect_action(\"Active Directory\ + \ containment\", ad_obj),\n collect_action(\"Entra ID containment\", entra_obj),\n ]\n\n action_statuses =\ + \ collect_actions(\"Containment action\", action_obj) if action_obj else []\n all_actions = identity_actions + action_statuses\n\ + \n successful_actions = [a[\"name\"] for a in all_actions if a[\"status\"] == \"success\"]\n failed_actions = [a[\"\ + name\"] for a in all_actions if a[\"status\"] == \"failed\"]\n skipped_actions = [a[\"name\"] for a in all_actions if\ + \ a[\"status\"] == \"skipped\"]\n unknown_actions = [a[\"name\"] for a in all_actions if a[\"status\"] == \"unknown\"\ + ]\n\n ad_status = identity_actions[0][\"status\"]\n entra_status = identity_actions[1][\"status\"]\n\n identity_success\ + \ = ad_status == \"success\" or entra_status == \"success\"\n critical_failure = any(a[\"status\"] == \"failed\" and\ + \ a.get(\"critical\", True) for a in all_actions)\n\n no_matching_account = (\n ad_status == \"skipped\"\n \ + \ and entra_status == \"skipped\"\n and not failed_actions\n and not successful_actions\n )\n\n \ + \ if no_matching_account:\n status = \"skipped\"\n containment_success = False\n elif identity_success\ + \ and critical_failure:\n status = \"partial_success\"\n containment_success = False\n elif identity_success:\n\ + \ status = \"success\"\n containment_success = True\n elif ad_status in (\"failed\", \"unknown\") and entra_status\ + \ in (\"failed\", \"unknown\"):\n status = \"failed\"\n containment_success = False\n else:\n status\ + \ = \"failed\"\n containment_success = False\n\n residual_items = []\n if failed_actions:\n residual_items.append(\"\ + Failed actions: \" + \", \".join(failed_actions))\n if skipped_actions:\n residual_items.append(\"Skipped actions:\ + \ \" + \", \".join(skipped_actions))\n if unknown_actions:\n residual_items.append(\"Unknown actions: \" + \"\ + , \".join(unknown_actions))\n\n residual_risk = (\n \"; \".join(residual_items)\n if residual_items\n \ + \ else \"No residual containment risk identified from supplied action statuses.\"\n )\n\n return {\n \ + \ \"containment_success\": containment_success,\n \"status\": status,\n \"residual_risk\": residual_risk,\n\ + \ \"failed_actions\": failed_actions,\n \"successful_actions\": successful_actions,\n \"skipped_actions\"\ + : skipped_actions,\n }\n\n\ndef main():\n try:\n args = demisto.args()\n\n ad_obj = parse_json_arg(args.get(\"\ + ad_status_json\"))\n entra_obj = parse_json_arg(args.get(\"entra_status_json\"))\n action_obj = parse_json_arg(args.get(\"\ + action_status_json\"))\n\n verdict = build_verdict(ad_obj, entra_obj, action_obj)\n\n readable = tableToMarkdown(\n\ + \ \"Compromised User Containment Verdict\",\n [{\n \"Status\": verdict[\"status\"],\n\ + \ \"Containment Success\": verdict[\"containment_success\"],\n \"Successful Actions\": \"\ + , \".join(verdict[\"successful_actions\"]) or \"None\",\n \"Failed Actions\": \", \".join(verdict[\"failed_actions\"\ + ]) or \"None\",\n \"Skipped Actions\": \", \".join(verdict[\"skipped_actions\"]) or \"None\",\n \ + \ \"Residual Risk\": verdict[\"residual_risk\"],\n }],\n )\n\n return_results(CommandResults(\n\ + \ readable_output=readable,\n outputs_prefix=OUTPUT_PREFIX,\n outputs=verdict,\n \ + \ ))\n\n except Exception as ex:\n demisto.error(traceback.format_exc())\n return_error(f\"Failed to build\ + \ compromised user containment verdict. Error: {ex}\")\n\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"\ + ):\n main()" +type: python +tags: [] +comment: Combines AD, Entra ID, session, MFA, password, and note/action statuses into one final compromised user containment + verdict. +enabled: true +args: +- supportedModules: [] + name: ad_status_json + description: Optional JSON or string containing Active Directory containment status. +- supportedModules: [] + name: entra_status_json + description: Optional JSON or string containing Entra ID containment status. +- supportedModules: [] + name: action_status_json + description: Optional JSON or string/list containing additional containment action statuses such as sessions, MFA, password + reset, or analyst notes. +outputs: +- contextPath: CompromisedUserContainment.Verdict.containment_success + description: True only when at least one identity platform was contained successfully and no critical containment action + failed. + type: boolean +- contextPath: CompromisedUserContainment.Verdict.status + description: Final containment verdict status. Possible values include success, partial_success, failed, or skipped. + type: string +- contextPath: CompromisedUserContainment.Verdict.residual_risk + description: Residual risk statement built from failed, skipped, or unknown actions. + type: string +- contextPath: CompromisedUserContainment.Verdict.failed_actions + description: List of failed containment actions. + type: unknown +- contextPath: CompromisedUserContainment.Verdict.successful_actions + description: List of successful containment actions. + type: unknown +- contextPath: CompromisedUserContainment.Verdict.skipped_actions + description: List of skipped containment actions. + type: unknown +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.13.84405 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/CollectCompromisedUserContext/CollectCompromisedUserContext.yml b/Packs/soc-framework-nist-ir/Scripts/CollectCompromisedUserContext/CollectCompromisedUserContext.yml new file mode 100644 index 00000000..52f2c54f --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/CollectCompromisedUserContext/CollectCompromisedUserContext.yml @@ -0,0 +1,83 @@ +commonfields: + id: CollectCompromisedUserContext + version: -1 +name: CollectCompromisedUserContext +script: "import traceback\nfrom typing import Any\n\n\nSCRIPT_NAME = \"CollectCompromisedUserContext\"\n\nCMD_ENTRA_GET_GROUPS\ + \ = \"msgraph-user-get-groups\"\nCMD_ENTRA_GET_MANAGER = \"msgraph-user-get-manager\"\nCMD_ENTRA_GET_DIRECT_REPORTS = \"\ + msgraph-direct-reports\"\nCMD_ENTRA_GET_DEVICES = \"msgraph-user-owned-devices-list\"\n\n\ndef arg_to_str(value: Any) ->\ + \ str:\n if value is None:\n return \"\"\n return str(value).strip()\n\n\ndef parse_args(args: dict) -> tuple[str,\ + \ str]:\n entra_user_id = arg_to_str(args.get(\"entra_user_id\"))\n ad_sam_account_name = arg_to_str(args.get(\"ad_sam_account_name\"\ + ))\n\n if not entra_user_id and not ad_sam_account_name:\n raise DemistoException(\"Either entra_user_id or ad_sam_account_name\ + \ must be provided.\")\n\n return entra_user_id, ad_sam_account_name\n\n\ndef extract_contents(result: list) -> Any:\n\ + \ if not result:\n return []\n\n contents = result[0].get(\"Contents\")\n if contents is None:\n \ + \ contents = result[0].get(\"EntryContext\")\n if contents is None:\n contents = result\n\n return contents\n\ + \n\ndef run_graph_command(command: str, command_args: dict, label: str, errors: list) -> Any:\n try:\n result\ + \ = demisto.executeCommand(command, command_args)\n\n if is_error(result):\n errors.append({\n \ + \ \"Source\": label,\n \"Command\": command,\n \"Error\": get_error(result)\n \ + \ })\n return []\n\n return extract_contents(result)\n\n except Exception as ex:\n errors.append({\n\ + \ \"Source\": label,\n \"Command\": command,\n \"Error\": str(ex)\n })\n \ + \ return []\n\n\ndef collect_entra_context(entra_user_id: str, errors: list) -> dict:\n command_args = {\n \"\ + user_id\": entra_user_id\n }\n\n return {\n \"Groups\": run_graph_command(\n CMD_ENTRA_GET_GROUPS,\n\ + \ command_args,\n \"Entra Groups\",\n errors\n ),\n \"Manager\": run_graph_command(\n\ + \ CMD_ENTRA_GET_MANAGER,\n command_args,\n \"Entra Manager\",\n errors\n \ + \ ),\n \"DirectReports\": run_graph_command(\n CMD_ENTRA_GET_DIRECT_REPORTS,\n command_args,\n\ + \ \"Entra Direct Reports\",\n errors\n ),\n \"Devices\": run_graph_command(\n \ + \ CMD_ENTRA_GET_DEVICES,\n command_args,\n \"Entra Owned Devices\",\n errors\n \ + \ )\n }\n\n\ndef build_readable_output(context: dict) -> str:\n entra = context.get(\"Entra\", {})\n\n lines\ + \ = [\n \"### Compromised User Context Collection\",\n \"\",\n f\"- Entra groups collected: {len(entra.get('Groups')\ + \ or []) if isinstance(entra.get('Groups'), list) else 'Yes'}\",\n f\"- Manager collected: {'Yes' if entra.get('Manager')\ + \ else 'No'}\",\n f\"- Direct reports collected: {len(entra.get('DirectReports') or []) if isinstance(entra.get('DirectReports'),\ + \ list) else 'Yes'}\",\n f\"- Devices collected: {len(entra.get('Devices') or []) if isinstance(entra.get('Devices'),\ + \ list) else 'Yes'}\",\n f\"- AD SAMAccountName preserved: {'Yes' if context.get('AD', {}).get('SAMAccountName')\ + \ else 'No'}\",\n f\"- Enrichment errors: {len(context.get('EnrichmentErrors') or [])}\"\n ]\n\n if context.get(\"\ + EnrichmentErrors\"):\n lines.extend([\n \"\",\n tableToMarkdown(\n \"Enrichment\ + \ Errors\",\n context.get(\"EnrichmentErrors\"),\n headers=[\"Source\", \"Command\", \"Error\"\ + ]\n )\n ])\n\n return \"\\n\".join(lines)\n\n\ndef main():\n try:\n entra_user_id, ad_sam_account_name\ + \ = parse_args(demisto.args())\n\n errors = []\n\n context = {\n \"Entra\": {\n \ + \ \"Groups\": [],\n \"Manager\": {},\n \"DirectReports\": [],\n \"Devices\"\ + : []\n },\n \"AD\": {\n \"SAMAccountName\": ad_sam_account_name\n },\n \ + \ \"EnrichmentErrors\": errors\n }\n\n if entra_user_id:\n context[\"Entra\"] = collect_entra_context(entra_user_id,\ + \ errors)\n\n readable_output = build_readable_output(context)\n\n return_results(CommandResults(\n \ + \ readable_output=readable_output,\n outputs_prefix=\"CompromisedUserContext\",\n outputs=context\n\ + \ ))\n\n except Exception as ex:\n demisto.error(traceback.format_exc())\n return_error(f\"{SCRIPT_NAME}\ + \ failed. Error: {str(ex)}\")\n\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"):\n main()" +type: python +tags: +- compromised-user +- enrichment +comment: Collects Entra and AD enrichment context for a confirmed compromised user before containment. +enabled: true +args: +- supportedModules: [] + name: entra_user_id + description: Optional Entra ID / Azure AD user ID. If provided, the automation attempts to collect groups, manager, direct + reports, and owned or registered devices. + type: String +- supportedModules: [] + name: ad_sam_account_name + description: Optional Active Directory SAMAccountName. If provided, it is preserved in the output context. + type: String +outputs: +- contextPath: CompromisedUserContext.Entra.Groups + description: Entra groups collected for the compromised user. + type: unknown +- contextPath: CompromisedUserContext.Entra.Manager + description: Manager collected from Entra for the compromised user. + type: unknown +- contextPath: CompromisedUserContext.Entra.DirectReports + description: Direct reports collected from Entra for the compromised user. + type: unknown +- contextPath: CompromisedUserContext.Entra.Devices + description: Owned or registered devices collected from Entra for the compromised user. + type: unknown +- contextPath: CompromisedUserContext.AD.SAMAccountName + description: Active Directory SAMAccountName for the compromised user. + type: string +- contextPath: CompromisedUserContext.EnrichmentErrors + description: Non-fatal enrichment errors encountered while collecting user context. + type: unknown +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.13.89009 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/NormalizeCompromisedUserIdentifier/NormalizeCompromisedUserIdentifier.yml b/Packs/soc-framework-nist-ir/Scripts/NormalizeCompromisedUserIdentifier/NormalizeCompromisedUserIdentifier.yml new file mode 100644 index 00000000..ead3e81f --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/NormalizeCompromisedUserIdentifier/NormalizeCompromisedUserIdentifier.yml @@ -0,0 +1,107 @@ +commonfields: + id: NormalizeCompromisedUserIdentifier + version: -1 +name: NormalizeCompromisedUserIdentifier +script: "import re\nimport traceback\nfrom typing import Any\n\n\nOUTPUT_PREFIX = \"AccountContainment.NormalizedUser\"\n\n\ + \ndef arg_to_bool(value: Any, default: bool = False) -> bool:\n if value is None:\n return default\n if isinstance(value,\ + \ bool):\n return value\n return str(value).strip().lower() in (\"true\", \"yes\", \"y\", \"1\")\n\n\ndef normalize_whitespace(value:\ + \ Any) -> str:\n return \" \".join(str(value or \"\").strip().split())\n\n\ndef detect_identifier_type(identifier: str)\ + \ -> str:\n if re.match(r\"(?i)^\\s*(CN|OU|DC|DN|UID)=\", identifier) and \",\" in identifier:\n return \"distinguished_name\"\ + \n\n if \"\\\\\" in identifier and re.match(r\"^[^\\\\]+\\\\[^\\\\]+$\", identifier):\n return \"domain_sam\"\n\ + \n if \"@\" in identifier:\n return \"upn_or_email\"\n\n return \"sam_or_username\"\n\n\ndef extract_dn_common_name(dn:\ + \ str) -> str:\n match = re.search(r\"(?i)(?:^|,)\\s*CN=([^,]+)\", dn)\n return match.group(1).strip() if match else\ + \ \"\"\n\n\ndef parse_args(args: dict[str, Any]) -> dict[str, Any]:\n user_identifier = normalize_whitespace(args.get(\"\ + user_identifier\", \"\"))\n default_upn_suffix = normalize_whitespace(args.get(\"default_upn_suffix\", \"\"))\n\n \ + \ if not user_identifier:\n raise DemistoException(\"user_identifier is required.\")\n\n return {\n \"\ + user_identifier\": user_identifier,\n \"allow_ambiguous\": arg_to_bool(args.get(\"allow_ambiguous\"), False),\n \ + \ \"default_upn_suffix\": default_upn_suffix.lower().lstrip(\"@\"),\n }\n\n\ndef build_upn(username: str, default_upn_suffix:\ + \ str) -> str:\n username = username.strip().lower()\n if default_upn_suffix:\n return f\"{username}@{default_upn_suffix}\"\ + \n return username\n\n\ndef normalize_user_identifier(\n user_identifier: str,\n allow_ambiguous: bool,\n default_upn_suffix:\ + \ str,\n) -> dict[str, Any]:\n identifier_type = detect_identifier_type(user_identifier)\n normalized_original = user_identifier.strip()\n\ + \n ambiguous = False\n ambiguity_reason = \"\"\n\n normalized = \"\"\n sam_account_name = \"\"\n upn = \"\ + \"\n ad_lookup_value = \"\"\n entra_lookup_value = \"\"\n\n if identifier_type == \"upn_or_email\":\n upn\ + \ = normalized_original.lower()\n sam_account_name = upn.split(\"@\", 1)[0]\n normalized = upn\n\n \ + \ ad_lookup_value = sam_account_name\n entra_lookup_value = upn\n\n elif identifier_type == \"domain_sam\":\n\ + \ domain, sam = normalized_original.split(\"\\\\\", 1)\n domain = domain.upper().strip()\n sam_account_name\ + \ = sam.strip()\n\n upn = build_upn(sam_account_name, default_upn_suffix)\n normalized = f\"{domain}\\\\{sam_account_name}\"\ + \n\n ad_lookup_value = sam_account_name\n entra_lookup_value = upn\n\n if not default_upn_suffix:\n\ + \ ambiguous = True\n ambiguity_reason = (\n \"domain\\\\username was provided but no\ + \ default_upn_suffix was provided. \"\n \"UPN is best-effort only.\"\n )\n\n elif identifier_type\ + \ == \"distinguished_name\":\n cn = extract_dn_common_name(normalized_original)\n\n sam_account_name = cn\ + \ or \"\"\n upn = build_upn(sam_account_name, default_upn_suffix) if sam_account_name else \"\"\n normalized\ + \ = cn or normalized_original\n\n ad_lookup_value = sam_account_name or normalized_original\n entra_lookup_value\ + \ = upn or normalized_original.lower()\n\n ambiguous = True\n ambiguity_reason = (\n \"Distinguished\ + \ name was provided. CN was extracted as a best-effort SAMAccountName, \"\n \"but authoritative SAMAccountName\ + \ should be resolved from AD when possible.\"\n )\n\n else:\n sam_account_name = normalized_original.strip()\n\ + \ upn = build_upn(sam_account_name, default_upn_suffix)\n normalized = sam_account_name\n\n ad_lookup_value\ + \ = sam_account_name\n entra_lookup_value = upn\n\n if not default_upn_suffix:\n ambiguous = True\n\ + \ ambiguity_reason = (\n \"Username/SAMAccountName was provided but no default_upn_suffix was\ + \ provided. \"\n \"UPN is best-effort only.\"\n )\n\n if not allow_ambiguous:\n \ + \ raise DemistoException(\n f\"Ambiguous user identifier: {user_identifier}. \"\n \ + \ \"Provide default_upn_suffix or set allow_ambiguous=true.\"\n )\n\n return {\n \"\ + Original\": user_identifier,\n \"Normalized\": normalized,\n \"IdentifierType\": identifier_type,\n \ + \ \"SAMAccountName\": sam_account_name,\n \"UPN\": upn,\n \"ADLookupValue\": ad_lookup_value,\n \"\ + EntraLookupValue\": entra_lookup_value,\n \"Ambiguous\": ambiguous,\n \"AmbiguityReason\": ambiguity_reason,\n\ + \ }\n\n\ndef main():\n try:\n args = parse_args(demisto.args())\n\n result = normalize_user_identifier(\n\ + \ args[\"user_identifier\"],\n args[\"allow_ambiguous\"],\n args[\"default_upn_suffix\"\ + ],\n )\n\n readable_output = tableToMarkdown(\n \"Normalized Compromised User Identifier\",\n \ + \ result,\n headers=[\n \"Original\",\n \"Normalized\",\n \ + \ \"IdentifierType\",\n \"SAMAccountName\",\n \"UPN\",\n \"ADLookupValue\"\ + ,\n \"EntraLookupValue\",\n \"Ambiguous\",\n \"AmbiguityReason\",\n \ + \ ],\n removeNull=True,\n )\n\n return_results(CommandResults(\n outputs_prefix=OUTPUT_PREFIX,\n\ + \ outputs_key_field=\"Original\",\n outputs=result,\n readable_output=readable_output,\n\ + \ raw_response=result,\n ))\n\n except Exception as ex:\n demisto.error(traceback.format_exc())\n\ + \ return_error(f\"Failed to normalize compromised user identifier. Error: {ex}\")\n\n\nif __name__ in (\"__main__\"\ + , \"__builtin__\", \"builtins\"):\n main()" +type: python +tags: +- Compromised User +- Account Containment +comment: Normalize a compromised user identifier before account containment. Accepts email, UPN, SAMAccountName, username, + domain\\username, or distinguished name and returns lookup values for downstream AD and Microsoft Graph tasks. Does not + call external integrations. +enabled: true +args: +- supportedModules: [] + name: user_identifier + required: true + description: Email, UPN, SAMAccountName, username, domain\\username, or distinguished name. + type: string +- supportedModules: [] + name: allow_ambiguous + description: Whether to allow best-effort output when the identifier is ambiguous, such as a local-only username without + a domain or UPN suffix. + defaultValue: 'true' + type: boolean +- supportedModules: [] + name: default_upn_suffix + default: true + description: Optional default UPN suffix to append when user_identifier is only a username/SAMAccountName, for example contoso.com. + Leave blank to avoid guessing a domain. + defaultValue: '' +outputs: +- contextPath: AccountContainment.NormalizedUser.Original + description: Original user identifier argument after whitespace normalization. + type: string +- contextPath: AccountContainment.NormalizedUser.Normalized + description: Display-safe normalized user identifier. + type: string +- contextPath: AccountContainment.NormalizedUser.IdentifierType + description: Detected identifier type. + type: string +- contextPath: AccountContainment.NormalizedUser.Ambiguous + description: Whether the identifier required best-effort handling. + type: boolean +- contextPath: AccountContainment.NormalizedUser.AmbiguityReason + description: Explanation when the identifier is ambiguous. + type: string +- contextPath: AccountContainment.NormalizedUser.SAMAccountName + description: Username/SAMAccountName value for Active Directory lookups such as ad-get-user. + type: string +- contextPath: AccountContainment.NormalizedUser.UPN + description: UPN value for Microsoft Entra ID or MS Graph tasks. +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.14.92207 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/ProtectedAccountContainmentGuard/ProtectedAccountContainmentGuard.yml b/Packs/soc-framework-nist-ir/Scripts/ProtectedAccountContainmentGuard/ProtectedAccountContainmentGuard.yml new file mode 100644 index 00000000..10b72a6d --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/ProtectedAccountContainmentGuard/ProtectedAccountContainmentGuard.yml @@ -0,0 +1,97 @@ +commonfields: + id: ProtectedAccountContainmentGuard + version: -1 +name: ProtectedAccountContainmentGuard +script: "import json\nimport re\nimport traceback\nfrom typing import Any\n\n\nDEFAULT_PROTECTED_KEYWORDS = [\n \"admin\"\ + ,\n \"domain admins\",\n \"enterprise admins\",\n \"global administrator\",\n \"privileged\",\n \"break glass\"\ + ,\n \"service account\",\n \"svc\",\n]\n\n\ndef arg_to_bool(value: Any, default: bool = False) -> bool:\n if value\ + \ is None or value == \"\":\n return default\n if isinstance(value, bool):\n return value\n return str(value).strip().lower()\ + \ in (\"true\", \"yes\", \"y\", \"1\")\n\n\ndef normalize_to_list(value: Any) -> list[str]:\n if value is None or value\ + \ == \"\":\n return []\n\n if isinstance(value, list):\n return [str(item).strip() for item in value if\ + \ str(item).strip()]\n\n if isinstance(value, dict):\n return [json.dumps(value, sort_keys=True)]\n\n if isinstance(value,\ + \ str):\n raw = value.strip()\n if not raw:\n return []\n\n try:\n parsed = json.loads(raw)\n\ + \ if isinstance(parsed, list):\n return [str(item).strip() for item in parsed if str(item).strip()]\n\ + \ if isinstance(parsed, dict):\n values = []\n for key in (\"name\", \"displayName\"\ + , \"group\", \"groups\"):\n if key in parsed:\n values.extend(normalize_to_list(parsed.get(key)))\n\ + \ return values or [json.dumps(parsed, sort_keys=True)]\n return [str(parsed).strip()]\n \ + \ except Exception:\n pass\n\n parts = re.split(r\"[\\n,;|]+\", raw)\n return [part.strip() for\ + \ part in parts if part.strip()]\n\n return [str(value).strip()]\n\n\ndef normalize_keywords(value: Any) -> list[str]:\n\ + \ provided = normalize_to_list(value)\n keywords = provided if provided else DEFAULT_PROTECTED_KEYWORDS\n return\ + \ sorted({keyword.lower().strip() for keyword in keywords if keyword.strip()})\n\n\ndef detect_service_account_patterns(user_identifier:\ + \ str) -> list[str]:\n checks = {\n \"username starts with svc_\": r\"(^|[\\\\/@])svc_\",\n \"username\ + \ starts with svc-\": r\"(^|[\\\\/@])svc-\",\n \"username contains service\": r\"service\",\n \"username contains\ + \ breakglass\": r\"break\\s*glass|breakglass\",\n }\n\n matches = []\n lowered = user_identifier.lower()\n for\ + \ reason, pattern in checks.items():\n if re.search(pattern, lowered):\n matches.append(reason)\n return\ + \ matches\n\n\ndef find_keyword_matches(groups: list[str], keywords: list[str]) -> list[str]:\n matched = []\n for\ + \ group in groups:\n group_lower = group.lower()\n if any(keyword in group_lower for keyword in keywords):\n\ + \ matched.append(group)\n return sorted(set(matched))\n\n\ndef parse_args(args: dict[str, Any]) -> dict[str,\ + \ Any]:\n user_identifier = str(args.get(\"user_identifier\", \"\")).strip()\n if not user_identifier:\n raise\ + \ ValueError(\"user_identifier is required.\")\n\n return {\n \"user_identifier\": user_identifier,\n \"\ + ad_groups\": normalize_to_list(args.get(\"ad_groups\")),\n \"entra_groups\": normalize_to_list(args.get(\"entra_groups\"\ + )),\n \"protected_keywords\": normalize_keywords(args.get(\"protected_keywords\")),\n \"require_approval_for_vip\"\ + : arg_to_bool(args.get(\"require_approval_for_vip\"), True),\n }\n\n\ndef main():\n try:\n args = parse_args(demisto.args())\n\ + \n all_groups = args[\"ad_groups\"] + args[\"entra_groups\"]\n matched_groups = find_keyword_matches(all_groups,\ + \ args[\"protected_keywords\"])\n reasons = []\n\n if matched_groups:\n reasons.append(\"User is\ + \ a member of one or more groups matching protected keywords.\")\n\n service_patterns = detect_service_account_patterns(args[\"\ + user_identifier\"])\n reasons.extend(service_patterns)\n\n is_protected = bool(matched_groups or service_patterns)\n\ + \n if is_protected and args[\"require_approval_for_vip\"]:\n recommended_action = \"require_manual_approval_before_containment\"\ + \n elif is_protected:\n recommended_action = \"treat_as_protected_account\"\n else:\n \ + \ recommended_action = \"automated_containment_allowed_by_this_check\"\n\n outputs = {\n \"user_identifier\"\ + : args[\"user_identifier\"],\n \"is_protected_account\": is_protected,\n \"reasons\": reasons,\n \ + \ \"matched_groups\": matched_groups,\n \"recommended_action\": recommended_action,\n }\n\n\ + \ readable = tableToMarkdown(\n \"Protected Account Containment Guard\",\n [{\n \ + \ \"User\": args[\"user_identifier\"],\n \"Protected\": is_protected,\n \"Matched Groups\"\ + : \", \".join(matched_groups) if matched_groups else \"None\",\n \"Reasons\": \"; \".join(reasons) if reasons\ + \ else \"No protected indicators found\",\n \"Recommended Action\": recommended_action,\n }],\n\ + \ )\n\n return_results(CommandResults(\n readable_output=readable,\n outputs_prefix=\"\ + ProtectedAccountGuard\",\n outputs=outputs,\n raw_response=outputs,\n ))\n\n except Exception\ + \ as ex:\n demisto.error(traceback.format_exc())\n return_error(f\"Failed to evaluate protected account status.\ + \ Error: {ex}\")\n\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"):\n main()" +type: python +tags: +- condition +- containment +comment: Determines whether a user should be treated as privileged or protected before automated containment. This automation + never executes containment actions. +enabled: true +args: +- supportedModules: [] + name: user_identifier + required: true + description: Username, UPN, SAM account name, or other user identifier to evaluate. +- supportedModules: [] + name: ad_groups + description: AD group membership data. Accepts JSON array, list, comma/newline/semicolon-separated string, or plain string. +- supportedModules: [] + name: entra_groups + description: Entra ID group membership data. Accepts JSON array, list, comma/newline/semicolon-separated string, or plain + string. +- supportedModules: [] + name: protected_keywords + description: Keywords used to identify protected groups. Defaults include admin, domain admins, enterprise admins, global + administrator, privileged, break glass, service account, and svc. +- supportedModules: [] + name: require_approval_for_vip + description: When true, protected accounts return a recommendation to require manual approval before containment. + defaultValue: 'true' +outputs: +- contextPath: ProtectedAccountGuard.user_identifier + description: User identifier evaluated by the automation. + type: string +- contextPath: ProtectedAccountGuard.is_protected_account + description: Whether the user matched protected or privileged indicators. + type: boolean +- contextPath: ProtectedAccountGuard.reasons + description: Reasons the user was treated as protected. + type: unknown +- contextPath: ProtectedAccountGuard.matched_groups + description: AD or Entra groups that matched protected keywords. + type: unknown +- contextPath: ProtectedAccountGuard.recommended_action + description: Recommended containment decision for downstream playbook logic. + type: string +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.13.89009 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/ResetEntraAuthenticationMethods/ResetEntraAuthenticationMethods.yml b/Packs/soc-framework-nist-ir/Scripts/ResetEntraAuthenticationMethods/ResetEntraAuthenticationMethods.yml new file mode 100644 index 00000000..e953afb4 --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/ResetEntraAuthenticationMethods/ResetEntraAuthenticationMethods.yml @@ -0,0 +1,178 @@ +commonfields: + id: ResetEntraAuthenticationMethods + version: -1 +name: ResetEntraAuthenticationMethods +script: "import traceback\nfrom typing import Any\n\n\nMETHOD_FAMILIES = [\n {\n \"key\": \"authenticator_methods\"\ + ,\n \"display\": \"Authenticator\",\n \"enabled_arg\": \"delete_authenticator_methods\",\n \"list_command\"\ + : \"msgraph-user-authenticator-method-list\",\n \"delete_command\": \"msgraph-user-authenticator-method-delete\"\ + ,\n },\n {\n \"key\": \"phone_methods\",\n \"display\": \"Phone\",\n \"enabled_arg\": \"delete_phone_methods\"\ + ,\n \"list_command\": \"msgraph-user-phone-method-list\",\n \"delete_command\": \"msgraph-user-phone-method-delete\"\ + ,\n },\n {\n \"key\": \"email_methods\",\n \"display\": \"Email\",\n \"enabled_arg\": \"delete_email_methods\"\ + ,\n \"list_command\": \"msgraph-user-email-method-list\",\n \"delete_command\": \"msgraph-user-email-method-delete\"\ + ,\n },\n {\n \"key\": \"fido2_methods\",\n \"display\": \"FIDO2\",\n \"enabled_arg\": \"delete_fido2_methods\"\ + ,\n \"list_command\": \"msgraph-user-fido2-method-list\",\n \"delete_command\": \"msgraph-user-fido2-method-delete\"\ + ,\n },\n {\n \"key\": \"software_oath_methods\",\n \"display\": \"Software OATH\",\n \"enabled_arg\"\ + : \"delete_software_oath_methods\",\n \"list_command\": \"msgraph-user-software-oath-method-list\",\n \"delete_command\"\ + : \"msgraph-user-software-oath-method-delete\",\n },\n {\n \"key\": \"windows_hello_methods\",\n \"\ + display\": \"Windows Hello\",\n \"enabled_arg\": \"delete_windows_hello_methods\",\n \"list_command\": \"\ + msgraph-user-windows-hello-method-list\",\n \"delete_command\": \"msgraph-user-windows-hello-method-delete\",\n \ + \ },\n {\n \"key\": \"tap_methods\",\n \"display\": \"Temporary Access Pass\",\n \"enabled_arg\"\ + : \"delete_tap_methods\",\n \"list_command\": \"msgraph-user-temp-access-pass-method-list\",\n \"delete_command\"\ + : \"msgraph-user-temp-access-pass-method-delete\",\n },\n]\n\n\ndef arg_to_bool(value: Any, default: bool = False) ->\ + \ bool:\n if value is None:\n return default\n if isinstance(value, bool):\n return value\n return\ + \ str(value).strip().lower() in {\"true\", \"yes\", \"y\", \"1\"}\n\n\ndef parse_args(args: dict[str, Any]) -> dict[str,\ + \ Any]:\n user_id = args.get(\"user_id\")\n if not user_id:\n raise DemistoException(\"Missing required argument:\ + \ user_id\")\n\n parsed = {\n \"user_id\": user_id,\n \"dry_run\": arg_to_bool(args.get(\"dry_run\"), False),\n\ + \ }\n\n for family in METHOD_FAMILIES:\n parsed[family[\"enabled_arg\"]] = arg_to_bool(args.get(family[\"enabled_arg\"\ + ]), True)\n\n return parsed\n\n\ndef execute_command(command: str, args: dict[str, Any]) -> list[dict[str, Any]]:\n \ + \ result = demisto.executeCommand(command, args)\n if is_error(result):\n raise DemistoException(get_error(result))\n\ + \ return result\n\n\ndef extract_items(command_result: list[dict[str, Any]]) -> list[dict[str, Any]]:\n items = []\n\ + \n for entry in command_result:\n contents = entry.get(\"Contents\")\n if isinstance(contents, list):\n\ + \ items.extend([item for item in contents if isinstance(item, dict)])\n elif isinstance(contents, dict):\n\ + \ if isinstance(contents.get(\"value\"), list):\n items.extend([item for item in contents.get(\"\ + value\") if isinstance(item, dict)])\n elif isinstance(contents.get(\"Value\"), list):\n items.extend([item\ + \ for item in contents.get(\"Value\") if isinstance(item, dict)])\n else:\n items.append(contents)\n\ + \n return items\n\n\ndef get_method_id(method: dict[str, Any]) -> str | None:\n for key in (\"id\", \"method_id\"\ + , \"authenticationMethodId\"):\n value = method.get(key)\n if value:\n return str(value)\n return\ + \ None\n\n\ndef process_family(user_id: str, family: dict[str, str], enabled: bool, dry_run: bool) -> dict[str, Any]:\n\ + \ summary = {\n \"family\": family[\"display\"],\n \"key\": family[\"key\"],\n \"list_command\"\ + : family[\"list_command\"],\n \"delete_command\": family[\"delete_command\"],\n \"enabled\": enabled,\n \ + \ \"dry_run\": dry_run,\n \"listed\": 0,\n \"deleted\": 0,\n \"skipped\": 0,\n \"failed\"\ + : 0,\n \"status\": \"success\",\n \"methods\": [],\n \"errors\": [],\n }\n\n if not enabled:\n\ + \ summary[\"status\"] = \"skipped\"\n summary[\"skipped\"] = 1\n return summary\n\n try:\n \ + \ list_result = execute_command(family[\"list_command\"], {\"user\": user_id})\n methods = extract_items(list_result)\n\ + \ summary[\"listed\"] = len(methods)\n except Exception as ex:\n summary[\"status\"] = \"failure\"\n \ + \ summary[\"failed\"] = 1\n summary[\"errors\"].append(f\"List failed: {ex}\")\n return summary\n\n \ + \ for method in methods:\n method_id = get_method_id(method)\n method_record = {\n \"id\": method_id,\n\ + \ \"displayName\": method.get(\"displayName\") or method.get(\"name\"),\n \"type\": method.get(\"\ + @odata.type\") or method.get(\"type\"),\n \"deleted\": False,\n \"dry_run\": dry_run,\n \ + \ \"error\": None,\n }\n\n if not method_id:\n summary[\"failed\"] += 1\n method_record[\"\ + error\"] = \"Unable to determine method ID.\"\n summary[\"errors\"].append(\"Delete skipped: unable to determine\ + \ method ID.\")\n summary[\"methods\"].append(method_record)\n continue\n\n if dry_run:\n \ + \ summary[\"skipped\"] += 1\n summary[\"methods\"].append(method_record)\n continue\n\n\ + \ try:\n execute_command(\n family[\"delete_command\"],\n {\n \ + \ \"user\": user_id,\n \"method_id\": method_id,\n },\n )\n \ + \ summary[\"deleted\"] += 1\n method_record[\"deleted\"] = True\n except Exception as ex:\n \ + \ summary[\"failed\"] += 1\n method_record[\"error\"] = str(ex)\n summary[\"errors\"].append(f\"\ + Delete failed for method {method_id}: {ex}\")\n\n summary[\"methods\"].append(method_record)\n\n if summary[\"\ + failed\"] > 0 and summary[\"deleted\"] > 0:\n summary[\"status\"] = \"partial_failure\"\n elif summary[\"failed\"\ + ] > 0:\n summary[\"status\"] = \"failure\"\n elif dry_run:\n summary[\"status\"] = \"dry_run\"\n else:\n\ + \ summary[\"status\"] = \"success\"\n\n return summary\n\n\ndef build_readable_output(user_id: str, dry_run: bool,\ + \ family_results: list[dict[str, Any]]) -> str:\n table_rows = [\n {\n \"Family\": item[\"family\"\ + ],\n \"Status\": item[\"status\"],\n \"Enabled\": item[\"enabled\"],\n \"Dry Run\": item[\"\ + dry_run\"],\n \"Listed\": item[\"listed\"],\n \"Deleted\": item[\"deleted\"],\n \"Skipped\"\ + : item[\"skipped\"],\n \"Failed\": item[\"failed\"],\n }\n for item in family_results\n ]\n\n\ + \ readable = tableToMarkdown(\n f\"Reset Entra Authentication Methods for {user_id}\",\n table_rows,\n\ + \ removeNull=True,\n )\n\n errors = []\n for item in family_results:\n for error in item.get(\"errors\"\ + , []):\n errors.append(f\"- {item['family']}: {error}\")\n\n if dry_run:\n readable += \"\\n\\nDry\ + \ run enabled. No authentication methods were deleted.\"\n\n if errors:\n readable += \"\\n\\nPartial failure\ + \ details:\\n\" + \"\\n\".join(errors)\n\n return readable\n\n\ndef main():\n try:\n args = parse_args(demisto.args())\n\ + \ user_id = args[\"user_id\"]\n dry_run = args[\"dry_run\"]\n\n family_results = []\n for family\ + \ in METHOD_FAMILIES:\n family_results.append(\n process_family(\n user_id=user_id,\n\ + \ family=family,\n enabled=args[family[\"enabled_arg\"]],\n dry_run=dry_run,\n\ + \ )\n )\n\n total_failed = sum(item[\"failed\"] for item in family_results)\n total_deleted\ + \ = sum(item[\"deleted\"] for item in family_results)\n\n overall_status = \"success\"\n if dry_run:\n \ + \ overall_status = \"dry_run\"\n elif total_failed and total_deleted:\n overall_status = \"partial_failure\"\ + \n elif total_failed and not total_deleted:\n overall_status = \"failure\"\n\n outputs = {\n \ + \ \"UserID\": user_id,\n \"DryRun\": dry_run,\n \"Status\": overall_status,\n \ + \ \"TotalDeleted\": total_deleted,\n \"TotalFailed\": total_failed,\n \"Families\": family_results,\n\ + \ }\n\n return_results(\n CommandResults(\n outputs_prefix=\"ResetEntraAuthenticationMethods\"\ + ,\n outputs_key_field=\"UserID\",\n outputs=outputs,\n readable_output=build_readable_output(user_id,\ + \ dry_run, family_results),\n )\n )\n\n except Exception as ex:\n demisto.error(traceback.format_exc())\n\ + \ return_error(f\"Failed to reset Entra authentication methods. Error: {ex}\")\n\n\nif __name__ in (\"__main__\"\ + , \"__builtin__\", \"builtins\"):\n main()" +type: python +tags: [] +comment: Lists and deletes supported Entra authentication/MFA methods for a compromised user using the MSGraph User integration. + Partial failures are reported but do not stop other method families. +enabled: true +args: +- supportedModules: [] + name: user_id + required: true + description: Entra user ID or UPN. +- supportedModules: [] + name: delete_authenticator_methods + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: Delete Microsoft Authenticator methods. + defaultValue: 'true' +- supportedModules: [] + name: delete_phone_methods + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: Delete phone authentication methods. + defaultValue: 'true' +- supportedModules: [] + name: delete_email_methods + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: Delete email authentication methods. + defaultValue: 'true' +- supportedModules: [] + name: delete_fido2_methods + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: Delete FIDO2 authentication methods. + defaultValue: 'true' +- supportedModules: [] + name: delete_software_oath_methods + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: Delete software OATH authentication methods. + defaultValue: 'true' +- supportedModules: [] + name: delete_windows_hello_methods + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: Delete Windows Hello authentication methods. + defaultValue: 'true' +- supportedModules: [] + name: delete_tap_methods + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: Delete Temporary Access Pass methods. + defaultValue: 'true' +- supportedModules: [] + name: dry_run + auto: PREDEFINED + predefined: + - 'true' + - 'false' + description: List methods and report intended deletions without deleting anything. + defaultValue: 'true' +outputs: +- contextPath: ResetEntraAuthenticationMethods.UserID + description: Entra user ID or UPN processed. + type: string +- contextPath: ResetEntraAuthenticationMethods.Status + description: Overall status. + type: string +- contextPath: ResetEntraAuthenticationMethods.TotalDeleted + description: Total number of authentication methods deleted. + type: number +- contextPath: ResetEntraAuthenticationMethods.TotalFailed + description: Total number of failed list/delete operations. + type: number +- contextPath: ResetEntraAuthenticationMethods.Families + description: Per-method-family result details. + type: unknown +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.14.92207 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/SetCompromisedUserContainmentActionStatus/SetCompromisedUserContainmentActionStatus.yml b/Packs/soc-framework-nist-ir/Scripts/SetCompromisedUserContainmentActionStatus/SetCompromisedUserContainmentActionStatus.yml new file mode 100644 index 00000000..9e18f1c2 --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/SetCompromisedUserContainmentActionStatus/SetCompromisedUserContainmentActionStatus.yml @@ -0,0 +1,64 @@ +commonfields: + id: SetCompromisedUserContainmentActionStatus + version: -1 +name: SetCompromisedUserContainmentActionStatus +script: "import traceback\nfrom datetime import datetime, timezone\nfrom typing import Any\n\n\nALLOWED_STATUSES = {\n \ + \ \"Success\",\n \"Failed\",\n \"Partial\",\n \"Skipped\",\n \"Blocked\",\n \"Submitted\",\n \"Unknown\"\ + ,\n}\n\n\ndef parse_args(args: dict[str, Any]) -> tuple[str, str, str]:\n action = str(args.get(\"action\", \"\")).strip()\n\ + \ status = str(args.get(\"status\", \"\")).strip()\n detail = str(args.get(\"detail\", \"\")).strip()\n\n if not\ + \ action:\n raise DemistoException(\"Missing required argument: action\")\n\n if not status:\n raise DemistoException(\"\ + Missing required argument: status\")\n\n if status not in ALLOWED_STATUSES:\n raise DemistoException(\n \ + \ f\"Invalid status '{status}'. Allowed values: {', '.join(sorted(ALLOWED_STATUSES))}\"\n )\n\n return action,\ + \ status, detail\n\n\ndef main():\n try:\n action, status, detail = parse_args(demisto.args())\n timestamp\ + \ = datetime.now(timezone.utc).isoformat()\n\n outputs = {\n \"CompromisedUserContainment\": {\n \ + \ \"Actions\": {\n action: {\n \"status\": status,\n \ + \ \"detail\": detail,\n \"timestamp\": timestamp,\n }\n \ + \ }\n }\n }\n\n readable = tableToMarkdown(\n \"Containment Action Status Updated\"\ + ,\n [{\n \"Action\": action,\n \"Status\": status,\n \"Detail\"\ + : detail,\n \"Timestamp\": timestamp,\n }],\n )\n\n return_results(CommandResults(\n\ + \ readable_output=readable,\n outputs=outputs,\n outputs_prefix=\"\",\n outputs_key_field=None,\n\ + \ ))\n\n except Exception as ex:\n demisto.error(traceback.format_exc())\n return_error(f\"Failed\ + \ to set containment action status. Error: {ex}\")\n\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"):\n\ + \ main()\n" +type: python +tags: +- compromised-user-containment +- Utility +comment: Sets a normalized compromised-user containment action status, detail, and timestamp in context. +enabled: true +args: +- supportedModules: [] + name: action + required: true + description: Name of the containment action to update. +- supportedModules: [] + name: status + required: true + auto: PREDEFINED + predefined: + - Success + - Failed + - Partial + - Skipped + - Blocked + - Submitted + - Unknown + description: Normalized containment action status. +- supportedModules: [] + name: detail + description: Optional supporting detail for the action status. +outputs: +- contextPath: CompromisedUserContainment.Actions..status + description: Normalized containment action status. + type: string +- contextPath: CompromisedUserContainment.Actions..detail + description: Optional detail for the containment action status. + type: string +- contextPath: CompromisedUserContainment.Actions..timestamp + description: UTC timestamp when the status was set. + type: date +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.13.84405 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/ValidateCompromisedUserContainmentInputs/ValidateCompromisedUserContainmentInputs.yml b/Packs/soc-framework-nist-ir/Scripts/ValidateCompromisedUserContainmentInputs/ValidateCompromisedUserContainmentInputs.yml new file mode 100644 index 00000000..eb30d897 --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/ValidateCompromisedUserContainmentInputs/ValidateCompromisedUserContainmentInputs.yml @@ -0,0 +1,65 @@ +commonfields: + id: ValidateCompromisedUserContainmentInputs + version: -1 +name: ValidateCompromisedUserContainmentInputs +script: "import traceback\nfrom typing import Any\n\n\nOUTPUT_PREFIX = \"CompromisedUser.InputValidation\"\n\n\ndef normalize_string(value:\ + \ Any) -> str:\n \"\"\"Return a trimmed string value, or an empty string when unset.\"\"\"\n if value is None:\n \ + \ return \"\"\n return str(value).strip()\n\n\ndef parse_args(args: dict[str, Any]) -> dict[str, Any]:\n \"\"\ + \"Validate and normalize script arguments.\"\"\"\n user_identifier = normalize_string(args.get(\"user_identifier\"))\n\ + \ reason = normalize_string(args.get(\"reason\"))\n strict_mode = argToBoolean(args.get(\"strict_mode\", True))\n\n\ + \ missing_fields = []\n if not user_identifier:\n missing_fields.append(\"user_identifier\")\n if not reason:\n\ + \ missing_fields.append(\"reason\")\n\n if strict_mode and missing_fields:\n missing = \", \".join(missing_fields)\n\ + \ return_error(f\"Missing required input(s): {missing}. Provide these values before continuing containment.\")\n\n\ + \ return {\n \"UserIdentifier\": user_identifier,\n \"Reason\": reason,\n \"StrictMode\": strict_mode,\n\ + \ \"Valid\": len(missing_fields) == 0,\n \"MissingFields\": missing_fields,\n }\n\n\ndef main():\n try:\n\ + \ result = parse_args(demisto.args())\n\n readable_output = \"Compromised user containment inputs validated\ + \ successfully.\"\n if not result.get(\"Valid\"):\n readable_output = (\n \"Compromised\ + \ user containment input validation completed with missing fields: \"\n f\"{', '.join(result.get('MissingFields',\ + \ []))}\"\n )\n\n return_results(\n CommandResults(\n outputs_prefix=OUTPUT_PREFIX,\n\ + \ outputs=result,\n readable_output=readable_output,\n )\n )\n\n except\ + \ Exception as ex:\n demisto.error(traceback.format_exc())\n return_error(f\"Failed to validate compromised\ + \ user containment inputs. Error: {ex}\")\n\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"):\n main()\n" +type: python +tags: +- compromised-user +- containment +- input-validation +comment: Validates required analyst inputs before a manual compromised user account containment playbook continues. +enabled: true +args: +- description: Compromised user email, UPN, SAMAccountName, username, or distinguished name. + name: user_identifier + required: true + supportedModules: [] + type: String +- description: Reason for containment or brief summary of why the account is believed compromised. + name: reason + required: true + supportedModules: [] + type: String +- defaultValue: 'true' + description: When true, fails if required values are missing or blank. + name: strict_mode + supportedModules: [] + type: Boolean +outputs: +- contextPath: CompromisedUser.InputValidation.UserIdentifier + description: Trimmed compromised user identifier. + type: string +- contextPath: CompromisedUser.InputValidation.Reason + description: Trimmed containment reason. + type: string +- contextPath: CompromisedUser.InputValidation.StrictMode + description: Whether strict validation mode was enabled. + type: boolean +- contextPath: CompromisedUser.InputValidation.Valid + description: Whether all required inputs were provided. + type: boolean +- contextPath: CompromisedUser.InputValidation.MissingFields + description: Required fields that were missing or blank. + type: string +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.12.8.1983910 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/VerifyADCompromisedUserContainment/VerifyADCompromisedUserContainment.yml b/Packs/soc-framework-nist-ir/Scripts/VerifyADCompromisedUserContainment/VerifyADCompromisedUserContainment.yml new file mode 100644 index 00000000..c94a0aea --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/VerifyADCompromisedUserContainment/VerifyADCompromisedUserContainment.yml @@ -0,0 +1,86 @@ +commonfields: + id: VerifyADCompromisedUserContainment + version: -1 +name: VerifyADCompromisedUserContainment +script: "import traceback\nfrom typing import Any, Optional\n\nAD_QUERY_COMMAND = \"ad-get-user\"\n\ndef parse_args(args:\ + \ dict[str, Any]) -> str:\n sam = args.get(\"sam_account_name\")\n if not sam or not str(sam).strip():\n raise\ + \ DemistoException(\"sam_account_name is required.\")\n return str(sam).strip()\n\ndef command_failed(result: Any) ->\ + \ bool:\n return is_error(result)\n\ndef first_entry_contents(result: list[Any]) -> dict[str, Any]:\n if not result:\n\ + \ return {}\n entry = result[0] or {}\n contents = entry.get(\"Contents\", {})\n if isinstance(contents,\ + \ list):\n return contents[0] if contents and isinstance(contents[0], dict) else {}\n return contents if isinstance(contents,\ + \ dict) else {}\n\ndef to_bool(value: Any) -> Optional[bool]:\n if value is None:\n return None\n if isinstance(value,\ + \ bool):\n return value\n if isinstance(value, str):\n lowered = value.strip().lower()\n if lowered\ + \ in (\"true\", \"yes\", \"1\", \"disabled\", \"expired\"):\n return True\n if lowered in (\"false\",\ + \ \"no\", \"0\", \"enabled\", \"not expired\"):\n return False\n return None\n\ndef pick_first(data: dict[str,\ + \ Any], keys: list[str]) -> Any:\n for key in keys:\n if key in data:\n return data.get(key)\n return\ + \ None\n\ndef query_ad_user(sam_account_name: str) -> tuple[dict[str, Any], list[str]]:\n errors = []\n result = demisto.executeCommand(AD_QUERY_COMMAND,\ + \ {\"sam_account_name\": sam_account_name})\n\n if command_failed(result):\n errors.append(get_error(result))\n\ + \ return {}, errors\n\n user = first_entry_contents(result)\n if not user:\n errors.append(\"AD query\ + \ returned no user data.\")\n return user, errors\n\ndef evaluate_user(user: dict[str, Any], errors: list[str]) -> dict[str,\ + \ Any]:\n disabled_raw = pick_first(user, [\n \"disabled\",\n \"account_disabled\",\n \"AccountDisabled\"\ + ,\n \"userAccountControlDisabled\",\n \"enabled\",\n \"Enabled\"\n ])\n\n disabled = to_bool(disabled_raw)\n\ + \ if disabled is None and str(disabled_raw).lower() in (\"enabled\", \"true\", \"false\"):\n disabled = not to_bool(disabled_raw)\n\ + \n enabled = to_bool(pick_first(user, [\"enabled\", \"Enabled\"]))\n if disabled is None and enabled is not None:\n\ + \ disabled = not enabled\n\n password_expired = to_bool(pick_first(user, [\n \"password_expired\",\n \ + \ \"PasswordExpired\",\n \"pwdLastSetZero\",\n \"must_change_password\",\n \"MustChangePassword\"\ + ,\n \"change_password_at_next_logon\",\n \"ChangePasswordAtNextLogon\"\n ]))\n\n details = []\n if\ + \ disabled is True:\n details.append(\"AD account appears disabled.\")\n elif disabled is False:\n details.append(\"\ + AD account does not appear disabled.\")\n else:\n details.append(\"Unable to determine AD disabled status from\ + \ returned fields.\")\n\n if password_expired is True:\n details.append(\"Password expiration/change-required\ + \ status appears present.\")\n elif password_expired is False:\n details.append(\"Password expiration/change-required\ + \ status does not appear present.\")\n else:\n details.append(\"Unable to determine password expiration/change-required\ + \ status from returned fields.\")\n\n if errors:\n details.append(\"Verification errors: \" + \" | \".join(errors))\n\ + \n if errors and not user:\n status = \"Unknown\"\n elif disabled is True and password_expired is True:\n \ + \ status = \"Success\"\n elif disabled is True or password_expired is True:\n status = \"Partial\"\n \ + \ elif disabled is False or password_expired is False:\n status = \"Failed\"\n else:\n status = \"Unknown\"\ + \n\n return {\n \"status\": status,\n \"disabled\": disabled,\n \"password_expired\": password_expired,\n\ + \ \"details\": \" \".join(details),\n \"ad_disable\": \"verified\" if disabled is True else \"not_verified\"\ + \ if disabled is False else \"unknown\",\n \"ad_password_expire\": \"verified\" if password_expired is True else\ + \ \"not_verified\" if password_expired is False else \"unknown\",\n }\n\ndef main():\n try:\n sam = parse_args(demisto.args())\n\ + \ user, errors = query_ad_user(sam)\n evaluation = evaluate_user(user, errors)\n\n outputs = {\n \ + \ \"CompromisedUserContainment\": {\n \"AD\": {\n \"status\": evaluation[\"status\"\ + ],\n \"disabled\": evaluation[\"disabled\"],\n \"password_expired\": evaluation[\"\ + password_expired\"],\n \"details\": evaluation[\"details\"],\n },\n \"\ + Actions\": {\n \"ad_disable\": evaluation[\"ad_disable\"],\n \"ad_password_expire\"\ + : evaluation[\"ad_password_expire\"],\n }\n }\n }\n\n readable = tableToMarkdown(\n\ + \ \"AD Compromised User Containment Verification\",\n [{\n \"sam_account_name\": sam,\n\ + \ \"status\": evaluation[\"status\"],\n \"disabled\": evaluation[\"disabled\"],\n \ + \ \"password_expired\": evaluation[\"password_expired\"],\n \"details\": evaluation[\"details\"],\n\ + \ }]\n )\n\n return_results(CommandResults(\n readable_output=readable,\n \ + \ outputs=outputs,\n outputs_prefix=\"\",\n raw_response={\"user\": user, \"errors\": errors}\n \ + \ ))\n\n except Exception as ex:\n demisto.error(traceback.format_exc())\n return_error(f\"VerifyADCompromisedUserContainment\ + \ failed. Error: {ex}\")\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"):\n main()\n" +type: python +tags: +- compromised-user-containment +comment: Verifies AD containment for a compromised user after disable and password-expire actions. +enabled: true +args: +- supportedModules: [] + name: sam_account_name + required: true + description: AD sAMAccountName of the compromised user. +outputs: +- contextPath: CompromisedUserContainment.AD.status + description: Success, Partial, Failed, or Unknown. + type: string +- contextPath: CompromisedUserContainment.AD.disabled + description: Whether the AD account appears disabled. + type: boolean +- contextPath: CompromisedUserContainment.AD.password_expired + description: Whether password expiration/change-required status appears present. + type: boolean +- contextPath: CompromisedUserContainment.AD.details + description: Human-readable verification details and captured errors. + type: string +- contextPath: CompromisedUserContainment.Actions.ad_disable + description: AD disable verification result. + type: string +- contextPath: CompromisedUserContainment.Actions.ad_password_expire + description: AD password expiration verification result. + type: string +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.14.92207 +runas: DBotWeakRole diff --git a/Packs/soc-framework-nist-ir/Scripts/VerifyEntraCompromisedUserContainment/VerifyEntraCompromisedUserContainment.yml b/Packs/soc-framework-nist-ir/Scripts/VerifyEntraCompromisedUserContainment/VerifyEntraCompromisedUserContainment.yml new file mode 100644 index 00000000..29ebd137 --- /dev/null +++ b/Packs/soc-framework-nist-ir/Scripts/VerifyEntraCompromisedUserContainment/VerifyEntraCompromisedUserContainment.yml @@ -0,0 +1,110 @@ +commonfields: + id: VerifyEntraCompromisedUserContainment + version: -1 +name: VerifyEntraCompromisedUserContainment +script: "import traceback\nfrom typing import Any, Optional\n\n\nOUTPUT_PREFIX = \"CompromisedUserContainment.Entra\"\n\n\n\ + def parse_args(args: dict[str, Any]) -> dict[str, Any]:\n user_id = args.get(\"user_id\")\n upn = args.get(\"user_principal_name\"\ + )\n\n if not user_id:\n raise DemistoException(\"The required argument 'user_id' was not provided.\")\n\n return\ + \ {\n \"user_id\": str(user_id).strip(),\n \"user_principal_name\": str(upn).strip() if upn else None,\n \ + \ }\n\n\ndef execute_command(command: str, args: dict[str, Any]) -> list[dict[str, Any]]:\n res = demisto.executeCommand(command,\ + \ args)\n if is_error(res):\n raise DemistoException(f\"{command} failed: {get_error(res)}\")\n return res\n\ + \n\ndef try_get_entra_user(user_id: str, upn: Optional[str]) -> dict[str, Any]:\n \"\"\"\n Tries common Microsoft\ + \ Graph / Entra command names.\n Adjust USER_LOOKUP_COMMANDS if your tenant uses a different integration command.\n \ + \ \"\"\"\n USER_LOOKUP_COMMANDS = [\n (\"msgraph-user-get\", {\"user\": user_id}),\n (\"msgraph-user-get\"\ + , {\"user_id\": user_id}),\n (\"msgraph-user-get\", {\"id\": user_id}),\n (\"azure-ad-get-user\", {\"user_id\"\ + : user_id}),\n (\"azure-ad-get-user\", {\"user\": user_id}),\n ]\n\n if upn:\n USER_LOOKUP_COMMANDS.extend([\n\ + \ (\"msgraph-user-get\", {\"user\": upn}),\n (\"azure-ad-get-user\", {\"user\": upn}),\n ])\n\ + \n last_error = None\n\n for command, command_args in USER_LOOKUP_COMMANDS:\n try:\n res = execute_command(command,\ + \ command_args)\n for entry in res:\n contents = entry.get(\"Contents\")\n if isinstance(contents,\ + \ dict):\n return contents\n if isinstance(contents, list) and contents and isinstance(contents[0],\ + \ dict):\n return contents[0]\n except Exception as ex:\n last_error = str(ex)\n\n\ + \ raise DemistoException(f\"Unable to re-query Microsoft Entra user. Last error: {last_error}\")\n\n\ndef get_context_value(path:\ + \ str) -> Any:\n try:\n return demisto.get(demisto.context(), path)\n except Exception:\n return None\n\ + \n\ndef normalize_bool(value: Any) -> Optional[bool]:\n if isinstance(value, bool):\n return value\n if isinstance(value,\ + \ str):\n lowered = value.lower().strip()\n if lowered in (\"true\", \"yes\", \"success\", \"succeeded\",\ + \ \"disabled\"):\n return True\n if lowered in (\"false\", \"no\", \"failed\", \"enabled\"):\n \ + \ return False\n return None\n\n\ndef determine_session_revocation_status() -> str:\n possible_paths = [\n \ + \ \"CompromisedUserContainment.Entra.sessions_revoked\",\n \"CompromisedUserContainment.Entra.session_revocation_status\"\ + ,\n \"ClearUserSessions.Result\",\n \"MicrosoftGraphUser.SessionRevocation.Status\",\n \"Entra.SessionRevocation.Status\"\ + ,\n ]\n\n for path in possible_paths:\n value = get_context_value(path)\n if value:\n text\ + \ = str(value).lower()\n if any(x in text for x in (\"success\", \"succeeded\", \"true\", \"revoked\")):\n \ + \ return \"Success\"\n if any(x in text for x in (\"submitted\", \"pending\", \"accepted\")):\n\ + \ return \"Submitted\"\n if \"fail\" in text or \"error\" in text:\n return \"\ + Failed\"\n\n return \"Submitted\"\n\n\ndef determine_mfa_reset_status() -> str:\n possible_paths = [\n \"CompromisedUserContainment.Entra.mfa_reset_status\"\ + ,\n \"ResetEntraAuthenticationMethods.Status\",\n \"ResetEntraAuthenticationMethods.Result\",\n \"\ + MicrosoftGraphUser.AuthenticationMethods.ResetStatus\",\n \"Entra.AuthenticationMethods.ResetStatus\",\n ]\n\n\ + \ for path in possible_paths:\n value = get_context_value(path)\n if value:\n text = str(value).lower()\n\ + \ if any(x in text for x in (\"success\", \"succeeded\", \"removed\", \"reset\")):\n return \"\ + Success\"\n if any(x in text for x in (\"submitted\", \"pending\", \"accepted\")):\n return \"\ + Submitted\"\n if any(x in text for x in (\"partial\", \"some\")):\n return \"Partial\"\n \ + \ if \"fail\" in text or \"error\" in text:\n return \"Failed\"\n\n return \"Unknown\"\n\n\ndef\ + \ calculate_status(signin_disabled: bool, sessions_revoked: str, mfa_reset_status: str) -> str:\n if not signin_disabled:\n\ + \ return \"Failed\"\n\n statuses = [sessions_revoked, mfa_reset_status]\n\n if \"Failed\" in statuses:\n \ + \ return \"Partial\"\n\n if sessions_revoked == \"Submitted\" or mfa_reset_status == \"Submitted\":\n return\ + \ \"Submitted\"\n\n if mfa_reset_status == \"Unknown\":\n return \"Partial\"\n\n if sessions_revoked == \"\ + Success\" and mfa_reset_status == \"Success\":\n return \"Success\"\n\n return \"Unknown\"\n\n\ndef build_readable_output(output:\ + \ dict[str, Any]) -> str:\n return tableToMarkdown(\n \"Microsoft Entra Containment Verification\",\n {\n\ + \ \"Status\": output.get(\"status\"),\n \"Account Enabled\": output.get(\"account_enabled\"),\n \ + \ \"Sign-in Disabled\": output.get(\"signin_disabled\"),\n \"Sessions Revoked\": output.get(\"sessions_revoked\"\ + ),\n \"MFA Reset Status\": output.get(\"mfa_reset_status\"),\n \"Details\": output.get(\"details\"\ + ),\n },\n removeNull=True,\n )\n\n\ndef main():\n try:\n args = parse_args(demisto.args())\n\n\ + \ user = try_get_entra_user(\n user_id=args[\"user_id\"],\n upn=args.get(\"user_principal_name\"\ + ),\n )\n\n account_enabled = user.get(\"accountEnabled\")\n if account_enabled is None:\n \ + \ account_enabled = user.get(\"account_enabled\")\n\n normalized_account_enabled = normalize_bool(account_enabled)\n\ + \ signin_disabled = normalized_account_enabled is False\n\n sessions_revoked = determine_session_revocation_status()\n\ + \ mfa_reset_status = determine_mfa_reset_status()\n\n status = calculate_status(\n signin_disabled=signin_disabled,\n\ + \ sessions_revoked=sessions_revoked,\n mfa_reset_status=mfa_reset_status,\n )\n\n details\ + \ = []\n if signin_disabled:\n details.append(\"Microsoft Entra user accountEnabled is false.\")\n \ + \ else:\n details.append(\"Microsoft Entra user accountEnabled is not confirmed false.\")\n\n if sessions_revoked\ + \ == \"Submitted\":\n details.append(\"Session revocation is treated as Submitted because revocation may be asynchronous\ + \ or only submission evidence exists.\")\n elif sessions_revoked == \"Success\":\n details.append(\"Session\ + \ revocation success evidence was found in context.\")\n else:\n details.append(f\"Session revocation\ + \ status: {sessions_revoked}.\")\n\n if mfa_reset_status == \"Unknown\":\n details.append(\"No ResetEntraAuthenticationMethods\ + \ context was found; MFA/authentication-method reset could not be verified.\")\n else:\n details.append(f\"\ + Authentication methods reset status: {mfa_reset_status}.\")\n\n output = {\n \"status\": status,\n \ + \ \"account_enabled\": normalized_account_enabled,\n \"signin_disabled\": signin_disabled,\n \ + \ \"sessions_revoked\": sessions_revoked,\n \"mfa_reset_status\": mfa_reset_status,\n \"details\"\ + : \" \".join(details),\n }\n\n return_results(CommandResults(\n outputs_prefix=OUTPUT_PREFIX,\n\ + \ outputs=output,\n readable_output=build_readable_output(output),\n ))\n\n except Exception\ + \ as ex:\n demisto.error(traceback.format_exc())\n return_error(f\"Failed to verify Microsoft Entra containment.\ + \ Error: {ex}\")\n\n\nif __name__ in (\"__main__\", \"__builtin__\", \"builtins\"):\n main()\n" +type: python +tags: +- compromised-user-containment +- entra +- verification +comment: Verifies Microsoft Entra containment for a compromised user after sign-in disablement, session revocation, and authentication + method reset. +enabled: true +args: +- supportedModules: [] + name: user_id + required: true + description: Microsoft Entra user object ID or identifier used to re-query the user. +- supportedModules: [] + name: user_principal_name + description: Optional user principal name used as a fallback lookup identifier. +outputs: +- contextPath: CompromisedUserContainment.Entra.status + description: Overall verification result. One of Success, Partial, Failed, Submitted, or Unknown. + type: string +- contextPath: CompromisedUserContainment.Entra.account_enabled + description: Current accountEnabled value from Microsoft Entra. + type: boolean +- contextPath: CompromisedUserContainment.Entra.signin_disabled + description: Whether accountEnabled is confirmed false. + type: boolean +- contextPath: CompromisedUserContainment.Entra.sessions_revoked + description: Session revocation verification state. + type: string +- contextPath: CompromisedUserContainment.Entra.mfa_reset_status + description: Authentication method reset verification state. + type: string +- contextPath: CompromisedUserContainment.Entra.details + description: Human-readable verification details. + type: string +scripttarget: 0 +subtype: python3 +runonce: false +dockerimage: demisto/python3:3.10.13.89009 +runas: DBotWeakRole