Deepsec Documentation Guide
vercel-labs/deepsec
Points the agent at deepsec's own docs to answer questions about initializing, configuring, resuming, scanning with and extending the vulnerability scanner.
Recommended starting point for new users and daily SOC operations.
$ npx skills add SCStelz/security-investigator --skill threat-pulse -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install SCStelz/security-investigator threat-pulse --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.github/skills/threat-pulse .claude/skills/threat-pulse && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "threat-pulse" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/threat-pulse into .claude/skills/threat-pulse/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "threat-pulse", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/SCStelz/security-investigator/tree/main/.github/skills/threat-pulseType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add SCStelz/security-investigator --skill threat-pulse -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install SCStelz/security-investigator threat-pulse --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.github/skills/threat-pulse .agents/skills/threat-pulse && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "threat-pulse" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/threat-pulse into .agents/skills/threat-pulse/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "threat-pulse", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add SCStelz/security-investigator --skill threat-pulse -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install SCStelz/security-investigator threat-pulse --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.github/skills/threat-pulse .cursor/skills/threat-pulse && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "threat-pulse" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/threat-pulse into .cursor/skills/threat-pulse/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "threat-pulse", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/SCStelz/security-investigator.git --path .github/skills/threat-pulse--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add SCStelz/security-investigator --skill threat-pulse -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install SCStelz/security-investigator threat-pulse --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.github/skills/threat-pulse .gemini/skills/threat-pulse && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "threat-pulse" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/threat-pulse into .gemini/skills/threat-pulse/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "threat-pulse", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install SCStelz/security-investigator threat-pulseInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add SCStelz/security-investigator --skill threat-pulse -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .github/skills && cp -r skills-src/.github/skills/threat-pulse .github/skills/threat-pulse && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "threat-pulse" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/threat-pulse into .github/skills/threat-pulse/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "threat-pulse", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add SCStelz/security-investigator --skill threat-pulse -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install SCStelz/security-investigator threat-pulse --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.github/skills/threat-pulse .opencode/skills/threat-pulse && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "threat-pulse" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/threat-pulse into .opencode/skills/threat-pulse/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "threat-pulse", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
threat-pulseRecommended starting point for new users and daily SOC operations.
Threat Pulse is an agent skill from SCStelz/security-investigator. Recommended starting point for new users and daily SOC operations. 15-minute broad security scan across 7 domains (incidents, identity, NHI, endpoint, email, admin/cloud, exposure) producing a Threat Pulse Dashboard with drill-down recommendations to specialized skills. Trigger on getting-started questions like "where do I start", "what can you do", "help me investigate".
Its SKILL.md is about 25k tokens, which your agent loads only when the skill is triggered. The skill folder holds 1 other file (for example `svg-widgets.yaml`).
It sits in Security, covering Security review. The repository describes itself as: Automated security investigation tool using Microsoft MCP Servers, GitHub Copilot, Python Modules and custom copilot-instructions. The licence is MIT.
5 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit 51e1385. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
pythonFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
security.microsoft.comAlso links to:
learn.microsoft.comFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Threat Pulse loads about 25k tokens when it runs. Until then it costs about 97 tokens; SKILL.md has 8,537 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from SCStelz/security-investigator at commit 51e1385, republished under its MIT licence (© SCStelz). 8,537 words, ~24,805 tokens.
.claude/skills/threat-pulse/SKILL.md (or your agent's skills folder). This skill also uses 1 other file; get the full folder from GitHub.The Threat Pulse skill is a rapid, broad-spectrum security scan designed for the "if you only had 15 minutes" scenario. It executes 12 queries across 7 security domains in parallel, producing a prioritized dashboard of findings with drill-down recommendations to specialized investigation skills.
What this skill covers:
| Domain | Key Questions Answered |
|---|---|
| 🔴 Incidents | What incidents are open and unresolved? Prioritizes High/Critical, backfills with Medium/Low in smaller environments. How old are they? Who owns them? What was recently resolved — TP rate, MITRE tactics, severity distribution? |
| 🔐 Identity (Human) | Which users have the highest Defender XDR Risk Score (0-100)? Which are flagged by Identity Protection (RiskLevel/RiskStatus)? What risk events are driving the signals? Are there password spray / brute-force patterns? |
| 🤖 Identity (NonHuman) | Which service principals expanded their resource/IP/location footprint? |
| 💻 Endpoint | Which endpoints deviated most from their process behavioral baseline? What singleton process chains exist? |
| 📧 Email Threats | What's the phishing/spam/malware breakdown? Were any phishing emails delivered? |
| 🔑 Admin & Cloud Ops | What mailbox rules, OAuth consents, transport rules, or mailbox permission changes occurred? Is there programmatic mailbox access via API? Any MCAS-flagged compromised sign-ins? Human-initiated CA policy changes? Who performed high-impact admin operations — role assignments, MFA registration, app registration, ownership grants? |
| 🛡️ Exposure | Are any critical assets internet-facing with RCE vulnerabilities? What exploitable CVEs (CVSS ≥ 8) are present across the fleet? |
Data sources: SecurityIncident, SecurityAlert, IdentityInfo, AADUserRiskEvents, EntraIdSignInEvents, DeviceProcessEvents, DeviceLogonEvents, ExposureGraphNodes, AADServicePrincipalSignInLogs, EmailEvents, CloudAppEvents, AuditLogs, DeviceTvmSoftwareVulnerabilities, DeviceTvmSoftwareVulnerabilitiesKB
Portal URL patterns are defined in the Defender XDR Portal Links table in the Take Action section. Append tid=<tenant_id> (from config.json) to ALL security.microsoft.com URLs — use ?tid= or &tid= depending on existing query params. Omit if tenant_id is not configured.
Workspace selection — Follow the SENTINEL WORKSPACE SELECTION rule from copilot-instructions.md. Call list_sentinel_workspaces() before first query.
Read config.json — Load workspace ID, tenant, subscription, and Azure MCP parameters before execution.
Output defaults — Default to inline chat with 7d lookback. Only ask the user for output preferences if they explicitly mention a different mode (e.g., "save to file", "markdown report", "30 day lookback"). If the user just says "threat pulse", "run a scan", or similar — proceed immediately with defaults, do not prompt.
⛔ MANDATORY: Evidence-based analysis only — Every finding must cite query results. Every "clear" verdict must cite 0 results. Follow the Evidence-Based Analysis rule from copilot-instructions.md.
Parallel execution — Run the Data Lake query (Q5) and all Advanced Hunting queries (Q1, Q2, Q3, Q4, Q6, Q7, Q8, Q9, Q10, Q11, Q12) simultaneously.
Cross-query correlation — After all queries complete, check for correlated findings per the Cross-Query Correlation table in Post-Processing. Escalate priority when patterns match.
SecurityIncident output rule — Every incident MUST include a clickable Defender XDR portal URL: https://security.microsoft.com/incidents/{ProviderIncidentId}?tid=<tenant_id>. See Tenant ID in Portal URLs.
⛔ MANDATORY: Query File Recommendations (tiered) — After rendering the main report body (Dashboard Summary through Recommended Actions), append the Query File Recommendations section. This runs AFTER the report is visible to the user — not as a blocking gate. Skip only when ALL verdicts are ✅.
⛔ MANDATORY: 30d drill-down lookback — ALL Phase 4 drill-down queries use 30d (AH) or 90d (Data Lake) lookback, regardless of the Threat Pulse scan window. Entity-scoped queries (filtered by UPN/IP/device) have negligible performance difference between 7d and 30d, and attacks routinely predate the pulse window. AH caps at 30d anyway. Substitute ago(7d) → ago(30d) in all query file and skill queries during drill-downs.
| Highest Verdict | Query Files | Proactive Skills | Report Section |
|---|---|---|---|
| 🔴 or 🟠 | Top 3–5, entity-specific prompts | All matching skills | 📂 Recommended Query Files |
| 🟡 (no 🔴/🟠) | Top 1–2, broader prompts | Up to 3 posture skills | 📂 Proactive Hunting Suggestions |
| All ✅ | Skip | Skip | Omit entirely |
/memories/session/threat-pulse-drilldowns.md. It MUST be created the first time the pool is built (Phase 4 step 1) and is a hard precondition for rendering any selection list. If you are about to present follow-up options and this file does not exist, STOP and create it first. NEVER reconstruct the pool from conversation history — always memory view immediately before each vscode_askQuestions call.vscode_askQuestions again with the updated pool — without waiting for the user to ask for the menu. The only exits are the user selecting Skip, or an empty pool. "Bring the menu back up" should never be something the user has to request.recommended keys, multiSelect: true, correct icon taxonomy (🔍 📄 🎯 💾 🆕 🔄 📋), and the 💾 / 🔄 / Skip tail every iteration. Do not substitute an ad-hoc "Done" option for the contracted tail.Read config.json for workspace ID and Azure MCP parameters
Call list_sentinel_workspaces() to enumerate available workspaces
Use defaults (inline chat, 7d) unless user specified otherwise
⛔ MANDATORY: Display scan summary — Before executing any queries, output the following brief to the user as plain markdown text (NOT inside a fenced code block, NOT as inline code). Use the exact heading, line breaks, and emoji-prefixed bullet items shown below. Substitute <WorkspaceName>, <WorkspaceId>, lookback, and output format. Never skip this step — it sets analyst expectations for what's about to run.
🔍 Threat Pulse — Scan Plan
Workspace: <WorkspaceName> (<WorkspaceId>) Lookback: <N>d Output: <Inline / Markdown file / Both>
Executing 12 queries across 7 domains:
🔴 Incidents — Open incidents (severity-ranked) + 7d closed summary (Q1, Q2) 🔐 Identity — Identity risk posture, risk event enrichment, auth spray (Q3, Q4) 🤖 NonHuman ID — Service principal behavioral drift (Q5) 💻 Endpoint — Device process drift, rare process chains (Q6, Q7) 📧 Email — Inbound threat snapshot (Q8) 🔑 Admin & Cloud — Cloud app ops, privileged operations (Q9, Q10) 🛡️ Exposure — Critical assets, exploitable CVEs (Q11, Q12)
Data Lake: 1 query | Advanced Hunting: 11 queries in parallel Estimated time: ~2–4 minutes
Why only 1 query on Data Lake? Q5 requires a 97-day lookback for SPN baseline computation — AH Graph API caps at 30 days. All other queries use ≤30d lookback on Analytics-tier tables accessible via AH.
| Query | Domain | Purpose | Tool |
|---|---|---|---|
| Q5 | 🤖 Identity (NonHuman) | Service principal behavioral drift (90d vs 7d) | query_lake |
Run all 11 in parallel — no dependencies between queries.
Design rationale: The connected LA workspace makes all Sentinel tables (SecurityIncident, IdentityInfo, AADUserRiskEvents, AuditLogs, etc.) queryable via AH. AH is preferred: it's free for Analytics-tier tables and avoids per-query Data Lake billing.
| Query | Domain | Purpose | Tool |
|---|---|---|---|
| Q1 | 🔴 Incidents | Open incidents (severity-ranked backfill) with MITRE tactics | RunAdvancedHuntingQuery |
| Q2 | 🔴 Incidents | 7-day closed incident summary (classification, MITRE, severity) | RunAdvancedHuntingQuery |
| Q3 | 🔐 Identity (Human) | Identity risk posture (IdentityInfo) + risk event enrichment (AADUserRiskEvents) | RunAdvancedHuntingQuery |
| Q4 | 🔐 Identity (Human) | Password spray / brute-force across Entra ID + RDP/SSH | RunAdvancedHuntingQuery |
| Q6 | 💻 Endpoint | Fleet device process drift (7d baseline vs 1d) | RunAdvancedHuntingQuery |
| Q7 | 💻 Endpoint | Rare process chain singletons (30d) | RunAdvancedHuntingQuery |
| Q8 | Inbound email threat snapshot | RunAdvancedHuntingQuery | |
| Q9 | 🔑 Admin & Cloud Ops | Cloud app suspicious activity (CloudAppEvents) | RunAdvancedHuntingQuery |
| Q10 | 🔑 Admin & Cloud Ops | High-impact admin operations (AuditLogs) | RunAdvancedHuntingQuery |
| Q11 | 🛡️ Exposure | Internet-facing critical assets | RunAdvancedHuntingQuery |
| Q12 | 🛡️ Exposure | Exploitable CVEs (CVSS ≥ 8) across fleet | RunAdvancedHuntingQuery |
📂 Recommended Query Files section. This happens while the user is already reading the report — no perceived delay. Skip entirely when all verdicts are ✅.Performance note: The Recommendation Gate was previously a blocking step (Phase 3.5) that loaded the ~500-line manifest YAML and ranked entries before the report could render. By moving it after the report output, the user sees findings immediately while recommendations load in the background. The Phase 4 prompt pool building also benefits — it reuses the recommendations already computed in step 6 rather than re-scanning all 12 query results independently.
After rendering the report, present the user with a selectable list of follow-up actions — skill investigations, query file hunts, and IOC lookups. Runs when at least one 🔴, 🟠, or 🟡 verdict exists (skip only when ALL verdicts are ✅).
This is a loop, not a one-shot. After each action completes, re-present the selection list with the prompt pool updated. Tier depth (🔴/🟠 vs 🟡-only vs all ✅) follows Rule 8.
⛔ Loop invariant — verify before EVERY iteration (per Rule 10): (a)
/memories/session/threat-pulse-drilldowns.mdexists and was just re-read viamemory view— if not, create/read it first; (b) you are re-presenting the menu automatically after the prior drill-down, not because the user asked; (c) the Pre-Flight Checklist passed and the Pool Receipt was printed. If any of the three is false, fix it before callingvscode_askQuestions. The loop only ends onSkipor an empty pool.
Prompt types (three categories, one unified list):
| Type | Icon | Source | Example |
|---|---|---|---|
| Skill investigation | 🔍 | Per-query Drill-down: skill + entities from findings | 🔍 Investigate user jsmith@contoso.com → user-investigation |
| Query file hunt | 📄 | Manifest domain + MITRE matching → query file | 📄 Hunt for RDP lateral movement from 10.0.0.50 → queries/endpoint/rdp_threat_detection.md |
| IOC lookup | 🎯 | Suspicious IPs, domains, hashes surfaced in findings | 🎯 Enrich and investigate IP 203.0.113.42 → ioc-investigation |
Skill matching rules — derive from findings:
| Query | Trigger | Skill | Prompt |
|---|---|---|---|
| Q1 | Incident surfaced | incident-investigation | Investigate incident <ProviderIncidentId> |
| Q1 | Incident with Exfiltration tactic or DLP/Insider Risk in AlertNames | data-security-analysis | Analyze data security events for <entity> |
| Q2 | TruePositive > 0 with non-empty Techniques array | mitre-coverage-report | Run MITRE coverage report |
| Q3–Q4 | Username/UPN in findings | user-investigation | Investigate <UPN> |
| Q3 | 3+ risky users, or any ConfirmedCompromised | identity-posture | Run identity posture report |
| Q3 | User with anonymizedIPAddress, impossibleTravel, or anomalousToken in TopRiskEventTypes | authentication-tracing | Trace authentication chain for <UPN> |
| Q3 | User with unfamiliarFeatures or suspiciousAPITraffic in TopRiskEventTypes | scope-drift-detection/user | Analyze user behavioral drift for <UPN> |
| Q3+Q4 | 🟡-only identity verdicts (no 🔴/🟠) | identity-posture | Run identity posture report |
| Q4 | Spray source IP | ioc-investigation | Investigate IP <address> |
| Q4 | Spray targeting 5+ users | identity-posture | Run identity posture report |
| Q5 | SPN with drift | scope-drift-detection/spn | Analyze drift for <SPN> |
| Q6 | Device with DriftScore > 130 | scope-drift-detection/device | Analyze device process drift for <hostname> |
| Q6–Q7 | Device in findings | computer-investigation | Investigate device <hostname> |
| Q8 | Phishing delivered or malware detected | email-threat-posture | Run email threat posture report |
| Q8+Q3 | Phishing recipient appears in Q3 risky users | authentication-tracing | Trace authentication chain for <UPN> |
| Q9 | Compromised Sign-In user surfaced | user-investigation + authentication-tracing | Investigate <UPN> / Trace authentication chain for <UPN> |
| Q9 | Conditional Access Change by human actor | ca-policy-investigation | Investigate CA policy changes by <UPN> |
| Q9 | Exchange Admin/Rule Change actors | user-investigation | Investigate <UPN> |
| Q10 | MFA-Registration user | user-investigation | Investigate <UPN> |
| Q10 | AppRegistration or Ownership operations | app-registration-posture | Run app registration posture report |
| Q10 | AppRegistration targets containing AI/Agent/Copilot keywords | ai-agent-posture | Run AI agent security audit |
| Q10 | RoleManagement Global/Security Admin OR bulk Password resets from single actor | identity-posture | Run identity posture report |
| Q10 | 3+ categories with same actor in TopActors | user-investigation | Investigate <UPN> |
| Q11 | Any IsVerifiedExposed == true asset | exposure-investigation | Run exposure report for <hostname> |
| Q11–Q12 | Device in findings | computer-investigation | Investigate device <hostname> |
| Q12 | CVE with fleet impact | exposure-investigation | Run vulnerability report for <CVE> |
Drill-down lookback — Per Rule 9, substitute
ago(7d)→ago(30d)(AH) orago(90d)(Data Lake) in all drill-down queries.
Procedure:
Build the initial prompt pool by combining:
/memories/session/threat-pulse-drilldowns.md using the exact template below. The format banner is mandatory — it makes the — delimiter contract visible to every iteration and every LLM that edits the file. This memory block is the single source of truth; conversation history is not.# Threat Pulse Session — <YYYY-MM-DD>
**Workspace:** <name> (<id>)
**Lookback:** <7d|30d|90d>
**Scan Start:** <YYYY-MM-DD HH:MM UTC>
## Active Prompt Pool
<!-- FORMAT: `- <ICON> <action> <entity> — Q<N>: <finding> → <skill-or-query-file>` -->
<!-- ` — ` (space-emdash-space) is the REQUIRED label/description split delimiter. -->
<!-- One icon per line. Order = file position (no numbering). Do not edit this comment block. -->
- 🔍 Investigate incident #<IncidentId> — Q1: <brief finding>, <N> alerts, <MITRE-ID> → incident-investigation
- 🎯 Enrich and investigate IP <IP> — Q4: <N> spray attempts / <N> users → ioc-investigation
...
## Pulse Key Findings (quick reference)
...
## Completed Drill-Downs
_(none yet)_Call vscode_askQuestions using the Quick Pick Call Contract below. Apply identically on every iteration.
header: Follow-Up Investigationquestion: Select one or more actions to launch (or skip):options: entity prompts (from memory), then 📋 (if truncated), then 💾 / 🔄 / Skip as the final three — in that order, every iteration. 🆕 prompts prepend to the entity portion only.💾 Save full investigation report — Save the complete Threat Pulse session (scan + all drill-downs) as a markdown file🔄 Refresh prompt pool — Rebuild the follow-up prompt list from existing pulse + drill-down findings (does NOT re-run the 12 pulse queries)Skip — No follow-up — investigation complete🔍 📄 🎯 💾 🆕 🔄 📋. Verdict emoji (🔴🟠🟡🟢✅) are banned from Labels (render as �� in VS Code quick picks) but fine in Descriptions. Drop 💾 after report is saved; 🔄 and Skip always remain.vscode_askQuestions call□ 1. memory view → read `## Active Prompt Pool` just now (not earlier)
□ 2. Count entity prompts (exclude 💾/🔄/📋/Skip) = N
□ 3. Format integrity: every entity line starts with `- ` followed by exactly ONE icon. Any legacy `<N>.` prefix → migrate to `- ` first, re-read, then continue.
□ 4. If N > 12: render top 12 (🆕 first, then memory order) + append `📋 Show full prompt pool (N items)`
□ 5. For each rendered option: split memory line at FIRST ` — ` → label = text after `- ` up to delimiter, description = right, BYTE-FOR-BYTE (no paraphrasing; if something is missing, edit memory first then re-read)
□ 6. Atomic check: each option Label has exactly ONE icon; Description has at most ONE `→ target`
□ 7. `multiSelect: true` in call args
□ 8. ZERO `recommended` keys anywhere in options[]
□ 9. Tail = 💾 / 🔄 / Skip (or 📋 / 💾 / 🔄 / Skip if truncated)
□ 10. Print the Pool Receipt line to chat BEFORE invoking the toolPool Receipt (box 10) — one-liner printed to chat so contract violations are user-visible:
📊 Pool: <N> total / rendering <R> (🆕×<a>, 🔍×<b>, 📄×<c>, 🎯×<d>) / truncated <✔|—> | multiSelect=true ✔ | recommended=0 ✔If user selects 📋: re-invoke with all entity prompts (drop 📋, keep 💾/🔄/Skip tail).
If user selects Skip (alone) or pool is empty: end skill execution. Ignore any freeform text if Skip is selected.
Freeform input routing — If user types freeform text instead of (or alongside) selecting options, route by matching intent to validated sources. Do NOT write ad-hoc KQL — find the right skill or query file first. Classified actions feed into step 7 alongside any selected options.
exposure-investigation or computer-investigation. Route as 🔍 — the read_file gate in step 7 applies.grep_search the request's key terms (table names, operations, attack types) against queries/**. "Check forwarding rules" → queries/email/email_threat_detection.md. Route as 📄.💾 Save full investigation report selected:
/memories/session/threat-pulse-drilldowns.md (critical after context compaction) and compile pulse dashboard + all drill-down findings into a single markdown file using the Report Template (file mode). Weave drill-down insights into the main report — do NOT just append raw output.Drill-Down Investigation Results and Cross-Investigation Correlation sections with note: "No drill-down investigations were performed in this session."reports/threat-pulse/Threat_Pulse_YYYYMMDD_HHMMSS.md. Drop 💾 from subsequent pool iterations.🔄 Refresh prompt pool selected — prompt list ONLY, no KQL execution. Refresh rebuilds the follow-up list; it does NOT re-run Q1–Q12 and does NOT re-run drill-downs. Discard the current pool, rebuild by re-applying Query File Recommendations and the skill matching table against pulse findings + all drill-down findings in memory. Deduplicate against completed prompts. If selected alongside other actions, refresh FIRST, then present the new pool before executing the others.
One or more actions selected — execute sequentially. Build a todo list (one item per action). For each:
read_file the child SKILL.md BEFORE writing ANY query → find Investigation shortcut → match TP Q# trigger → execute with entity substitution. Writing KQL without the prior read_file = schema hallucination. See 🔍 Skill Drill-Down Execution Rule.ioc-investigation skill with the target indicator.After each drill-down, append a session-state entry to /memories/session/threat-pulse-drilldowns.md under ## Completed Drill-Downs:
### <N>. <Prompt Label> (<skill-name>, <YYYY-MM-DD HH:MM>)
- **Entity:** <target entity>
- **Trigger:** Q<N> — <original finding>
- **Key Findings:** <1–8 bullets, evidence-cited>
- **Risk Assessment:** <emoji> <level> — <1-line justification>
- **Cross-References:** <overlaps with other drill-downs or pulse queries>
- **Recommendations:** <top 1–3 actions>This survives context compaction and feeds the 💾 Save report.
Before returning to step 2 — MANDATORY, in order:
.github/manifests/discovery-manifest.yaml (match by domains, mitre, or title). Only fall back to ad-hoc KQL if nothing matches.memory view ## Active Prompt Pool → delete the completed bullet line(s) → prepend 🆕 prompts as new bullet lines (- <ICON> ...) → memory str_replace. Every entity line is a bullet — no ordinals, so adding/removing items never requires renumbering. Never reconstruct the pool from conversation history.vscode_askQuestions.Atomic options — ONE action per option. Each option maps to ONE skill + ONE entity, OR ONE query file. When correlations link findings (e.g., Q3+Q9 same user), generate separate options, put the correlation in the Description. Bundling multiple actions/arrows in a single option is the #1 follow-up mistake.
✅ Correct: 🔍 Investigate user cameron@contoso.com / desc Q3+Q9: identity risk + inbox rule manipulation → user-investigation
❌ Wrong: 🔍 Investigate cameron ... → user-investigation, 📄 Hunt phishing → queries/email/...
⛔ MANDATORY — applies to ALL 📄 query file prompt executions in Phase 4.
When executing a 📄 prompt, use the queries from the file verbatim with entity substitution. Do NOT rewrite queries against different tables than the file specifies.
(TP Q#) annotation to the triggering Threat Pulse query to identify the recommended query chain. Follow that chain for the huntago(Nd) lookback if context-aware expansion applies== for device/computer filters — use startswith (default, case-insensitive, matches both short name and FQDN), or in~ (multi-device). Override == in query file entity substitution notes with startswith.| Action | Status |
|---|---|
| Reading a query file then writing queries against a different table | ❌ PROHIBITED |
| Using the query file as "inspiration" and rewriting from scratch | ❌ PROHIBITED |
| Executing the file's queries verbatim with entity substitution | ✅ REQUIRED |
⛔ MANDATORY — applies to ALL 🔍 skill drill-down executions in Phase 4.
When executing a skill drill-down, load the child skill's SKILL.md and use its validated queries. Do NOT write ad-hoc queries from memory — schema hallucination (wrong column names, wrong table) is the #1 drill-down failure mode.
project/summarize by, or restructure. Column names vary across Device* tables; the SKILL.md queries already use the correct ones.| Action | Status |
|---|---|
| Writing ad-hoc KQL without loading the child SKILL.md | ❌ PROHIBITED |
| Loading SKILL.md then modifying its queries (adding/changing columns, restructuring) | ❌ PROHIBITED |
| Using SKILL.md queries verbatim with entity substitution | ✅ REQUIRED |
⚠️ AI-generated content may be incorrect. Always review Take Action queries and portal links for accuracy before executing remediation actions.
After every non-✅ drill-down that surfaces actionable entities, append a 🎬 Take Action section with direct portal links (single entities) or Advanced Hunting queries (bulk entities). Ref: Take action on AH results
Every 🎬 Take Action heading in the output — this one and every subsequent one — MUST be immediately followed by the AI-content warning blockquote above.
Skip when: verdict is ✅/🔵, or the action was already taken (e.g., ZAP purged emails).
The remediation format depends on how many entities need action.
| Scenario | Format |
|---|---|
| 1 entity (user, device, IP, domain, hash) | Direct Defender XDR portal link (see Portal Links table for URL patterns) |
| 2+ emails | AH query with NetworkMessageId in (...) → Take actions |
| 2+ devices | AH query with DeviceName in~ (...) → Take actions |
| 2+ IPs/domains/hashes | AH query → click value in results → Add Indicator (allow/warn/block) |
⛔ PROHIBITED: Generating an AH query for a single entity when a direct portal link would suffice. AH Take Action is for bulk remediation — for a single entity, link directly to the portal page where the analyst can act.
ID sources (agent retrieves silently — never ask the user):
/v1.0/users/<UPN>?$select=id or IdentityInfo.AccountObjectIdDeviceInfo.DeviceId or GetDefenderMachine API⛔ Never emit prompts like "Retrieve the DeviceId" — run the lookup and emit the finished link in the same turn.
Missing a required column silently disables the action menu. Always include these:
| Entity | Required Columns | Actions | Notes |
|---|---|---|---|
NetworkMessageId, RecipientEmailAddress | Soft/hard delete, move to folder, submit to Microsoft, initiate investigation | Do NOT use project — Submit to Microsoft and Initiate Automated Investigation require undocumented columns that project strips, silently greying out those options. The portal's Show empty columns toggle only works when columns exist in the result schema. Return all columns; use where to scope results. | |
| 💻 Device | DeviceId | Isolate, collect investigation package, AV scan, initiate investigation, restrict app execution | Use summarize arg_max(Timestamp, *) by DeviceId for latest state |
| 📁 File | SHA1 or SHA256 + DeviceId | Quarantine file | Both hash and device required |
| 🔗 Indicator | IP, URL/domain, or SHA hash column | Add indicator: allow, warn, or block | An AH query is still required to surface the values as clickable — there is no Take actions dropdown button. Instead, click any IP/URL/hash value directly in the AH results → Add indicator to create a Defender for Endpoint custom indicator |
| 🔐 Identity | (No AH Take Action) | Confirm compromised, revoke sessions, suspend in app | Single user: Direct Defender XDR Identity page link. Never generate an AH query for identity remediation |
📧 Email — by NetworkMessageId: (no project — see Email row above)
EmailEvents
| where Timestamp > ago(7d)
| where NetworkMessageId in ("<id1>", "<id2>")→ Take actions → Move to mailbox folder, Delete email (soft/hard), Submit to Microsoft, Initiate automated investigation
📧 Email — by compromised sender domain:
EmailEvents
| where Timestamp > ago(30d)
| where SenderFromDomain =~ "<domain>" and ThreatTypes has "Phish" and DeliveryAction == "Delivered"
| take 500→ Take actions → Move to mailbox folder, Delete email (soft/hard), Submit to Microsoft, Initiate automated investigation
💻 Single Device — direct portal link:
Link to the Defender XDR machine page. If DeviceId isn't in context, look it up yourself:
DeviceInfo | where DeviceName startswith '<name>' | summarize arg_max(Timestamp, *) by DeviceId | project DeviceIdThen emit: [<DeviceName>](https://security.microsoft.com/machines/v2/<DeviceId>?tid=<tenant_id>). Never fabricate URLs with ?DeviceName=, /machines?, or bare hostnames.
→ Machine page → Response actions → Isolate device, Collect investigation package, Run antivirus scan, Initiate investigation, Restrict app execution
💻 Bulk Devices (2+) — AH query:
DeviceInfo
| where Timestamp > ago(1d)
| where DeviceName in~ ("<device1>", "<device2>")
| summarize arg_max(Timestamp, *) by DeviceId
| project DeviceId, DeviceName, OSPlatform, MachineGroup→ Take actions → Isolate device, Collect investigation package, Run antivirus scan, Initiate investigation, Restrict app execution
📁 File — by hash:
Source-aware table selection. SHA hashes appear across many tables (
DeviceProcessEvents,DeviceImageLoadEvents,DeviceFileEvents,AlertEvidence). UseDeviceFileEventsas the default — it captures file writes and has the columns needed for Quarantine. If the hash was only observed via process execution (no separate file write event), substitute or union withDeviceProcessEvents. The Quarantine action requiresDeviceId+SHA1/SHA256regardless of source table.
File write events (default — DeviceFileEvents):
DeviceFileEvents
| where Timestamp > ago(7d)
| where SHA1 == "<hash>" or SHA256 == "<hash>"
| project DeviceId, DeviceName, SHA1, SHA256, FileName, FolderPathProcess execution events (when file write not captured — DeviceProcessEvents):
DeviceProcessEvents
| where Timestamp > ago(7d)
| where SHA1 == "<hash>" or SHA256 == "<hash>"
| project DeviceId, DeviceName, SHA1, SHA256, FileName, FolderPath, ProcessCommandLine→ Take actions → Quarantine file
🔗 Bulk Indicators (2+ IPs/domains/hashes) — AH query for Add Indicator:
When blocking multiple IPs, domains, or hashes, provide an AH query that surfaces the values as clickable columns. There is no Take actions dropdown — the analyst clicks each value directly in results → Add indicator.
Source-aware table selection. The table MUST match where the IPs were originally discovered.
DeviceNetworkEventsis the default for network-layer IPs (endpoint connections, firewall events). However, IPs from authentication-layer sources (AADUserRiskEvents,EntraIdSignInEvents,SigninLogs,AADServicePrincipalSignInLogs) may never appear in endpoint network events — queryingDeviceNetworkEventsfor those returns 0 results. Use the originating table so the analyst sees the IPs in context and can click to add indicators.
Network-layer IPs (from DeviceNetworkEvents, DeviceLogonEvents, firewall logs):
// Surface attacker IPs as clickable values for Add Indicator
DeviceNetworkEvents
| where Timestamp > ago(7d)
| where RemoteIP in ("<ip1>", "<ip2>", "<ip3>")
| summarize Connections = count(), Ports = make_set(LocalPort) by RemoteIP
| order by Connections descAuth-layer IPs (from AADUserRiskEvents, EntraIdSignInEvents, SigninLogs):
// Surface attacker IPs from sign-in/risk events for Add Indicator
EntraIdSignInEvents
| where Timestamp > ago(30d)
| where IPAddress in ("<ip1>", "<ip2>", "<ip3>")
| summarize SignIns = count(), Users = dcount(AccountUpn), Countries = make_set(Country, 5) by IPAddress
| order by SignIns desc→ Click any IP value in results → Add indicator → Block and remediate
Variant — domains/URLs:
DeviceNetworkEvents
| where Timestamp > ago(7d)
| where RemoteUrl has_any ("<domain1>", "<domain2>")
| summarize Connections = count() by RemoteUrl
| order by Connections desc→ Click any RemoteUrl value → Add indicator → Block and remediate
🔴 Every entity (user, domain, URL, IP, file hash) in action/recommendation tables MUST be a clickable Defender XDR portal link — the entity name IS the link. Do NOT add a separate "Portal" column or leave entities as plain text. VS Code renders bare UPNs as mailto: and bare URLs/IPs as broken links.
| Entity | URL Pattern | Example |
|---|---|---|
| User | https://security.microsoft.com/user?aad=<OID>&upn=<UPN>&tab=overview&tid=<tenant_id> | [user@contoso.com](https://security.microsoft.com/user?aad=<OID>&upn=user@contoso.com&tab=overview&tid=<tenant_id>) |
| Domain | https://security.microsoft.com/domains/overview?urlDomain=<domain>&tid=<tenant_id> | [contoso.com](https://security.microsoft.com/domains/overview?urlDomain=contoso.com&tid=<tenant_id>) |
| URL | https://security.microsoft.com/url/overview?url=<url-encoded-URL>&tid=<tenant_id> | [example.com/path](https://security.microsoft.com/url/overview?url=http%3A%2F%2Fexample.com%2Fpath&tid=<tenant_id>) |
| IP | https://security.microsoft.com/ip/<IP>/overview?tid=<tenant_id> | [<IP>](https://security.microsoft.com/ip/<IP>/overview?tid=<tenant_id>) |
| File Hash | https://security.microsoft.com/file/<SHA1-or-SHA256>/?tid=<tenant_id> | [da5e459...b1bb1e](https://security.microsoft.com/file/da5e45915354850261cf0e87dc7af19597b1bb1e/?tid=<tenant_id>) |
| Device | https://security.microsoft.com/machines/v2/<MDE_DeviceId>?tid=<tenant_id> | [<DeviceName>](https://security.microsoft.com/machines/v2/<MDE_DeviceId>?tid=<tenant_id>) |
| SPN / Non-Human Identity | https://security.microsoft.com/identity-inventory?tab=NonHumanIdentities&tid=<tenant_id> | [Non-Human Identities Inventory](https://security.microsoft.com/identity-inventory?tab=NonHumanIdentities&tid=<tenant_id>) |
User fallbacks: ?upn=<UPN> when ObjectId is unavailable; ?sid=<SID>&accountName=<Name>&accountDomain=<Domain> for on-prem AD.
Device ID source: DeviceId from the DeviceInfo AH table or the id field from GetDefenderMachine API. This is the MDE machine identifier — NOT the Entra Device Object ID (which is different). The computer-investigation skill retrieves this in Step 1b.
🔴 Portal URL Allowlist — No Invented Paths. The 7 patterns above plus /v2/advanced-hunting?tid=<tenant_id> are the ONLY security.microsoft.com URLs you may emit. For any other action (Custom Indicators, Safe Links policy, Email Explorer, CA policy editor, Secure Score, etc.), write a textual breadcrumb — e.g., "Defender XDR → Settings → Endpoints → Indicators → URLs/Domains → Add item". Never guess a path from memory.
| Context | Treatment | Example |
|---|---|---|
| Action / Take Action / recommendation tables | Wrap entity name in portal link (from table above). Never defang. | [evil.com](https://security.microsoft.com/domains/overview?urlDomain=evil.com&tid=<tenant_id>) |
| Data / results tables (raw query output) | Defang entity as plain text. Never portal-link. | hxxps://evil[.]com/path |
Defang rules: http:// → hxxp://, https:// → hxxps://, . in domain → [.]. VS Code auto-linkifies anything URL-shaped, which is why defanging is required in data tables. Conversely, a portal-linked entity has the portal URL as the link target, so linkification is safe \u2014 defanging would just break the link.
| Rule | Status |
|---|---|
Every 🎬 Take Action heading immediately followed by the AI-content warning blockquote | ✅ REQUIRED |
| Single entity \u2192 direct portal link (never an AH query) | ✅ REQUIRED |
2+ entities \u2192 AH query with Take Actions, all required columns present, no project on emails | ✅ REQUIRED |
Every AH query includes BOTH a ```kql code block AND a plain [Run in Advanced Hunting](https://security.microsoft.com/v2/advanced-hunting?tid=<tenant_id>) link below it | ✅ REQUIRED |
| Action tables: entity = clickable portal link (from the 7 approved patterns). No separate "Portal" column, no defanging. | ✅ REQUIRED |
| Data tables: entity = defanged plain text. No portal linking. | ✅ REQUIRED |
| Textual breadcrumb ("Defender XDR → …") when no approved portal URL pattern covers the action | ✅ REQUIRED |
Emitting any security.microsoft.com URL outside the 7 approved patterns + /v2/advanced-hunting | ❌ PROHIBITED |
Generating gzip/base64-encoded AH deep links via kql_to_ah_url.py for output | ❌ PROHIBITED |
| Non-✅ drill-down surfaces actionable entities but no Take Action block | ❌ PROHIBITED |
All queries below are verified against live Sentinel/Defender XDR schemas. Use them exactly as written. Lookback periods use
ago(Nd)— substitute the user's preferred lookback where noted.
🔴 Incident hygiene — Surfaces unresolved incidents prioritized by severity (Critical → High → Medium → Low), with age, owner, alert count, MITRE tactics, MITRE technique IDs, and extracted entity names (accounts + devices) for cross-query correlation. In large environments, all 10 slots fill with High/Critical. In smaller environments, Medium/Low backfill remaining slots automatically.
Tool: RunAdvancedHuntingQuery
let OpenIncidents = SecurityIncident
| where TimeGenerated > ago(30d)
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status in ("New", "Active");
let TotalHighCritical = toscalar(OpenIncidents | where Severity in ("High", "Critical") | count);
let TotalAll = toscalar(OpenIncidents | count);
OpenIncidents
| extend SevRank = case(Severity == "Critical", 0, Severity == "High", 1, Severity == "Medium", 2, Severity == "Low", 3, 4)
| extend ParsedLabels = parse_json(Labels)
| mv-apply Label = ParsedLabels on (
summarize Tags = make_set(tostring(Label.labelName), 5)
)
| extend Tags = set_difference(Tags, dynamic([""]))
| mv-expand AlertId = AlertIds | extend AlertId = tostring(AlertId)
| join kind=leftouter (
SecurityAlert
| where TimeGenerated > ago(30d)
| summarize arg_max(TimeGenerated, Entities, Tactics, Techniques, AlertName, AlertSeverity) by SystemAlertId
| extend ParsedEntities = parse_json(Entities)
| mv-expand Entity = ParsedEntities
| extend EntityType = tostring(Entity.Type),
AccountUPN = case(
tostring(Entity.Type) == "account" and isnotempty(tostring(Entity.UPNSuffix)),
tolower(strcat(tostring(Entity.Name), "@", tostring(Entity.UPNSuffix))),
tostring(Entity.Type) == "account" and isnotempty(tostring(Entity.AadUserId)),
tostring(Entity.AadUserId),
""),
HostName = iff(tostring(Entity.Type) == "host", tolower(tostring(Entity.HostName)), "")
| project SystemAlertId, Tactics, Techniques, AlertName, AlertSeverity, AccountUPN, HostName
) on $left.AlertId == $right.SystemAlertId
| mv-expand Technique = parse_json(Techniques)
| extend Technique = tostring(Technique)
| extend TacticsSplit = split(Tactics, ", ")
| mv-expand Tactic = TacticsSplit
| extend Tactic = tostring(Tactic)
| summarize
Tactics = make_set(Tactic),
Techniques = make_set(Technique),
AlertNames = make_set(AlertName, 5),
AlertCount = dcount(AlertId),
Accounts = make_set(AccountUPN, 5),
Devices = make_set(HostName, 5),
Tags = take_any(Tags)
by ProviderIncidentId, Title, Severity, SevRank, Status, CreatedTime,
OwnerUPN = tostring(Owner.userPrincipalName)
| extend Techniques = set_difference(Techniques, dynamic([""]))
| extend Tactics = set_difference(Tactics, dynamic([""]))
| extend Accounts = set_difference(Accounts, dynamic([""]))
| extend Devices = set_difference(Devices, dynamic([""]))
| extend AgeDisplay = case(
datetime_diff('minute', now(), CreatedTime) < 60, strcat(datetime_diff('minute', now(), CreatedTime), "m ago"),
datetime_diff('hour', now(), CreatedTime) < 24, strcat(datetime_diff('hour', now(), CreatedTime), "h ago"),
strcat(datetime_diff('day', now(), CreatedTime), "d ago"))
| extend PortalUrl = strcat("https://security.microsoft.com/incidents/", ProviderIncidentId, "?tid=<TENANT_ID>")
| extend TotalHighCritical = TotalHighCritical, TotalAll = TotalAll
| project TotalHighCritical, TotalAll, ProviderIncidentId, Title, Severity, SevRank, AgeDisplay, AlertCount,
OwnerUPN, Tactics, Techniques, Accounts, Devices, Tags, PortalUrl, AlertNames, CreatedTime
// --- Deduplicate by Title: keep one representative incident per title for variety ---
| as AllOpenIncidents
| join kind=leftouter (
AllOpenIncidents | summarize TitleDupCount = count() by Title
) on Title
| project-away Title1
| order by Title asc, SevRank asc, bin(CreatedTime, 1d) desc, AlertCount desc
| extend _rn = row_number(1, prev(Title) != Title)
| where _rn == 1
| project-away _rn
| order by SevRank asc, bin(CreatedTime, 1d) desc, AlertCount desc
| take 10Purpose: Top 10 open incidents with severity-ranked backfill (Critical→High→Medium→Low). In large envs, all slots fill with High/Critical; small envs backfill with Medium/Low. TotalHighCritical and TotalAll drive the adaptive report header ("Showing 10 of {TotalAll} open incidents ({TotalHighCritical} High/Critical)") and are computed across all open incidents pre-dedup, so header counts stay accurate. The list is deduplicated by Title so the top 10 shows distinct incident types rather than near-identical rows — in noisy envs a single recurring title (password-spray, DLP rule) can otherwise monopolize all 10 slots; the single highest-priority representative per title is kept (severity → newest day → alert count) and TitleDupCount preserves the volume signal. Joins SecurityAlert for MITRE tactics/techniques and extracts Accounts (UPN or AAD ObjectId, lowercased), Devices (hostname, lowercased), and Tags (from Labels — both AutoAssigned ML classifications and User-applied SOC tags) — each capped at 5 per incident — for cross-query correlation with Q3/Q4/Q6/Q7/Q12. Flags unassigned incidents (empty OwnerUPN).
Sort: SevRank asc, bin(CreatedTime, 1d) desc, AlertCount desc — severity tier first, then calendar day (newest first), then complexity within each day.
Verdict logic:
AlertCount > 50, OR any unassigned High/Critical with CredentialAccess/LateralMovement tacticsAlertCount > 10, OR multiple High/Critical in <6h🔴 Threat landscape context — Even when all incidents are resolved, the classification breakdown, MITRE tactic distribution, and severity mix from recent closures provide actionable signals for cross-correlation and query file recommendations.
Tool: RunAdvancedHuntingQuery
Always runs in parallel with Q1 — not conditional on Q1 results.
SecurityIncident
| where CreatedTime > ago(7d)
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where Status == "Closed"
| where array_length(AlertIds) > 0
| mv-expand AlertId = AlertIds | extend AlertId = tostring(AlertId)
| join kind=leftouter (
SecurityAlert
| where TimeGenerated > ago(30d)
| summarize arg_max(TimeGenerated, Tactics, Techniques) by SystemAlertId
| project SystemAlertId, Tactics, Techniques
) on $left.AlertId == $right.SystemAlertId
| mv-expand Technique = parse_json(Techniques)
| extend Technique = tostring(Technique)
| extend TacticsSplit = split(Tactics, ", ")
| mv-expand Tactic = TacticsSplit
| extend Tactic = tostring(Tactic)
| summarize
Total = dcount(IncidentNumber),
TruePositive = dcountif(IncidentNumber, Classification == "TruePositive"),
BenignPositive = dcountif(IncidentNumber, Classification == "BenignPositive"),
FalsePositive = dcountif(IncidentNumber, Classification == "FalsePositive"),
Undetermined = dcountif(IncidentNumber, Classification == "Undetermined"),
HighCritical = dcountif(IncidentNumber, Severity in ("High", "Critical")),
MediumLow = dcountif(IncidentNumber, Severity in ("Medium", "Low")),
Tactics = make_set(Tactic),
Techniques = make_set(Technique)
| extend Techniques = set_difference(Techniques, dynamic([""]))
| extend Tactics = set_difference(Tactics, dynamic([""]))Purpose: Provides a 7-day closed incident summary with classification breakdown (TP/BP/FP/Undetermined), severity distribution, aggregated MITRE tactics, and aggregated MITRE technique IDs. Uses CreatedTime (not TimeGenerated) to match portal "created in last 7 days" semantics — TimeGenerated captures any incident updated in the window, inflating counts with old incidents. Filters array_length(AlertIds) > 0 to exclude phantom incidents — the SecurityIncident table contains hundreds of records synced from XDR with empty AlertIds that never surface in the Defender XDR portal queue (see copilot-instructions.md Known Table Pitfalls). This data feeds three downstream uses:
Techniques array contains ATT&CK technique IDs (e.g., T1566, T1078, T1059) directly matchable against manifest entry mitre fields. No tactic→technique mapping needed — the technique IDs are the primary matching key for query file recommendationsVerdict logic:
TruePositive / Total > 0.5 (majority of closures are real threats — active threat environment)Undetermined > 0 (some incidents lack classification)Rendering rules:
Tactics and Techniques arrays and report distinct values from TruePositive incidentsTechniques array feeds directly into the Query File Recommendations manifest MITRE matching (no tactic→technique translation needed)🔐 Identity risk posture — Hybrid two-signal query: IdentityInfo.RiskScore (Defender XDR composite, 0-100) captures alert-chain and MITRE-stage risk, while RiskLevel/RiskStatus (Identity Protection) captures sign-in anomalies and AI-driven signals. Uses both because they are independent engines — a user can have RiskScore=93 with Remediated IdP status, or RiskScore=0 with High/AtRisk IdP status. AADUserRiskEvents enriches with the specific detections explaining why they're flagged.
Tool: RunAdvancedHuntingQuery
let lookback = 7d;
// Layer 1: IdentityInfo — hybrid filter (Defender RiskScore + IdP RiskLevel/Status + Criticality)
let IdentityPosture = IdentityInfo
| where Timestamp > ago(lookback)
| summarize arg_max(Timestamp, *) by AccountUpn
| where RiskScore >= 71
or RiskLevel in ("High", "Medium")
or RiskStatus in ("AtRisk", "ConfirmedCompromised")
or CriticalityLevel >= 3;
// Layer 2: AADUserRiskEvents — enrichment (the why)
let UserRiskEvents = AADUserRiskEvents
| where TimeGenerated > ago(lookback)
| extend Country = tostring(parse_json(Location).countryOrRegion)
| summarize
RiskDetections = count(),
HighCount = countif(RiskLevel == "high"),
TopRiskEventTypes = make_set(RiskEventType, 8),
TopCountries = make_set(Country, 5),
LatestDetection = max(TimeGenerated)
by UserPrincipalName;
// IdentityInfo drives, AADUserRiskEvents enriches
IdentityPosture
| join hint.strategy=broadcast kind=leftouter (UserRiskEvents)
on $left.AccountUpn == $right.UserPrincipalName
| extend
DisplayName = coalesce(AccountDisplayName, AccountName, AccountUpn),
PortalUrl = strcat("https://security.microsoft.com/user?",
case(
isnotempty(AccountObjectId), strcat("aad=", AccountObjectId, "&upn=", AccountUpn),
isnotempty(OnPremSid), strcat("sid=", OnPremSid, "&accountName=", AccountName,
"&accountDomain=", AccountDomain),
isnotempty(AccountUpn), strcat("upn=", AccountUpn),
""),
"&tab=overview&tid=<TENANT_ID>")
| project DisplayName, PortalUrl, RiskScore, RiskLevel, RiskStatus, CriticalityLevel,
RiskDetections = coalesce(RiskDetections, long(0)),
HighCount = coalesce(HighCount, long(0)),
TopRiskEventTypes, TopCountries, LatestDetection
| order by RiskScore desc, HighCount desc, RiskDetections desc, CriticalityLevel desc
| take 15Purpose: RiskScore (int, 0-100) is the Defender XDR composite score on IdentityInfo — factors include alert chains, MITRE stage progression, and asset criticality. Portal thresholds: 71-90 = High, 91-100 = Critical. RiskLevel/RiskStatus are Identity Protection signals (sign-in anomalies, leaked creds, AI signals) — a separate engine that doesn't always agree with RiskScore. The hybrid OR filter ensures users flagged by either engine surface. Users with both signals firing are highest priority (corroborated).
Output columns: DisplayName (linked to Defender XDR Identity page via PortalUrl), RiskScore (0-100, primary sort), RiskLevel, RiskStatus, CriticalityLevel, RiskDetections (count), HighCount, TopRiskEventTypes, TopCountries, LatestDetection.
Portal URL resolution: Three-tier fallback for identity environment coverage:
aad=<ObjectId>&upn=<UPN>sid=<SID>&accountName=<Name>&accountDomain=<Domain>upn=<UPN>Report rendering: Show top 10 users in the dashboard table. Use DisplayName as clickable link text with PortalUrl as the target. If >10 results, note "+N more — drill down with user-investigation skill". For each user, render RiskScore and TopRiskEventTypes as the key risk indicators.
Verdict logic:
RiskScore >= 91, or ConfirmedCompromised status, or HighCount > 3, or multiple users with HighCount > 0RiskScore >= 71, or HighCount > 0 for any user, or any user AtRisk with risk events indicating aiCompoundAccountRisk, impossibleTravel, or maliciousIPAddressMedium risk users with low-severity risk event types (e.g., unfamiliarFeatures)⚠️ Risk Event Type Routing Guard (Phase 4 drill-down):
suspiciousAuthAppApproval → T1621 MFA Fatigue (suspicious Authenticator push approval patterns), NOT OAuth app consent. Route to user-investigation or authentication-tracing. NEVER recommend app-registration-posture based on this risk event alonemcasSuspiciousInboxManipulationRules → T1114.003 email exfiltration via inbox rules. Route to user-investigation with OfficeActivity drill-down🔐 Auth spray detection (T1110.003 / T1110.001) — Identifies IPs targeting multiple users with failed auth across Entra ID cloud sign-ins AND RDP/SSH/network logons on endpoints.
Tool: RunAdvancedHuntingQuery
// Step 1: Count spray-specific failures per IP (materialized — referenced twice)
let SprayFailures = materialize(EntraIdSignInEvents
| where Timestamp > ago(7d)
| where ErrorCode in (50126, 50053, 50057)
| summarize
FailedAttempts = count(),
TargetUsers = dcount(AccountUpn),
SampleTargets = make_set(AccountUpn, 5),
FailedApps = make_set(Application, 3),
Countries = make_set(Country, 3)
by SourceIP = IPAddress
| where TargetUsers >= 5);
// Step 2: Get full traffic profile for flagged IPs (success context)
let IPTrafficProfile = EntraIdSignInEvents
| where Timestamp > ago(7d)
| where IPAddress in ((SprayFailures | project SourceIP))
| summarize
TotalSignIns = count(),
Successes = countif(ErrorCode == 0),
TotalDistinctUsers = dcount(AccountUpn),
TotalDistinctApps = dcount(Application)
by SourceIP = IPAddress;
// Step 3: Join and filter — eliminate shared infrastructure false positives
let EntraResults = SprayFailures
| join kind=inner IPTrafficProfile on SourceIP
| extend
SprayRatio = round(FailedAttempts * 100.0 / max_of(TotalSignIns, 1), 1),
SuccessRate = round(Successes * 100.0 / max_of(TotalSignIns, 1), 1)
| where SprayRatio >= 1.0 and TotalDistinctApps < 50
| extend Surface = "Entra ID"
| project SourceIP, FailedAttempts, TargetUsers, SampleTargets,
Protocols = FailedApps, Countries, Surface,
TotalSignIns, Successes, SprayRatio, SuccessRate, TotalDistinctApps;
// Endpoint brute-force — Surface label by LogonType
let EndpointBrute = DeviceLogonEvents
| where Timestamp > ago(7d)
| where ActionType == "LogonFailed"
| where LogonType in ("RemoteInteractive", "Network")
| where isnotempty(RemoteIP)
| summarize
FailedAttempts = count(),
TargetUsers = dcount(AccountName),
SampleTargets = make_set(AccountName, 5),
Protocols = make_set(strcat(LogonType, " → ", DeviceName), 3),
Countries = dynamic(["—"]),
LogonTypes = make_set(LogonType)
by SourceIP = RemoteIP
| where FailedAttempts >= 10
| extend Surface = iff(array_length(LogonTypes) == 1 and LogonTypes[0] == "RemoteInteractive", "Endpoint (RDP)", "Endpoint (Network Logon)"),
TotalSignIns = FailedAttempts, Successes = long(0),
SprayRatio = 100.0, SuccessRate = 0.0, TotalDistinctApps = long(0)
| project-away LogonTypes;
union EntraResults, EndpointBrute
| order by SprayRatio desc, TargetUsers desc, FailedAttempts desc
| take 15Purpose: Detects password spray (1 IP → many users, MITRE T1110.003) and brute-force (1 IP → high failure count, T1110.001) across two surfaces, with shared infrastructure false-positive filtering:
EntraIdSignInEvents (Advanced Hunting) which merges interactive + non-interactive sign-ins into a single table. Error codes: 50126=bad password, 50053=locked account, 50057=disabled account. The query enriches failure data with the IP's full traffic profile to compute SprayRatio (spray failures ÷ total sign-ins) and TotalDistinctApps. Two filters eliminate corporate proxies, VPN concentrators, and Azure gateways:SprayRatio >= 1.0 — spray failures must be ≥1% of the IP's total sign-in volume. A proxy with 500K sign-ins and 77 spray errors → 0.01% → filtered. A pure attacker with 77 failures and 0 successes → 100% → kept.TotalDistinctApps < 50 — IPs serving 50+ distinct applications are shared infrastructure. Real spray targets 1–3 apps.RemoteInteractive) and Network Logon (Network) failed logons on MDE-enrolled devices. Surface labels: Endpoint (RDP) for pure RemoteInteractive, Endpoint (Network Logon) for anything involving Network logon type. NLA caveat: RDP with Network Level Authentication generates LogonType == "Network" (not RemoteInteractive), so Endpoint (Network Logon) may be RDP-via-NLA or SMB — the manifest surfaces both rdp_threat_detection.md and smb_threat_detection.md for drill-down. Threshold of ≥10 failures. No success context available in DeviceLogonEvents for filtering.Output columns: SourceIP, FailedAttempts, TargetUsers, SampleTargets, Protocols, Countries, Surface, TotalSignIns, Successes, SprayRatio, SuccessRate, TotalDistinctApps. The SprayRatio and TotalDistinctApps columns provide immediate false-positive triage context.
Verdict logic:
Drill-down: Use user-investigation skill for targeted users, ioc-investigation for source IPs.
🤖 Automation monitoring — Composite drift score across 5 dimensions for service principals, with IPv6 subnet normalization and IPDrift cap.
Tool: mcp_sentinel-data_query_lake (needs >30d lookback)
let BL_Start = ago(97d); let BL_End = ago(7d);
let RC_Start = ago(7d); let RC_End = now();
let BL = AADServicePrincipalSignInLogs
| where TimeGenerated between (BL_Start .. BL_End)
| extend NormalizedIP = case(
IPAddress has ":", strcat_array(array_slice(split(IPAddress, ":"), 0, 3), ":"),
IPAddress)
| summarize
BL_Vol = count(),
BL_Res = dcount(ResourceDisplayName),
BL_IPs = dcount(NormalizedIP),
BL_Loc = dcount(Location),
BL_Fail = dcountif(ResultType, ResultType != "0" and ResultType != 0)
by ServicePrincipalId, ServicePrincipalName;
let RC = AADServicePrincipalSignInLogs
| where TimeGenerated between (RC_Start .. RC_End)
| extend NormalizedIP = case(
IPAddress has ":", strcat_array(array_slice(split(IPAddress, ":"), 0, 3), ":"),
IPAddress)
| summarize
RC_Vol = count(),
RC_Res = dcount(ResourceDisplayName),
RC_IPs = dcount(NormalizedIP),
RC_Loc = dcount(Location),
RC_Fail = dcountif(ResultType, ResultType != "0" and ResultType != 0)
by ServicePrincipalId, ServicePrincipalName;
RC | join kind=inner BL on ServicePrincipalId
| extend
VolDrift = round(RC_Vol * 100.0 / max_of(BL_Vol, 10), 0),
ResDrift = round(RC_Res * 100.0 / max_of(BL_Res, 3), 0),
IPDriftRaw = round(RC_IPs * 100.0 / max_of(BL_IPs, 3), 0),
IPDrift = min_of(round(RC_IPs * 100.0 / max_of(BL_IPs, 3), 0), 300),
LocDrift = round(RC_Loc * 100.0 / max_of(BL_Loc, 2), 0),
FailDrift = round(RC_Fail * 100.0 / max_of(BL_Fail, 5), 0)
| extend DriftScore = round((VolDrift*0.20 + ResDrift*0.25 + IPDrift*0.25 + LocDrift*0.15 + FailDrift*0.15), 0)
| where DriftScore > 120
| order by DriftScore desc
| take 10Purpose: Identifies service principals with significant behavioral changes from their 90-day baseline.
Tuning notes:
fd00: ULA pod addresses within the same cluster — without normalization, each pod IP inflates IPDrift by hundreds of percent.IPDriftRaw shows the true ratio; IPDrift is capped to prevent IP-only spikes from dominating. Transparent when IPv4-only SPNs have genuine expansion.Verdict logic:
DriftScore > 250 or IPDriftRaw > 400%DriftScore > 150DriftScore 120–150Drill-down: Use scope-drift-detection/spn skill for full investigation of flagged SPNs.
💻 Endpoint behavioral baseline — Per-device drift scores computed in-query (7d baseline vs 1d recent), with infrastructure noise filtering and VolDrift cap to prevent automation-driven false positives.
Tool: RunAdvancedHuntingQuery
let uptime = DeviceInfo
| where Timestamp > ago(7d)
| extend IsRecent = Timestamp >= ago(1d)
| summarize
BaselineHours = dcountif(bin(Timestamp, 1h), not(IsRecent)),
RecentHours = dcountif(bin(Timestamp, 1h), IsRecent)
by DeviceName;
DeviceProcessEvents
| where Timestamp > ago(7d)
| where not(
InitiatingProcessFileName in ("gc_worker", "gc_linux_service", "dsc_host")
or (InitiatingProcessFileName == "dash" and InitiatingProcessParentFileName in ("gc_worker", "gc_linux_service"))
)
| extend IsRecent = Timestamp >= ago(1d), DayBucket = bin(Timestamp, 1d)
| summarize
BL_Events = countif(not(IsRecent)),
RC_Events = countif(IsRecent),
BL_Procs = dcountif(FileName, not(IsRecent)),
RC_Procs = dcountif(FileName, IsRecent),
BL_Accts = dcountif(AccountName, not(IsRecent)),
RC_Accts = dcountif(AccountName, IsRecent),
BL_Chains = dcountif(strcat(InitiatingProcessFileName, "→", FileName), not(IsRecent)),
RC_Chains = dcountif(strcat(InitiatingProcessFileName, "→", FileName), IsRecent),
BL_Comps = dcountif(ProcessVersionInfoCompanyName, not(IsRecent)),
RC_Comps = dcountif(ProcessVersionInfoCompanyName, IsRecent),
BaselineDays = dcountif(DayBucket, not(IsRecent))
by DeviceName
| where RC_Events > 0 and BL_Events > 0 and BaselineDays >= 4
| join kind=inner uptime on DeviceName
| where BaselineHours >= 48 and RecentHours >= 4
| extend
VolDriftRaw = round(RC_Events * 600.0 / max_of(BL_Events, 1), 0),
VolDrift = min_of(round(RC_Events * 600.0 / max_of(BL_Events, 1), 0), 300),
ProcDrift = round(RC_Procs * 100.0 / max_of(BL_Procs, 1), 0),
AcctDrift = round(RC_Accts * 100.0 / max_of(BL_Accts, 1), 0),
ChainDrift = round(RC_Chains * 100.0 / max_of(BL_Chains, 1), 0),
CompDrift = round(RC_Comps * 100.0 / max_of(BL_Comps, 1), 0)
| extend DriftScore = round(VolDrift * 0.30 + ProcDrift * 0.25 + ChainDrift * 0.20 + AcctDrift * 0.15 + CompDrift * 0.10, 0)
| order by DriftScore desc
| take 10
| project DeviceName, DriftScore, BaselineDays, BaselineHours, RecentHours, VolDriftRaw, VolDrift, ProcDrift, AcctDrift, ChainDrift, CompDriftPurpose: Returns the top 10 devices ranked by composite drift score, pre-computed in KQL. No LLM-side math required — just interpret the returned scores.
Tuning notes:
DeviceInfo join if heartbeats aren't ingested.VolDriftRaw preserves the true ratio. High VolDriftRaw with ~100 diversity metrics = infrastructure noise; both elevated = high-confidence anomaly.Verdict logic: See Device Drift Score Interpretation in Post-Processing for the full scale, VolDrift cap context, and fleet-uniformity rule.
💻 Threat hunting — Parent→child process combinations appearing fewer than 3 times in 30 days.
Tool: RunAdvancedHuntingQuery
DeviceProcessEvents
| where Timestamp > ago(30d)
| summarize
Count = count(),
UniqueDevices = dcount(DeviceName),
SampleDevice = take_any(DeviceName),
SampleUser = strcat(take_any(AccountDomain), "\\", take_any(AccountName)),
SampleChildCmd = take_any(ProcessCommandLine),
GrandparentProcess = take_any(InitiatingProcessParentFileName),
LastSeen = max(Timestamp)
by ParentProcess = InitiatingProcessFileName, ChildProcess = FileName
| where Count < 3
| order by Count asc, UniqueDevices asc
| take 20Purpose: Surfaces the 20 rarest process chains — singletons and near-singletons within the 30-day AH window. Effective for spotting LOLBin abuse, malware execution, or novel attack tooling. Review SampleChildCmd for suspicious command-line patterns.
Verdict logic:
📧 Email posture — Single-row summary of inbound email volume, threat breakdown, and delivered threats.
Tool: RunAdvancedHuntingQuery
EmailEvents
| where Timestamp > ago(7d)
| where EmailDirection == "Inbound"
| summarize
TotalInbound = count(),
Clean = countif(isempty(ThreatTypes)),
Phish = countif(ThreatTypes has "Phish"),
Malware = countif(ThreatTypes has "Malware"),
Spam = countif(ThreatTypes has "Spam"),
HighConfPhish = countif(ConfidenceLevel has "High" and ThreatTypes has "Phish"),
Blocked = countif(DeliveryAction == "Blocked"),
Delivered = countif(DeliveryAction == "Delivered"),
PhishDelivered = countif(ThreatTypes has "Phish" and DeliveryAction == "Delivered"),
DistinctSenders = dcount(SenderFromAddress),
DistinctRecipients = dcount(RecipientEmailAddress)Purpose: Instant C-level email posture briefing. The key escalation metric is PhishDelivered — phishing emails that bypassed all protections and reached mailboxes.
Verdict logic:
PhishDelivered > 5 or Malware > 0 deliveredPhishDelivered > 0 (any phishing reached mailboxes)Drill-down: Use email-threat-posture skill for full email security analysis including ZAP, Safe Links, and authentication breakdown.
🔑 Cloud ops monitoring — Detects mailbox rule manipulation, transport rule changes, mailbox delegation, MCAS-flagged compromised sign-ins, and human-initiated Conditional Access policy changes via CloudAppEvents. Focuses on rule/permission/CA mutations — the lower-confidence signals not duplicated by Q1's incident roll-up.
Tool: RunAdvancedHuntingQuery
// Allow-list of Microsoft platform service principals that perform automated mailbox/CA lifecycle ops.
// These appear with empty AccountDisplayName; the real actor name lives in RawEventData.UserId.
// Pattern: any RawEventData.UserId starting with "NT SERVICE\" is Microsoft datacenter automation
// (e.g., MSExchangeAdminApiNetCore for tenant-onboarding/permission hygiene). Exclude from analyst
// view to avoid false-positive "empty actor" alarms.
let PlatformServicePrefix = @"NT SERVICE\";
CloudAppEvents
| where Timestamp > ago(7d)
| where ActionType in (
// Exchange — Mail flow manipulation
"New-InboxRule", "Set-InboxRule", "Set-Mailbox",
"Add-MailboxPermission", "New-TransportRule", "Set-TransportRule", "New-Mailbox",
// Exchange — Anti-forensic
"Remove-MailboxPermission", "Remove-InboxRule",
// Conditional Access manipulation (human-initiated only)
"Set-ConditionalAccessPolicy", "New-ConditionalAccessPolicy",
// Compromise signals
"CompromisedSignIn"
)
// Resolve effective actor: AccountDisplayName when present, else RawEventData.UserId
| extend RawUserId = tostring(parse_json(tostring(RawEventData)).UserId)
| extend EffectiveActor = iff(isnotempty(AccountDisplayName), AccountDisplayName, RawUserId)
// Exclude Microsoft platform service principals (datacenter automation noise)
| where not(EffectiveActor startswith PlatformServicePrefix)
// Filter out system/automation-driven CA changes (CA agent, backup policies)
| where not(ActionType in ("Set-ConditionalAccessPolicy", "New-ConditionalAccessPolicy")
and isempty(EffectiveActor))
| extend Category = case(
ActionType in ("New-InboxRule", "Set-InboxRule", "Remove-InboxRule",
"Set-Mailbox", "Add-MailboxPermission", "Remove-MailboxPermission",
"New-TransportRule", "Set-TransportRule", "New-Mailbox"),
"Exchange Admin/Rule Change",
ActionType in ("Set-ConditionalAccessPolicy", "New-ConditionalAccessPolicy"),
"Conditional Access Change",
ActionType == "CompromisedSignIn",
"Compromised Sign-In",
"Other")
| summarize
Count = count(),
UniqueActors = dcount(EffectiveActor),
TopActors = make_set(EffectiveActor, 5),
Actions = make_set(ActionType, 5),
LatestTime = max(Timestamp)
by Category
| order by Count descPurpose: Three-category view of cloud app activity invisible to Q10 (AuditLogs). CompromisedSignIn is an MCAS signal independent from Q3's Identity Protection risk events — dual-source corroboration when both fire. CA changes with empty AccountDisplayName are system/agent-driven and filtered out. Inbox rule, transport rule, and mailbox permission changes are the primary BEC persistence/exfil mechanisms — even when no rule has a forwarding payload, rule creation by a previously-flagged user is a strong follow-up signal.
Actor resolution:
AccountDisplayNameis often empty for non-interactive ops; the query falls back toRawEventData.UserId. Actors prefixedNT SERVICE\are Microsoft datacenter automation (e.g.,MSExchangeAdminApiNetCore) and are excluded.
Verdict logic:
Compromised Sign-In with 5+ users, OR Conditional Access Change by any human actor, OR Exchange Admin/Rule Change with forwarding-related rules (New-InboxRule, Set-InboxRule, New-TransportRule)Compromised Sign-In (any count), OR Remove-InboxRule / Remove-MailboxPermission (anti-forensic cleanup signals)Set-Mailbox from system actorsDrill-down: Use user-investigation for actors in Compromised Sign-In category. Use ca-policy-investigation for Conditional Access Change. For any Exchange-related Q9 finding, also query OfficeActivity | where OfficeWorkload == "Exchange" — CloudAppEvents only surfaces ActionType summaries; OfficeActivity carries the full Parameters JSON (ForwardTo / RedirectTo / ForwardingSmtpAddress), per-operation ClientIP, and ops like MoveToDeletedItems / SoftDelete / HardDelete / MailboxLogin that reveal post-compromise forensics. See queries/email/email_threat_detection.md and the CloudAppEvents / OfficeActivity entries in copilot-instructions.md Known Table Pitfalls.
🔑 Admin activity monitoring — Category-aggregated view of privileged operations: role assignments, PIM activations, credential lifecycle, consent grants, CA policy changes, password management, MFA registration, app registration, and ownership grants.
Tool: RunAdvancedHuntingQuery
let PrivOps = AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName has_any (
"role", "credential", "consent", "Conditional Access", "password", "certificate",
"security info", "owner", "application"
)
| where Result == "success"
| extend Actor = tostring(InitiatedBy.user.userPrincipalName)
// Exclude system-driven CA policy additions (empty actor = CA agent)
| where not(OperationName has "conditional access" and isempty(Actor))
| extend Target = tostring(TargetResources[0].displayName)
| extend Category = case(
OperationName has "security info", "MFA-Registration",
OperationName has "owner", "Ownership",
OperationName has "application", "AppRegistration",
OperationName has "role", "RoleManagement",
OperationName has "credential" or OperationName has "certificate", "Credentials",
OperationName has "consent", "Consent",
OperationName has "Conditional Access", "ConditionalAccess",
OperationName has "password", "Password",
"Other");
PrivOps
| summarize
Count = count(),
UniqueActors = dcount(Actor),
TopActors = make_set(Actor, 5),
Operations = make_set(OperationName, 5),
Targets = make_set(Target, 5),
LatestTime = max(TimeGenerated)
by Category
| order by Count descPurpose: Category-level aggregation ensures all 8 privilege domains surface regardless of volume distribution (previous per-actor aggregation was truncated at 15 rows, hiding MFA-Registration, Ownership, and AppRegistration). Key non-obvious details: MFA-Registration deletion + re-registration by same user = credential takeover (T1556.006). Ownership grants to external accounts = persistence (T1098). System-driven CA additions (empty Actor) are filtered out. Password category is high-volume by nature — flag single-actor bulk resets, not self-service.
Verdict logic:
MFA-Registration deletions + registrations for same user (method swap attack), OR Consent grants from unexpected actors, OR Ownership grants to external accounts, OR ConditionalAccess changes by non-admin actors, OR AppRegistration with secrets management from external domainsMFA-Registration from CTF/external accounts, OR RoleManagement targeting Global Admin / Security Admin roles, OR AppRegistration consent operations, OR Password with bulk admin resets (single actor, 10+ targets)🛡️ Attack surface — Combines ExposureGraph critical asset inventory with MDE's authoritative DeviceInfo.IsInternetFacing classification to identify verified internet-exposed critical assets.
Tool: RunAdvancedHuntingQuery
let InternetFacing = DeviceInfo
| where Timestamp > ago(7d)
| where IsInternetFacing == true
| summarize arg_max(Timestamp, *) by DeviceId
| project DeviceName,
Reason = extractjson("$.InternetFacingReason", AdditionalFields, typeof(string)),
PublicIP = extractjson("$.InternetFacingPublicScannedIp", AdditionalFields, typeof(string)),
ExposedPort = extractjson("$.InternetFacingLocalPort", AdditionalFields, typeof(int));
let CriticalAssets = ExposureGraphNodes
| where set_has_element(Categories, "device")
| where isnotnull(NodeProperties.rawData.criticalityLevel)
| extend critLevel = toint(NodeProperties.rawData.criticalityLevel.criticalityLevel)
| where critLevel < 4
| project DeviceName = NodeName, CriticalityLevel = critLevel,
ExposureScore = tostring(NodeProperties.rawData.exposureScore);
CriticalAssets
| join kind=leftouter InternetFacing on DeviceName
| extend IsVerifiedExposed = isnotempty(PublicIP) or isnotempty(Reason)
| project DeviceName, CriticalityLevel, IsVerifiedExposed,
Reason, PublicIP, ExposedPort, ExposureScore
| order by IsVerifiedExposed desc, CriticalityLevel asc
| take 25Purpose: Returns the critical asset inventory (criticality 0–3) enriched with MDE's authoritative internet-facing classification. DeviceInfo.IsInternetFacing is confirmed via Microsoft external scans or observed inbound connections and auto-expires after 48h — far more reliable than ExposureGraph properties like isCustomerFacing (business flag) or rawData.IsInternetFacing (not populated in many environments). See MS Docs and queries/network/internet_exposure_analysis.md Query 1 for the canonical reference.
IsVerifiedExposed logic: Checks BOTH PublicIP (populated for PublicScan — Microsoft external scanner) AND Reason (populated for ExternalNetworkConnection — observed inbound traffic). The original isnotempty(PublicIP) missed ExternalNetworkConnection exposures where MDE confirms inbound connections but doesn't populate the scanned public IP field.
Verdict logic:
IsVerifiedExposed == true with CriticalityLevel == 0 (internet-facing domain controller/CA)IsVerifiedExposed == true (internet-facing critical asset)🛡️ Vulnerability patch priority — Top exploitable critical CVEs with affected device count.
Tool: RunAdvancedHuntingQuery
DeviceTvmSoftwareVulnerabilities
| join kind=inner (
DeviceTvmSoftwareVulnerabilitiesKB
| where IsExploitAvailable == true
| where CvssScore >= 8.0
) on CveId
| summarize
AffectedDevices = dcount(DeviceName),
SampleDevices = make_set(DeviceName, 3),
Software = make_set(SoftwareName, 3)
by CveId, VulnerabilitySeverityLevel, CvssScore
| order by AffectedDevices desc, CvssScore desc
| take 15Purpose: Instant "what should we patch today" list. Ranks exploitable CVEs by fleet impact (devices affected × CVSS severity). Focus on CVEs with public exploits affecting the most devices.
Verdict logic:
CvssScore >= 9.0 AND AffectedDevices > 10CvssScore >= 8.0 AND AffectedDevices > 5Drill-down: Use exposure-investigation skill for full vulnerability posture assessment.
Q6 returns pre-computed drift scores directly from KQL — no LLM-side math is needed. Simply present the returned table and apply verdicts using this scale:
| DriftScore | Interpretation | Verdict |
|---|---|---|
| < 80 | Contracting activity (device may be idle/decommissioned) | 🔵 Informational |
| 80–110 | Stable steady-state servers (fleet floor with uptime gate — was 80–120 pre-uptime-filter) | ✅ Clear |
| 110–130 | Minor behavioral expansion | 🟡 Monitor |
| 130–180 | Significant deviation — includes genuine intermittent-workstation drift now that uptime FPs are filtered | 🟠 Investigate |
| 180+ | Major anomaly — multi-dimensional with confirmed uptime baseline | 🔴 Escalate |
VolDrift cap context: VolDriftRaw is projected alongside the capped VolDrift. When interpreting results:
VolDriftRaw ≫ 300 but ProcDrift/ChainDrift/AcctDrift are near 100: infrastructure volume spike (GC, patching, agent restart) — low concern despite high raw volume.VolDriftRaw > 300 AND ProcDrift/ChainDrift/AcctDrift are also elevated: genuine multi-dimensional anomaly — high confidence finding.VolDriftRaw ≤ 300: cap was not triggered — score reflects true proportions.Fleet-uniformity rule: If ALL top-10 devices cluster within 20 points of each other, the fleet is behaving uniformly and the verdict should be downgraded one level. Drift is most meaningful when individual devices diverge from the fleet cluster.
⛔ DO NOT manually recompute drift scores. The KQL query handles Volume normalization (÷6 baseline days), VolDrift capping (at 300%), GC infrastructure filtering, and dcount comparison (direct ratio). Trust the returned DriftScore column.
After all queries complete, check these correlation patterns and escalate priority when found:
| Pattern | Queries | Implication | Action |
|---|---|---|---|
| Incident account matches risky identity | Q1 Accounts ∩ Q3 AccountUpn | Incident involves user already flagged AtRisk/Compromised — corroborated signal | Escalate to 🔴 |
| Incident device matches drifting endpoint | Q1 Devices ∩ Q6 DeviceName | Incident target has behavioral anomalies on endpoint | Escalate to 🔴 |
| Incident device has exploitable CVE | Q1 Devices ∩ Q12 DeviceName | Incident device is vulnerable to active exploitation | Escalate to 🔴 |
| Spray target already in incident | Q4 targets ∩ Q1 Accounts | Spray target is already involved in an active incident | Escalate to 🔴 |
| SPN drift AND unusual credential/consent activity | Q5 + Q10 | App credential abuse / persistence | Escalate to 🔴 |
| Device with rare process chain AND exploitable CVE | Q7 + Q12 | Potential active exploitation | Escalate to 🔴 |
| Spray IP target already flagged as risky | Q4 + Q3 | Spray target has active Identity Protection risk | Escalate to 🔴 |
| Closed TP tactics match active findings | Q2 + Q3/Q7/Q8 | Same attack pattern recurring despite recent closures | Escalate to 🟠, note recurrence |
| Mailbox rule manipulation AND email threats | Q9 + Q8 | Potential email exfiltration setup following phishing | Escalate to 🔴 |
| Compromised Sign-In user matches risky identity | Q9 Compromised Sign-In ∩ Q3 AccountUpn | MCAS compromise + Identity Protection risk — dual-signal corroboration | Escalate to 🔴 |
| Compromised Sign-In user has Mailbox Read (API) | Q9 Compromised Sign-In ∩ Q9 Mailbox Read (API) | Compromised account actively exfiltrating email via API — BEC kill chain | Escalate to 🔴 |
| Compromised Sign-In user in open incident | Q9 Compromised Sign-In ∩ Q1 Accounts | MCAS compromise detection overlaps active incident entities | Escalate to 🔴 |
| MFA registration from spray target | Q10 MFA-Registration ∩ Q4 spray targets | Attacker completing MFA enrollment after successful spray — T1556.006 | Escalate to 🔴 |
| MFA registration from risky user | Q10 MFA-Registration ∩ Q3 AccountUpn | Risky user registering new auth methods — potential credential takeover | Escalate to 🔴 |
| App registration + SPN drift | Q10 AppRegistration ∩ Q5 SPN drift | New app + expanding SPN footprint = T1098.001 app-based persistence | Escalate to 🔴 |
| CA policy change + spray/compromise activity | Q9 Conditional Access Change + Q4 or Q9 Compromised Sign-In | Defense weakened during active attack | Escalate to 🔴 |
| Mailbox Read (API) user has inbox rule changes | Q9 Mailbox Read (API) ∩ Q9 Exchange Admin/Rule Change | Programmatic read + forwarding rule = full exfiltration chain (T1114.003) | Escalate to 🔴 |
| Phishing recipient is risky user | Q8 delivered phishing ∩ Q3 AccountUpn | Credential harvesting targeting already-compromised or at-risk user — AiTM chain indicator | Escalate to 🔴 |
| DLP/exfiltration incident + API mailbox access | Q1 Exfiltration tactic ∩ Q9 Mailbox Read (API) | Incident-level exfiltration alert + active API data access — data loss in progress | Escalate to 🔴 |
| Role management + SPN drift by same actor | Q10 RoleManagement same actor ∩ Q5 SPN drift | Role escalation + expanding app footprint = app-based persistence (T1098) | Escalate to 🔴 |
Use .github/manifests/discovery-manifest.yaml (auto-generated by python .github/manifests/build_manifest.py) to match findings to downstream query files and skills. Contains title, path, domains, mitre, prompt.
Skip entirely when all verdicts are ✅. Tier depth follows the Rule 8 table.
| Query Group | Domain Tag(s) |
|---|---|
| Q1, Q2 (Incidents) | incidents |
| Q3, Q4 (Identity) | identity |
| Q5 (SPN Drift) | spn |
| Q6, Q7 (Endpoint) | endpoint |
| Q8 (Email) | email |
| Q9, Q10 (Admin & Cloud) | admin, cloud |
| Q11, Q12 (Exposure) | exposure |
Valid tags: incidents, identity, spn, endpoint, email, admin, cloud, exposure.
For each non-✅ verdict, collect its domain tag(s), then:
manifest.queries where domains contains ANY active tag. Rank by (a) number of matching tags, (b) MITRE technique overlap with Q1/Q2 Techniques (exact string match on mitre field), (c) keyword overlap (entities, process names, CVE IDs, ActionTypes) against title/path. Select top 3–5 files for 🔴/🟠, 1–2 for 🟡-only.manifest.skills where domains matches. Substitute actual entity values into the prompt template's {entity} placeholder. 🔴/🟠: include all matches as drill-down options; 🟡-only: limit to 3. Skills without domains (tooling/visualization) are never auto-suggested.Insert 📂 Recommended Query Files after 🎯 Recommended Actions. Include a 🔧 Suggested Skill Drill-Downs sub-section with manifest skill prompts (entity-substituted).
⛔ Numbered list, NOT table — links inside table cells don't render clickable in VS Code chat.
Format: 1. **[<Title>](queries/<subfolder>/<file>.md)** — Q<N>: <finding> — 💡 *"<entity-specific prompt>"*
title, target = manifest path (forward slashes).**Domains:** <tag1>, <tag2> to metadata header (after **MITRE:**).threat_pulse_domains: [<tag>] and drill_down_prompt: '<prompt>' to YAML frontmatter.python .github/manifests/build_manifest.py — validator flags missing fields.Output modes:
💾 Save full investigation report in Phase 4. Full data tables, no row limits. Path: reports/threat-pulse/Threat_Pulse_YYYYMMDD_HHMMSS.md. Source data: pulse results from context + /memories/session/threat-pulse-drilldowns.md (authoritative after context compaction).Verdicts: 🔴 Escalate | 🟠 Investigate | 🟡 Monitor | ✅ Clear | 🔵 Informational | ❓ No Data
✅ No <type> detected in the last <N>d. Checked: <table> (0 matches)# 🔍 Threat Pulse — <Workspace> | <Date>
**Workspace:** <name> (`<id>`)
**Scan Date:** <YYYY-MM-DD HH:MM UTC>
**Scan Duration:** <N>min | **Queries:** 12 | **Drill-Downs:** <N> (file mode only)
## Executive Summary
<2–4 sentences synthesizing pulse + drill-down findings. State final risk posture incorporating all evidence.>
## Dashboard Summary
<12-row table (Q1, Q2, Q3, Q4–Q12) — columns: #, Domain, Status (verdict emoji), Key Finding (1-line).>
## Detailed Findings
<One section per query — EVERY query gets a section (no skipping). Q2 closed summary always renders after Q1 even when Q1 is ✅.>
## Cross-Query Correlations
<Table per Post-Processing rules, or `✅ No correlations detected`.>
## 🎯 Recommended Actions
<Prioritized table: action, trigger query, drill-down skill.>
## 📂 Recommended Query Files
<Per Report Output Block procedure. For 🟡-only verdicts use "📂 Proactive Hunting Suggestions" header. Omit when all ✅.>
## Drill-Down Investigation Results (file mode, when drill-downs executed)
### 1. <Title> — <Skill Name>
**Triggered by:** Q<N> — <finding>
**Entity:** <target> | **Lookback:** <timerange> | **Risk:** <emoji> <level>
**Key Findings:** <max 8 evidence-cited bullets>
**Evidence Summary:** <1–2 paragraph narrative with specific numbers/identifiers. Back-reference pulse queries.>
**Recommendations:** <numbered actions>
### 2. <Next Title> — <Skill Name>
...
## Cross-Investigation Correlation (file mode, when drill-downs executed)
| Connection | Evidence | Drill-Downs | Implication |
|-----------|----------|-------------|-------------|
<Patterns only visible across multiple investigations. If none: `✅ No cross-investigation correlations identified — each finding is independent.`>
## Consolidated Recommendations (file mode)
| Priority | Recommendation | Source | Risk |
<Deduplicated across pulse + drill-downs. If same action appears in both, cite both sources on one row.>
## Appendix: Investigation Timeline (file mode)
| Time | Action | Key Result || Incident | Sev | Title | Age | Alerts | Owner | Tactics | Accounts | Devices | Tags | — Sev = incident severity, Unassigned → ⚠️ Unassigned, Age uses relative AgeDisplay, entity/tag columns render max 5 comma-separated.TotalAll > 10: prepend **Showing 10 of {TotalAll} open incidents ({TotalHighCritical} High/Critical)** (sorted by severity, then newest, most complex first)TitleDupCount > 1, append (+{TitleDupCount-1} more) to its Title cell so recurring/noisy incident types remain visible without monopolizing the table.TotalHighCritical == 0: prepend **No High/Critical incidents — showing top Medium/Low from {TotalAll} open**[#<id>](https://security.microsoft.com/incidents/<ProviderIncidentId>?tid=<tenant_id>) links.| Rule | Status |
|---|---|
| Executive Summary synthesizes across pulse AND drill-downs (when present) | ✅ REQUIRED |
| Every query has a verdict row — no omissions, no skipped "clear" sections | ✅ REQUIRED |
Drill-down subsections are structured summaries, not raw dumps, with Triggered by: Q<N> | ✅ REQUIRED |
| Cross-Investigation Correlation explicitly states "none found" if no connections exist | ✅ REQUIRED |
| Consolidated Recommendations are deduplicated (same action + multiple sources → one row) | ✅ REQUIRED |
| Fabricated data | ❌ PROHIBITED |
Schema pitfalls (column names, dynamic fields,
parse_jsonpatterns) are covered incopilot-instructions.mdKnown Table Pitfalls. Refer there forSecurityAlert.Status,ExposureGraphNodes.NodeProperties, timestamp columns, andAuditLogs.InitiatedBy.
🎬 Take Action block with portal-ready KQL (correct required columns per entity type)🎬 Take Action block includes the ⚠️ AI-generated content warning immediately below the heading📂 Recommended Query Files section present when any non-✅ verdict exists (clickable links, not tables)© SCStelz, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. The last 2 KB are left out here. Raw file
SKILL.md and 1 other file in .github/skills/threat-pulse of SCStelz/security-investigator.
Open the folder on GitHubat commit 51e1385
Threat Pulse next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Threat Pulse this skillSCStelz/security-investigator | 249 | — | ~25k | Automated safety check: Pass | MIT | |
| Deepsec Documentation Guidevercel-labs/deepsec | 8.1k | — | ~956 | Automated safety check: Pass | Apache-2.0 | |
| Kubernetes Network Security Auditkubeshark/kubeshark | 12k | — | ~7.3k | Automated safety check: Notes | Apache-2.0 | |
| Native Dependency Updatemono/SkiaSharp | 5.6k | — | ~4.1k | Automated safety check: Pass | MIT | |
| Semgrep Security Scantrailofbits/skills | 7.5k | — | ~3.7k | Automated safety check: Notes | CC-BY-SA-4.0 | |
| Skillward AuditFangcun-AI/SkillWard | 143 | — | ~2.9k | Automated safety check: Pass | Custom licence |
vercel-labs/deepsec
Points the agent at deepsec's own docs to answer questions about initializing, configuring, resuming, scanning with and extending the vulnerability scanner.
kubeshark/kubeshark
Hunts for compromised workloads and malicious traffic in a Kubernetes cluster by sweeping network data through Kubeshark MCP, mapped to MITRE ATT&CK.
mono/SkiaSharp
Update native dependencies (libpng, libexpat, zlib, libwebp, harfbuzz, freetype, libjpeg-turbo, etc.) in SkiaSharp's Skia fork.
trailofbits/skills
Detects languages, proposes rulesets for approval, then runs the approved Semgrep scan across a codebase and merges the output into one SARIF file.
Fangcun-AI/SkillWard
Security-audit a third-party skill bundle (folder with SKILL.md, or .zip / .tar.gz archive) before installing it, using the SkillWard cloud scanner.
TheDecipherist/claude-code-mastery
Checks a codebase for hardcoded secrets, vulnerable dependencies, weak input handling, weak authentication and unsafe transport settings before deployment or merge.
SCStelz/security-investigator
A skill your agent uses when asked to investigate Conditional Access policy changes, sign-in failures related to CA policies (error codes 53000, 50074, 530032), or suspected policy…
SCStelz/security-investigator
Weekly review of an investigation tenant-context memory file against the most recent SOC scan reports (e.g.
SCStelz/security-investigator
A skill your agent uses when asked to create heatmaps, visualize patterns over time, show activity grids, or display aggregated data in a matrix format.
SCStelz/security-investigator
Report/investigate RUNTIME ACTIVITY of AI agents (Agent 365 / Copilot Studio / M365 Copilot / Work IQ) — agents used, tools/connectors, channels, tokens, prompt/reply content, and Prompt Shield…
SCStelz/security-investigator
Audit or report on AI agent security posture across Copilot Studio, Microsoft 365 Copilot, Microsoft Foundry, and third-party agents.
SCStelz/security-investigator
Audit Entra ID app registration and service principal security posture.
Categories
Recommended starting point for new users and daily SOC operations. Threat Pulse is an agent skill from SCStelz/security-investigator. Recommended starting point for new users and daily SOC operations.
Threat Pulse fits situations like: getting-started questions like where do I start; what can you do; help me investigate.
Run `npx skills add SCStelz/security-investigator --skill threat-pulse -a claude-code`. Or copy the skill folder (.github/skills/threat-pulse in SCStelz/security-investigator) into .claude/skills/threat-pulse in your project. Claude Code loads it when a task matches its description.
Run `npx skills add SCStelz/security-investigator --skill threat-pulse -a codex`. Or copy the skill folder (.github/skills/threat-pulse in SCStelz/security-investigator) into .agents/skills/threat-pulse in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add SCStelz/security-investigator --skill threat-pulse -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/threat-pulse, .gemini/skills/threat-pulse, .github/skills/threat-pulse and .opencode/skills/threat-pulse in your project.
Going by SKILL.md and its folder, Threat Pulse needs the command-line tools its instructions call (python).
SKILL.md names 2 domains. In commands or code: security.microsoft.com; the agent is likely to contact it when it follows the instructions. As links in the text: learn.microsoft.com. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Threat Pulse is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 25k tokens (SKILL.md is roughly 99k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Threat Pulse: Deepsec Documentation Guide (vercel-labs/deepsec, 8.1k stars), Kubernetes Network Security Audit (kubeshark/kubeshark, 12k stars), Native Dependency Update (mono/SkiaSharp, 5.6k stars) and Semgrep Security Scan (trailofbits/skills, 7.5k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
SCStelz (a GitHub user) maintains it in SCStelz/security-investigator, which has 249 GitHub stars. The repository holds 22 skills in this directory. The repository was last updated on October 8, 2026.
Source: SCStelz/security-investigator on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.