.github/skills/user-investigation/SKILL.md
Use this skill when asked to investigate a user account for security issues, suspicious activity, or compliance review. Triggers on keywords like "investigate user", "security investigation", "user investigation", "check user activity", "analyze sign-ins", or when a UPN/email is mentioned with investigation context. This skill provides comprehensive Entra ID user security analysis including sign-in anomalies, MFA status, device compliance, audit logs, security incidents, Identity Protection risk, and automated reports (HTML, markdown file, or inline chat).
npx skillsauth add scstelz/security-investigator user-investigationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill performs comprehensive security investigations on Entra ID user accounts, analyzing sign-in patterns, anomalies, MFA status, device compliance, audit logs, Office 365 activity, security incidents, and Identity Protection risk signals.
Investigation shortcuts:
enrich_ips.py⛔ Shortcut Default Rule: When a matching shortcut exists for the investigation context, use it — don't run the full workflow. Only run full Batch 1 + Batch 2 when the user explicitly requests "full investigation", "comprehensive", or "deep dive". Shortcuts render only the report sections relevant to their query chain (plus Executive Summary and Recommendations, always).
Before starting ANY user investigation:
create_file for JSON export and markdown reports (NEVER use PowerShell terminal commands)This skill requires a Sentinel workspace to execute queries. Follow these rules STRICTLY:
SELECTED_WORKSPACE_IDS passed from the parent skilllist_sentinel_workspaces MCP tool FIRSTIF query returns "Failed to resolve table" or similar error:
- STOP IMMEDIATELY
- Report: "⚠️ Query failed on workspace [NAME] ([ID]). Error: [ERROR_MESSAGE]"
- Display: "Available workspaces: [LIST_ALL_WORKSPACES]"
- ASK: "Which workspace should I use instead?"
- WAIT for explicit user response
- DO NOT retry with a different workspace automatically
🔴 PROHIBITED ACTIONS:
Date Range Rules:
datetime(2025-11-18) to datetime(2025-11-27)When to use: General security reviews, routine investigations
Example prompts:
When to use: Urgent cases, recent suspicious activity
Example prompts:
When to use: Deep-dive analysis, compliance reviews, thorough forensics
Example prompts:
All types include: Anomaly detection, sign-in analysis, IP enrichment, Graph identity data, device compliance, audit logs, Office 365 activity, security alerts, threat intelligence, risk assessment, and automated recommendations.
This skill supports three output modes. ASK the user which they prefer if not explicitly specified. Multiple modes may be selected simultaneously.
reports/user-investigations/user_investigation_<username>_<YYYYMMDD_HHMMSS>.mdcreate_file tool — NEVER use terminal commands for file outputuser_investigation_<username>_YYYYMMDD_HHMMSS.md (extract username from UPN, e.g., jdoe from [email protected])generate_report_from_json.py█ full block, ─ box-drawing horizontal) display correctly in monospaced fonts| col |) render as formatted tables| User Request | Mode(s) | |---|---| | "Investigate [email protected]" (no mode specified) | ASK user to choose | | "Investigate [email protected] — markdown report" | Mode 2 only | | "Investigate [email protected] — full report" | Mode 2 + Mode 3 (both) | | "Quick investigate [email protected]" | Mode 1 (inline) | | "Investigate [email protected] — HTML report" | Mode 3 only | | "Investigate [email protected] — inline and markdown" | Mode 1 + Mode 2 |
When a user requests a security investigation:
Get User ID:
mcp_microsoft_mcp_microsoft_graph_suggest_queries("get user by email")
mcp_microsoft_mcp_microsoft_graph_get("/v1.0/users/<UPN>?$select=id,onPremisesSecurityIdentifier")
Determine Output Mode:
Run Parallel Queries:
Generate Output (based on selected mode):
Mode 1 — Inline: Render analysis directly in chat (no file output)
Mode 2 — Markdown file:
create_file("reports/user-investigations/user_investigation_<username>_<timestamp>.md", markdown_content)
Mode 3 — HTML report:
create_file("temp/investigation_<upn_prefix>_<timestamp>.json", json_content)
$env:PYTHONPATH = "<WORKSPACE_ROOT>"
.venv\Scripts\python.exe scripts/generate_report_from_json.py temp/investigation_<upn_prefix>_<timestamp>.json
IP Enrichment (Modes 2 & 3):
python enrich_ips.py <ip1> <ip2> ... for top IPs extracted from queries, then include enrichment results in the markdown reportgenerate_report_from_json.pyTrack time after each major step and report to user
YOU MUST TRACK AND REPORT TIME AFTER EVERY MAJOR STEP:
[MM:SS] ✓ Step description (XX seconds)
Required Reporting Points:
- Get user Object ID (Entra ID) and onPremisesSecurityIdentifier (Windows SID) from Microsoft Graph
- Query: /v1.0/users/<UPN>?$select=id,onPremisesSecurityIdentifier
Why this is required:
CRITICAL: Use create_file tool to create JSON - NEVER use PowerShell terminal commands!
["ip1", "ip2", "ip3", ...]let target_ips = dynamic(["ip1", "ip2", "ip3", ...]);Assess IP enrichment needs:
python enrich_ips.py <ip1> <ip2> ... for threat intelligence enrichmentBuild the markdown report using the Markdown Report Template below
generate_report_from_json.py)Save the report:
create_file("reports/user-investigations/user_investigation_<username>_YYYYMMDD_HHMMSS.md", markdown_content)
create_file tool — NEVER use terminal commands for file outputjdoe from [email protected])Export to JSON:
Create single JSON file: temp/investigation_{upn_prefix}_{timestamp}.json
Merge all results into one dict structure (see JSON Export Structure section below).
Generate HTML report:
$env:PYTHONPATH = "<WORKSPACE_ROOT>"
cd "<WORKSPACE_ROOT>"
.\.venv\Scripts\python.exe scripts/generate_report_from_json.py temp/investigation_<upn_prefix>_<timestamp>.json
The HTML report generator handles:
When multiple modes are selected (e.g., "markdown and HTML"):
/v1.0/users/<UPN>?$select=id,displayName,userPrincipalName,mail,userType,jobTitle,department,officeLocation,accountEnabled,onPremisesSecurityIdentifier
department="Unknown", officeLocation="Unknown"onPremisesSecurityIdentifier returns Windows SID (format: S-1-5-21-...) - REQUIRED for on-premises incident matching/v1.0/users/<USER_ID>/ownedDevices?$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,registrationDateTime,isCompliant,isManaged,trustType,approximateLastSignInDateTime&$orderby=approximateLastSignInDateTime desc&$top=5&$count=true
trustType="Workplace", approximateLastSignInDateTime="2025-01-01T00:00:00Z"/v1.0/users/<USER_ID>/authentication/methods?$top=5
Replace <UPN>, <StartDate>, <EndDate> in these patterns.
⚠️ CRITICAL: START WITH THESE EXACT QUERY PATTERNS These queries have been tested and validated. Use them as your PRIMARY reference.
Follow the global tool selection rule from copilot-instructions.md:
| Investigation Lookback | Tool | Reason |
|------------------------|------|--------|
| ≤ 30 days (Quick, Standard, Comprehensive) | RunAdvancedHuntingQuery | Free for Analytics-tier tables; covers all connected workspace tables |
| > 30 days (custom range) | mcp_sentinel-data_query_lake | AH only retains 30 days |
| AH query blocked by safety filter | mcp_sentinel-data_query_lake | Fallback |
| AH returns "table not found" | mcp_sentinel-data_query_lake | Fallback |
Default: Use RunAdvancedHuntingQuery for all standard investigations. All three investigation types (1d, 7d, 30d) fit within AH's 30-day retention window. Only fall back to Data Lake when the lookback exceeds 30 days or AH fails.
Timestamp column: All tables used in this skill (SigninLogs, AuditLogs, SecurityAlert, SecurityIncident, OfficeActivity, CloudAppEvents, AADUserRiskEvents, Signinlogs_Anomalies_KQL_CL, ThreatIntelIndicators) use TimeGenerated in both tools — no adaptation needed when switching.
🔴 STEP 0: GET CURRENT DATE FIRST (MANDATORY) 🔴
RULE 1: Real-Time/Recent Searches (Current Activity)
datetime(2025-11-27) as end dateRULE 2: Historical Searches (User-Specified Dates)
Examples Table (Assuming Current Date = November 27, 2025):
| User Request | <StartDate> | <EndDate> | Rule Applied |
|--------------|---------------|-------------|--------------|
| "Last 7 days" | 2025-11-20 | 2025-11-29 | Rule 1 (+2) |
| "Last 30 days" | 2025-10-28 | 2025-11-29 | Rule 1 (+2) |
| "Nov 21 to Nov 23" | 2025-11-21 | 2025-11-24 | Rule 2 (+1) |
🚨 CRITICAL - SIGN-IN QUERIES REQUIREMENT 🚨
You MUST run ALL THREE sign-in queries (3, 3b, 3c) to populate the signin_events dict!
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let upn = '<UPN>';
// Priority 1: Anomaly IPs (top 8 by anomaly count)
let anomaly_ips =
Signinlogs_Anomalies_KQL_CL
| where DetectedDateTime between (start .. end)
| where UserPrincipalName =~ upn
| where AnomalyType endswith "IP"
| summarize AnomalyCount = count(), FirstSeen = min(DetectedDateTime) by IPAddress = Value
| order by AnomalyCount desc, FirstSeen asc
| take 8
| extend Priority = 1, Source = "Anomaly";
// Priority 2: Risky IPs from Identity Protection (top 10 for selection pool)
let risky_ips_pool =
AADUserRiskEvents
| where ActivityDateTime between (start .. end)
| where UserPrincipalName =~ upn
| where isnotempty(IpAddress)
| summarize RiskCount = count(), FirstSeen = min(ActivityDateTime) by IPAddress = IpAddress
| order by RiskCount desc, FirstSeen asc
| take 10
| extend Priority = 2, Source = "RiskyIP";
// Priority 3: Frequent Sign-in IPs (top 10 for selection pool)
let frequent_ips_pool =
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated between (start .. end)
| where UserPrincipalName =~ upn
| summarize SignInCount = count(), FirstSeen = min(TimeGenerated) by IPAddress
| order by SignInCount desc, FirstSeen asc
| take 10
| extend Priority = 3, Source = "Frequent";
// Get anomaly IP list for exclusion from risky slot
let anomaly_ip_list = anomaly_ips | project IPAddress;
// Get anomaly + risky IP list for exclusion from frequent slot
let priority_ip_list =
union anomaly_ips, risky_ips_pool
| project IPAddress;
// Reserve slots with deduplication: 8 anomaly + 4 risky + 3 frequent
let anomaly_slot = anomaly_ips | extend Count = AnomalyCount;
let risky_slot = risky_ips_pool
| join kind=anti anomaly_ip_list on IPAddress
| order by RiskCount desc, FirstSeen asc
| take 4
| extend Count = RiskCount;
let frequent_slot = frequent_ips_pool
| join kind=anti priority_ip_list on IPAddress
| order by SignInCount desc, FirstSeen asc
| take 3
| extend Count = SignInCount;
union anomaly_slot, risky_slot, frequent_slot
| project IPAddress, Priority, Count, Source
| order by Priority asc, Count desc
| project IPAddress
Signinlogs_Anomalies_KQL_CL
| where DetectedDateTime between (datetime(<StartDate>) .. datetime(<EndDate>))
| where UserPrincipalName =~ '<UPN>'
| extend Severity = case(
BaselineSize < 3, "Informational",
CountryNovelty and CityNovelty and ArtifactHits >= 20, "High",
ArtifactHits >= 10, "Medium",
(CountryNovelty or CityNovelty or StateNovelty), "Medium",
ArtifactHits >= 5, "Low",
"Informational")
| extend SeverityOrder = case(Severity == 'High', 1, Severity == 'Medium', 2, Severity == 'Low', 3, 4)
| project
DetectedDateTime,
UserPrincipalName,
AnomalyType,
Value,
Severity,
SeverityOrder,
Country,
City,
State,
CountryNovelty,
CityNovelty,
StateNovelty,
ArtifactHits,
FirstSeenRecent,
BaselineSize,
OS,
BrowserFamily,
RawBrowser
| order by SeverityOrder asc, DetectedDateTime desc
| take 10
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated between (start .. end)
| where UserPrincipalName =~ '<UPN>'
| summarize
SignInCount=count(),
SuccessCount=countif(ResultType == '0'),
FailureCount=countif(ResultType != '0'),
FirstSeen=min(TimeGenerated),
LastSeen=max(TimeGenerated),
IPAddresses=make_set(IPAddress),
UniqueLocations=dcount(Location)
by AppDisplayName
| order by SignInCount desc
| take 5
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated between (start .. end)
| where UserPrincipalName =~ '<UPN>'
| where isnotempty(Location)
| summarize
SignInCount=count(),
SuccessCount=countif(ResultType == '0'),
FailureCount=countif(ResultType != '0'),
FirstSeen=min(TimeGenerated),
LastSeen=max(TimeGenerated),
IPAddresses=make_set(IPAddress),
Applications=make_set(AppDisplayName, 5)
by Location
| order by SignInCount desc
| take 5
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated between (start .. end)
| where UserPrincipalName =~ '<UPN>'
| where ResultType != '0'
| summarize
FailureCount=count(),
FirstSeen=min(TimeGenerated),
LastSeen=max(TimeGenerated),
Applications=make_set(AppDisplayName, 3),
Locations=make_set(Location, 3)
by ResultType, ResultDescription
| order by FailureCount desc
| take 5
let target_ips = dynamic(["<IP_1>", "<IP_2>", "<IP_3>", ...]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let most_recent_signins = union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated between (start .. end)
| where UserPrincipalName =~ '<UPN>'
| where IPAddress in (target_ips)
| summarize arg_max(TimeGenerated, *) by IPAddress;
most_recent_signins
| extend AuthDetails = parse_json(AuthenticationDetails)
| extend HasAuthDetails = array_length(AuthDetails) > 0
| extend AuthDetailsToExpand = iif(HasAuthDetails, AuthDetails, dynamic([{"authenticationStepResultDetail": ""}]))
| mv-expand AuthDetailsToExpand
| extend AuthStepResultDetail = tostring(AuthDetailsToExpand.authenticationStepResultDetail)
| extend AuthPriority = case(
AuthStepResultDetail has "MFA requirement satisfied", 1,
AuthStepResultDetail has "Correct password", 2,
AuthStepResultDetail has "Passkey", 2,
AuthStepResultDetail has "Phone sign-in", 2,
AuthStepResultDetail has "SMS verification", 2,
AuthStepResultDetail has "First factor requirement satisfied", 3,
AuthStepResultDetail has "MFA required", 4,
999)
| summarize
MostRecentTime = any(TimeGenerated),
MostRecentResultType = any(ResultType),
HasAuthDetails = any(HasAuthDetails),
MinPriority = min(AuthPriority),
AllAuthDetails = make_set(AuthStepResultDetail)
by IPAddress
| extend LastAuthResultDetail = case(
MostRecentResultType != "0", "Authentication failed",
not(HasAuthDetails) and MostRecentResultType == "0", "Token",
MinPriority == 1 and AllAuthDetails has "MFA requirement satisfied", "MFA requirement satisfied by claim in the token",
MinPriority == 2 and AllAuthDetails has "Correct password", "Correct password",
MinPriority == 2 and AllAuthDetails has "Passkey (device-bound)", "Passkey (device-bound)",
MinPriority == 3 and AllAuthDetails has "First factor requirement satisfied by claim in the token", "First factor requirement satisfied by claim in the token",
MinPriority == 4 and AllAuthDetails has "MFA required in Entra ID", "MFA required in Entra ID",
tostring(AllAuthDetails[0]))
| join kind=inner (
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated between (start .. end)
| where UserPrincipalName =~ '<UPN>'
| where IPAddress in (target_ips)
| summarize
SignInCount = count(),
SuccessCount = countif(ResultType == '0'),
FailureCount = countif(ResultType != '0'),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by IPAddress
) on IPAddress
| project IPAddress, SignInCount, SuccessCount, FailureCount, FirstSeen, LastSeen, LastAuthResultDetail
| order by SignInCount desc
Tool: RunAdvancedHuntingQuery (≤30d) | mcp_sentinel-data_query_lake (>30d fallback)
AH parsing note: InitiatedBy is dynamic in AH — use tostring(InitiatedBy.user.userPrincipalName) for direct field access. For TargetResources, use tostring(TargetResources[0].displayName). Do NOT double-wrap with parse_json(tostring(parse_json(tostring(...)))) — that Data Lake pattern can cause errors in AH.
AuditLogs
| where TimeGenerated between (datetime(<StartDate>) .. datetime(<EndDate>))
| where Identity =~ '<UPN>' or tostring(InitiatedBy) has '<UPN>'
| summarize
Count=count(),
FirstSeen=min(TimeGenerated),
LastSeen=max(TimeGenerated),
Operations=make_set(OperationName, 10)
by Category, Result
| order by Count desc
| take 10
Ad-hoc drill-down pattern (AH-safe): When you need detailed audit entries beyond the summary above:
AuditLogs
| where TimeGenerated between (datetime(<StartDate>) .. datetime(<EndDate>))
| where Identity =~ '<UPN>' or tostring(InitiatedBy) has '<UPN>'
| extend Actor = tostring(InitiatedBy.user.userPrincipalName)
| extend Target = tostring(TargetResources[0].displayName)
| project TimeGenerated, OperationName, Actor, Target, Result, Category
| order by TimeGenerated desc
| take 30
OfficeActivity
| where TimeGenerated between (datetime(<StartDate>) .. datetime(<EndDate>))
| where UserId =~ '<UPN>'
| summarize ActivityCount = count() by RecordType, Operation
| order by ActivityCount desc
| take 5
let targetUPN = "<UPN>";
let targetUserId = "<USER_OBJECT_ID>"; // REQUIRED: Get from Microsoft Graph API
let targetSid = "<WINDOWS_SID>"; // REQUIRED: Get from Microsoft Graph API
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let relevantAlerts = SecurityAlert
| where TimeGenerated between (start .. end)
| where Entities has targetUPN or Entities has targetUserId or Entities has targetSid
| summarize arg_max(TimeGenerated, *) by SystemAlertId
| project SystemAlertId, AlertName, AlertSeverity, ProviderName, Tactics;
SecurityIncident
| where CreatedTime between (start .. end)
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where not(tostring(Labels) has "Redirected")
| mv-expand AlertId = AlertIds
| extend AlertId = tostring(AlertId)
| join kind=inner relevantAlerts on $left.AlertId == $right.SystemAlertId
| extend ProviderIncidentUrl = tostring(AdditionalData.providerIncidentUrl)
| extend OwnerUPN = tostring(Owner.userPrincipalName)
| extend LastModifiedTime = todatetime(LastModifiedTime)
| summarize
Title = any(Title),
Severity = any(Severity),
Status = any(Status),
Classification = any(Classification),
CreatedTime = any(CreatedTime),
LastModifiedTime = any(LastModifiedTime),
OwnerUPN = any(OwnerUPN),
ProviderIncidentUrl = any(ProviderIncidentUrl),
AlertCount = count()
by ProviderIncidentId
| order by LastModifiedTime desc
| take 10
CRITICAL: ALL THREE identifiers are REQUIRED (targetUPN, targetUserId, targetSid) - different alert types use different entity formats.
let upn = '<UPN>';
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
CloudAppEvents
| where TimeGenerated between (start .. end)
| where ActionType in ("FileCopiedToRemovableMedia", "FileUploadedToCloud", "FileCopiedToNetworkShare")
| extend ParsedData = parse_json(RawEventData)
| extend DlpAudit = ParsedData["DlpAuditEventMetadata"]
| extend File = ParsedData["ObjectId"]
| extend UserId = ParsedData["UserId"]
| extend DeviceName = ParsedData["DeviceName"]
| extend ClientIP = ParsedData["ClientIP"]
| extend RuleName = ParsedData["PolicyMatchInfo"]["RuleName"]
| extend Operation = ParsedData["Operation"]
| extend TargetDomain = ParsedData["TargetDomain"]
| extend TargetFilePath = ParsedData["TargetFilePath"]
| where isnotnull(DlpAudit)
| where UserId == upn
| summarize by TimeGenerated, tostring(UserId), tostring(DeviceName), tostring(ClientIP), tostring(RuleName), tostring(File), tostring(Operation), tostring(TargetDomain), tostring(TargetFilePath)
| order by TimeGenerated desc
| take 5
Performance notes: Filter IsActive/ValidUntil before transformations per KQL best practices. The triple replace_string was replaced with direct array indexing split(...)[0].
let target_ips = dynamic(["<IP_1>", "<IP_2>", "<IP_3>"]);
ThreatIntelIndicators
| where IsActive and (ValidUntil > now() or isempty(ValidUntil))
| where tostring(split(ObservableKey, ":")[0]) in ("ipv4-addr", "ipv6-addr", "network-traffic")
| where ObservableValue in (target_ips)
| extend Description = tostring(parse_json(Data).description)
| where Description !contains_cs "State: inactive;" and Description !contains_cs "State: falsepos;"
| extend TrafficLightProtocolLevel = tostring(parse_json(AdditionalFields).TLPLevel)
| extend ActivityGroupNames = extract(@"ActivityGroup:(\S+)", 1, tostring(parse_json(Data).labels))
| summarize arg_max(TimeGenerated, *) by ObservableValue
| project
TimeGenerated,
IPAddress = ObservableValue,
ThreatDescription = Description,
ActivityGroupNames,
Confidence,
ValidUntil,
TrafficLightProtocolLevel,
IsActive
| order by Confidence desc, TimeGenerated desc
Purpose: Retrieves scored behavioral anomaly detections from Sentinel's built-in UEBA anomaly rules. Aggregates by anomaly type — collapses high-volume rows (e.g., 50 "Anomalous Role Assignment" events) into a single summary row per template. Extracts only the anomalous flags (IsAnomalous == true) and flattens MITRE arrays. Score range: 0.0–1.0 (≥0.7 = High, 0.3–0.7 = Medium, <0.3 = Low).
Data source: The Anomalies table is the KQL source behind the portal's "UEBA anomalies" section. It is distinct from BehaviorInfo (MCAS, AH-only) and BehaviorAnalytics (raw UEBA events, Data Lake-only). Available in both Advanced Hunting and Data Lake.
Tool: RunAdvancedHuntingQuery (default) or mcp_sentinel-data_query_lake (>30d fallback)
⚠️ TI False Positive: DeviceInsights.ThreatIntelIndicatorType frequently shows BruteForce on corporate/Azure egress IPs (TITAN dynamic reputation). Weight the Score and AnomalyFlags over the TI match — a 0.2-score anomaly with a BruteForce TI hit on a known corporate IP is noise.
let targetUPN = '<UPN>';
let lookback = 30d;
Anomalies
| where TimeGenerated > ago(lookback)
| where UserPrincipalName =~ targetUPN
| extend TI_Type = tostring(DeviceInsights.ThreatIntelIndicatorType)
| mv-apply reason = AnomalyReasons on (
where tobool(reason.IsAnomalous) == true
| project FlagName = tostring(reason.Name))
| summarize
Occurrences = dcount(Id),
MaxScore = max(Score),
AvgScore = round(avg(Score), 2),
Tactics = make_set(parse_json(Tactics)),
Techniques = make_set(parse_json(Techniques)),
SourceIPs = make_set(SourceIpAddress, 5),
AnomalyFlags = make_set(FlagName),
TI_Flags = make_set_if(TI_Type, isnotempty(TI_Type)),
FirstSeen = min(StartTime),
LastSeen = max(EndTime),
SampleDescription = take_any(Description)
by AnomalyTemplateName
| mv-apply t = Tactics to typeof(string) on (summarize Tactics = make_set(t))
| mv-apply t = Techniques to typeof(string) on (summarize Techniques = make_set(t))
| extend Tactics = set_difference(Tactics, dynamic([""]))
| extend Techniques = set_difference(Techniques, dynamic([""]))
| order by MaxScore desc, Occurrences desc
Output columns: AnomalyTemplateName, Occurrences (unique anomaly IDs), MaxScore, AvgScore, Tactics, Techniques, SourceIPs, AnomalyFlags (flat set of anomalous reasons), TI_Flags, FirstSeen, LastSeen, SampleDescription (one example description for context).
Verdict guidance:
CountryUncommonlyConnectedFromByUser combined with ActionUncommonlyPerformedByUserZero results note: Unlike Q2 (custom Signinlogs_Anomalies_KQL_CL), Q12 queries the built-in Sentinel UEBA Anomalies table. Zero results means no built-in anomaly rules fired — not that UEBA is disabled. If UEBA is not enabled in the workspace, the table may not exist (handle gracefully).
CRITICAL: Always query Identity Protection data in Phase 2 (Batch 2) of investigation workflow
mcp_microsoft_mcp_microsoft_graph_get("/v1.0/users/<UPN>?$select=id,displayName,userPrincipalName,onPremisesSecurityIdentifier")
mcp_microsoft_mcp_microsoft_graph_get("/v1.0/identityProtection/riskyUsers/<USER_ID>")
Returns: riskLevel (low/medium/high/none), riskState (atRisk/confirmedCompromised/dismissed/remediated)
mcp_microsoft_mcp_microsoft_graph_get("/v1.0/identityProtection/riskDetections?$filter=userId eq '<USER_ID>'&$select=id,detectedDateTime,riskEventType,riskLevel,riskState,riskDetail,ipAddress,location,activity,activityDateTime&$orderby=detectedDateTime desc&$top=10")
Returns: Array of risk events with riskEventType (unlikelyTravel, unfamiliarFeatures, anonymizedIPAddress, etc.)
mcp_microsoft_mcp_microsoft_graph_get("/beta/auditLogs/signIns?$filter=userId eq '<USER_ID>' and (riskState eq 'atRisk' or riskState eq 'confirmedCompromised')&$select=id,createdDateTime,userPrincipalName,appDisplayName,ipAddress,location,riskState,riskLevelDuringSignIn,riskEventTypes_v2,riskDetail,status&$orderby=createdDateTime desc&$top=5")
NOTE: Risky sign-ins are ONLY available in /beta endpoint, not /v1.0
When outputting to markdown file (Mode 2), use this template. Populate ALL sections with actual query data. For sections with no data, use the explicit absence confirmation pattern.
Filename pattern: reports/user-investigations/user_investigation_<username>_YYYYMMDD_HHMMSS.md
# User Security Investigation Report
**Generated:** YYYY-MM-DD HH:MM UTC
**Workspace:** <workspace_name>
**User:** <display_name> (`<UPN>`)
**Department:** <department> | **Title:** <job_title> | **Location:** <office_location>
**Account Status:** <Enabled/Disabled> | **User Type:** <Member/Guest>
**Investigation Period:** <start_date> → <end_date> (<N> days)
**Investigation Type:** <Standard (7d) / Quick (1d) / Comprehensive (30d)>
**Data Sources:** SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, SecurityAlert, SecurityIncident, OfficeActivity, CloudAppEvents, AADUserRiskEvents, Signinlogs_Anomalies_KQL_CL, Identity Protection (Graph API), ThreatIntelIndicators
---
## Executive Summary
<2-4 sentence summary: overall risk level, key findings, most significant anomalies or concerns, and primary recommendation. Ground every claim in evidence from query results.>
**Overall Risk Level:** 🔴 HIGH / 🟠 MEDIUM / 🟡 LOW / 🟢 INFORMATIONAL
---
## Key Metrics
| Metric | Value |
|--------|-------|
| **Total Sign-ins** | <count> |
| **Successful** | <count> (<percentage>%) |
| **Failed** | <count> (<percentage>%) |
| **Unique IPs** | <count> |
| **Unique Locations** | <count> |
| **Anomalies Detected** | <count> (High: <n>, Medium: <n>, Low: <n>) |
| **Security Incidents** | <count> (Open: <n>, Closed: <n>) |
| **Risk Detections** | <count> (atRisk: <n>, remediated: <n>) |
| **DLP Events** | <count> |
| **MFA Methods** | <count> methods |
---
## MFA & Authentication Status
| Factor | Status |
|--------|--------|
| **MFA Enabled** | 🟢 Yes / 🔴 No |
| **Methods** | <list of methods: Authenticator, FIDO2, Phone, etc.> |
| **FIDO2/Passkey** | 🟢 Enrolled / 🟡 Not enrolled |
| **Authenticator App** | 🟢 Enrolled / 🟡 Not enrolled |
| **Phishing-Resistant** | 🟢 Yes (passkey/FIDO2) / 🟡 No |
---
## Identity Protection
### User Risk Profile
| Field | Value |
|-------|-------|
| **Risk Level** | 🔴/🟠/🟡/🟢 <high/medium/low/none> |
| **Risk State** | <atRisk / confirmedCompromised / remediated / dismissed / none> |
| **Risk Detail** | <detail text> |
| **Last Updated** | <datetime> |
### Risk Detections
<If risk detections found:>
| Detected | Risk Type | Level | State | IP Address | Location | Activity |
|----------|-----------|-------|-------|------------|----------|----------|
| <datetime> | <riskEventType> | <level> | <state> | <ip> | <city, country> | <signin/user> |
<If no risk detections:>
✅ No Identity Protection risk detections for this user in the investigation period.
### Risky Sign-ins
<If risky sign-ins found:>
| Time | Application | IP Address | Location | Risk Level | Risk State | Detail |
|------|-------------|------------|----------|------------|------------|--------|
| <datetime> | <app> | <ip> | <city, country> | <level> | <state> | <detail> |
<If no risky sign-ins:>
✅ No risky sign-ins detected for this user in the investigation period.
---
## Anomalies (Signinlogs_Anomalies_KQL_CL)
<If anomalies found:>
| Detected | Type | Value | Severity | Location | Hits | Geo Novelty |
|----------|------|-------|----------|----------|------|-------------|
| <datetime> | <NewInteractiveIP / NewInteractiveDeviceCombo / etc.> | <IP or OS\|Browser> | 🔴/🟠/🟡 <severity> | <country, city> | <count> | <Country: Y/N, City: Y/N> |
**Anomaly Summary:**
- <X> new IP addresses detected (Y with geographic novelty)
- <X> new device combinations detected
- Highest severity: <level> — <brief description of most critical anomaly>
<If no anomalies:>
✅ No sign-in anomalies detected in the investigation period.
- Checked: Signinlogs_Anomalies_KQL_CL (0 records)
---
## IP Intelligence
<Table of up to 15 prioritized IPs with enrichment data. Run `enrich_ips.py` for top IPs.>
| IP Address | Source | Location | ISP/Org | VPN | Abuse Score | Reports | Risk | Sign-ins | Auth Method |
|------------|--------|----------|---------|-----|-------------|---------|------|----------|-------------|
| <ip> | 🔴 Anomaly / 🟠 Risky / 🔵 Frequent | <city, country> | <org> | 🟢 No / 🔴 Yes | <score>% | <count> | HIGH/MED/LOW | <count> (✓<success>/✗<fail>) | <MFA/Password/Token/Passkey> |
### Threat Intelligence Matches
<If TI matches found:>
| IP Address | Threat Description | Confidence | Activity Groups | Valid Until |
|------------|-------------------|------------|-----------------|------------|
| <ip> | <description> | <score> | <groups> | <date> |
<If no TI matches:>
✅ No threat intelligence matches found for investigated IPs.
---
## Sign-in Activity
### Top Applications
| Application | Sign-ins | Success | Failures | Unique Locations | IP Addresses | First Seen | Last Seen |
|-------------|----------|---------|----------|------------------|--------------|------------|-----------|
| <app> | <count> | <count> | <count> | <count> | <ip_list> | <date> | <date> |
### Top Locations
| Location | Sign-ins | Success | Failures | IP Addresses | Applications | First Seen | Last Seen |
|----------|----------|---------|----------|--------------|--------------|------------|-----------|
| <location> | <count> | <count> | <count> | <ip_list> | <app_list> | <date> | <date> |
### Sign-in Failures
<If failures found:>
| Error Code | Description | Count | Applications | Locations | First Seen | Last Seen |
|------------|-------------|-------|--------------|-----------|------------|-----------|
| <code> | <description> | <count> | <app_list> | <loc_list> | <date> | <date> |
**Failure Analysis:**
- <Brief analysis of failure patterns — device compliance (53000), MFA required (50074), blocked by CA (530032), etc.>
<If no failures:>
✅ No sign-in failures detected in the investigation period.
---
## Registered Devices
<If devices found:>
| Device Name | OS | Trust Type | Compliant | Managed | Last Sign-in |
|-------------|-----|------------|-----------|---------|--------------|
| <name> | <os> <version> | <AzureAd/Hybrid/Workplace> | 🟢 Yes / 🔴 No | 🟢 Yes / 🔴 No | <date> |
<If no devices:>
✅ No registered devices found for this user.
---
## Audit Log Activity
<If audit events found:>
| Category | Result | Count | Operations | First Seen | Last Seen |
|----------|--------|-------|------------|------------|-----------|
| <category> | <Success/Failure> | <count> | <operation_list> | <date> | <date> |
**Notable Operations:**
- <Brief summary of significant audit events — password changes, role assignments, MFA modifications, app consent, etc.>
<If no audit events:>
✅ No audit log activity detected for this user in the investigation period.
---
## Office 365 Activity
<If O365 events found:>
| Record Type | Operation | Count |
|-------------|-----------|-------|
| <type> | <operation> | <count> |
<If no O365 events:>
✅ No Office 365 activity detected for this user in the investigation period.
---
## DLP Events
<If DLP events found:>
| Time | Device | Operation | File | Target | Rule |
|------|--------|-----------|------|--------|------|
| <datetime> | <device> | <operation> | <filename> | <domain/path> | <rule_name> |
**DLP Summary:**
- ⚠️ <X> sensitive file operations detected
- Operations: <network share copy, cloud upload, removable media, etc.>
- Rules triggered: <list of DLP rule names>
<If no DLP events:>
✅ No DLP events detected for this user in the investigation period.
---
## Security Incidents
<If incidents found:>
| ID | Title | Severity | Status | Classification | Created | Owner | Alerts | Link |
|----|-------|----------|--------|----------------|---------|-------|--------|------|
| <id> | <title> | 🔴/🟠/🟡 <severity> | <New/Active/Closed> | <TP/FP/BP/—> | <date> | <owner_upn> | <count> | [View](<url>) |
**Incident Summary:**
- <X> total incidents (<Y> open, <Z> closed)
- Highest severity: <level>
- <Brief description of most critical incident>
<If no incidents:>
✅ No security incidents involving this user in the investigation period.
- Checked: SecurityAlert → SecurityIncident join on UPN, User Object ID, and Windows SID (0 matches)
---
## Risk Assessment
### Risk Score: <XX>/100 — 🔴 HIGH / 🟠 MEDIUM / 🟡 LOW / 🟢 INFORMATIONAL
### Risk Factors
| Factor | Finding |
|--------|---------|
| 🔴/🟠/🟡 **<Factor Name>** | <Evidence-grounded finding with specific numbers> |
### Mitigating Factors
| Factor | Finding |
|--------|---------|
| 🟢 **<Factor Name>** | <Evidence-grounded finding with specific numbers> |
---
## Recommendations
### Critical Actions
<Numbered list of critical actions with evidence. Only include if critical findings exist.>
### High Priority Actions
<Numbered list of high-priority actions with evidence.>
### Monitoring Actions (14-Day Follow-Up)
<Bulleted list of ongoing monitoring recommendations.>
---
## Appendix: Query Details
| # | Query | Table(s) | Records | Execution |
|---|-------|----------|--------:|----------:|
| 1 | IP Selection (Priority IPs) | Signinlogs_Anomalies_KQL_CL, AADUserRiskEvents, SigninLogs | <count> | <time> |
| 2 | Anomaly Detection | Signinlogs_Anomalies_KQL_CL | <count> | <time> |
| 3 | Sign-ins by Application | SigninLogs, AADNonInteractiveUserSignInLogs | <count> | <time> |
| 3b | Sign-ins by Location | SigninLogs, AADNonInteractiveUserSignInLogs | <count> | <time> |
| 3c | Sign-in Failures | SigninLogs, AADNonInteractiveUserSignInLogs | <count> | <time> |
| 3d | IP Sign-in Counts | SigninLogs, AADNonInteractiveUserSignInLogs | <count> | <time> |
| 4 | Audit Log Activity | AuditLogs | <count> | <time> |
| 5 | Office 365 Activity | OfficeActivity | <count> | <time> |
| 6 | Security Incidents | SecurityAlert, SecurityIncident | <count> | <time> |
| 10 | DLP Events | CloudAppEvents | <count> | <time> |
| 11 | Threat Intelligence | ThreatIntelIndicators | <count> | <time> |
| — | User Profile | Microsoft Graph API | 1 | <time> |
| — | MFA Methods | Microsoft Graph API | <count> | <time> |
| — | Registered Devices | Microsoft Graph API | <count> | <time> |
| — | Risk Profile | Microsoft Graph API | 1 | <time> |
| — | Risk Detections | Microsoft Graph API | <count> | <time> |
| — | Risky Sign-ins | Microsoft Graph API (beta) | <count> | <time> |
*Query definitions: see the Sample KQL Queries section in this SKILL.md file.*
**Do NOT include full KQL text in the appendix** — the canonical queries are already documented in this SKILL.md file. The appendix serves as an audit trail only.
---
**Investigation Timeline:**
- [MM:SS] ✓ Phase 1: User ID retrieval (<X>s)
- [MM:SS] ✓ Phase 2: Parallel data collection (<X>s)
- [MM:SS] ✓ IP Enrichment (<X>s)
- [MM:SS] ✓ Phase 3: Report generation (<X>s)
- **Total Investigation Time:** <duration>
✅ No <X> detected... pattern for empty sections.generate_report_from_json.py (risk factors × 10 − mitigating factors × 5 + baseline 30, capped 0–100).enrich_ips.py for IP intelligence data. If enrich_ips.py is unavailable, use Sentinel ThreatIntelIndicators and Signinlogs_Anomalies_KQL_CL data as fallback.reports/ which is gitignored. However, exercise caution with any files that may be shared externally.copilot-instructions.md for all risk/status indicators.Export MCP query results to a single JSON file with these required keys:
{
"upn": "[email protected]",
"user_id": "<USER_OBJECT_ID>",
"user_sid": "<WINDOWS_SID>",
"investigation_date": "2025-11-23",
"start_date": "2025-11-15",
"end_date": "2025-11-24",
"timestamp": "20251123_164532",
"anomalies": [...],
"signin_apps": [...],
"signin_locations": [...],
"signin_failures": [...],
"signin_ip_counts": [...],
"audit_events": [...],
"office_events": [...],
"dlp_events": [...],
"incidents": [...],
"user_profile": {
"id": "...",
"displayName": "...",
"userPrincipalName": "...",
"mail": "...",
"userType": "...",
"jobTitle": "...",
"department": "...",
"officeLocation": "...",
"accountEnabled": true
},
"mfa_methods": {...},
"devices": [...],
"risk_profile": {...},
"risk_detections": [...],
"risky_signins": [...],
"threat_intel_ips": [...]
}
| Issue | Solution |
|-------|----------|
| Missing department or officeLocation | Use "Unknown" as default value |
| No anomalies found | Export empty array: "anomalies": [] |
| Graph API returns 404 for user | Verify UPN is correct |
| Sentinel query timeout | Reduce date range or add | take 5 |
| Missing trustType in device query | Use default: "Workplace" |
| No results from SecurityIncident query | Ensure using ALL THREE identifiers (UPN, UserID, SID) |
| Risky sign-ins query fails | Must use /beta endpoint |
{
"department": "Unknown",
"officeLocation": "Unknown",
"trustType": "Workplace",
"approximateLastSignInDateTime": "2025-01-01T00:00:00Z"
}
{
"anomalies": [],
"signin_apps": [],
"signin_locations": [],
"signin_failures": [],
"audit_events": [],
"office_events": [],
"dlp_events": [],
"incidents": [],
"risk_detections": [],
"risky_signins": [],
"threat_intel_ips": []
}
This skill follows all patterns from the main copilot-instructions.md:
create_file for all outputcopilot-instructions.md for authentication tracing workflowsExample invocations:
After generating a user investigation report (markdown file output), an SVG dashboard can be created using the shared SVG rendering skill.
Trigger: User asks "generate an SVG dashboard from the report" or "visualize this report"
Workflow:
svg-widgets.yaml (widget manifest — defines layout, colors, field mapping).github/skills/svg-dashboard/SKILL.md (rendering rules — component library, quality standards)data_sources.field_mapping_notes{report_basename}_dashboard.svg in the same directoryLayout: 5 rows — title banner, risk score card + KPI cards (sign-ins/success rate/IPs/incidents/anomalies), top apps bar chart + failure codes bar chart, incidents table + risk/mitigating factors table, assessment banner + recommendations.
Last Updated: March 24, 2026
development
Use this skill when asked to investigate a computer, device, endpoint, or machine for security issues, suspicious activity, malware, or compliance review. Triggers on keywords like "investigate computer", "investigate device", "investigate endpoint", "check machine", "device security", "endpoint investigation", or when a device name/hostname is mentioned with investigation context. This skill provides comprehensive device security analysis including Defender alerts, sign-in patterns, logged-on users, vulnerabilities, software inventory, compliance status, network activity, and automated investigation tracking for Entra Joined, Hybrid Joined, and Entra Registered devices.
development
Recommended starting point for new users and daily SOC operations. Quick 15-minute security posture scan across 7 domains: active incidents, identity (human + NonHuman), endpoint, email threats, admin & cloud ops, and exposure. 12 queries executed in parallel batches, producing a prioritized Threat Pulse Dashboard with color-coded verdicts (🔴 Escalate / 🟠 Investigate / 🟡 Monitor / ✅ Clear) and drill-down recommendations pointing to specialized skills. Trigger on getting-started questions like "what can you do", "where do I start", "help me investigate". Supports inline chat and markdown file output
development
Use this skill when asked to generate SVG data visualization dashboards from investigation data or skill reports. Triggers on keywords like "generate SVG dashboard", "create a visual dashboard", "visualize this report", "SVG from the report", "visualize results", "create SVG chart", "SVG from this data". Supports two modes: manifest-driven structured dashboards (from skill reports with svg-widgets.yaml) and freeform adaptive visualizations from ad-hoc investigation data. Component library includes KPI cards, score cards, bar charts, line charts, donut charts, waterfall charts, tables, recommendation cards, assessment banners. SharePoint Dark Theme default palette.
development
Sentinel Ingestion Report — YAML-driven PowerShell pipeline gathers all data via az monitor/az rest/Graph API, writes a deterministic scratchpad, LLM renders the report. Covers table-level volume breakdown, tier classification (Analytics/Basic/Data Lake), SecurityEvent/Syslog/CommonSecurityLog deep dives, ingestion anomaly detection (24h and WoW), analytic rule inventory via REST API, rule health via SentinelHealth, detection coverage cross-reference, tier migration candidates with DL-eligibility lookup, license benefit analysis (DfS P2 500MB/server/day, M365 E5 data grant). Inline chat and markdown file output.