Skip to content
OpenSmartRoute
Skillv1.0.0

computer-investigation

Computer/device security investigation skill for environments with Azure Monitor MCP (Log Analytics workspace queries) and Azure CLI access — currently without Sentinel Data Lake MCP, Sentinel Triage

by stefanpems(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from stefanpems/sec-sre-ag (.builder/computer-investigation/SKILL.md). Install upstream with npx skills add stefanpems/sec-sre-ag --skill computer-investigation. Copyright stays with the author.

📏 Result & Token Guardrails — See shared-guardrails.md for universal result-cap, time-window, and aggregation rules that apply to all skills.

⚠️ CRITICAL OUTPUT RULE — ALWAYS PRESENT RESULTS INLINE FIRST

The investigation results MUST be presented inline in chat as the primary output. This is mandatory and non-negotiable.

  • Phase 2 findings: present the full analysis inline (timeline, alerts, key findings, risk assessment)
  • HTML report generation: ONLY when the user explicitly requests it (e.g., "generate HTML", "create report")
  • JSON export: ONLY when needed for HTML generation or user explicitly requests it

NEVER skip the inline presentation and jump directly to file generation. The user expects to see results in chat immediately. File exports are supplements, not replacements.

⚠️ CRITICAL TOOL RULE — ALWAYS PASS --subscription TO MCP MONITOR

When calling monitor-client_monitor_workspace_log_query, the subscription parameter is MANDATORY. Without it, the tool returns a 400 error that may be silently interpreted as "no data". Always read the subscription ID from the agent's <azure_resource_access> settings and pass it in every call. Never omit it.

⚠️ Token Efficiency — Do NOT read large files into context:

  • Pass file paths between steps. Do NOT use ReadFile on intermediate artifacts (JSON bodies, HTML reports, query results) unless explicitly debugging.
  • When inspecting command output, use head, tail, grep, or jq via RunInTerminal to extract only the fields you need — never dump entire files.
  • If a step produces a file that the next step consumes, pass the file path directly. The LLM context is expensive; files on disk are free.

Computer Security Investigation — Monitor MCP + Azure CLI

Purpose

This skill performs comprehensive security investigations on Windows, macOS, and Linux devices registered in Microsoft Entra ID and/or onboarded to Microsoft Defender for Endpoint, in environments where:

  • Azure Monitor MCP tool is available (monitor-client_monitor_workspace_log_query) for KQL queries against Log Analytics
  • Azure CLI (az in shell or RunAzCliReadCommands tool) is available for read operations (including az rest for Graph API)
  • ⚠️ Microsoft Graph MCP Server for Enterprise (mcp_microsoft_ent_*) is not available for integration with Azure SRE Agent, but access to Entra ID data is possible and implemented in this skill via direct Graph API calls (az rest / RunAzCliReadCommands). The agent’s User-Assigned Managed Identity must be granted the required Graph API permissions (see the README — Managed Identity Permissions section). KQL-based fallback queries are provided if Graph API permissions are not granted.
  • Sentinel Data Lake MCP — not integrated (no query_lake, list_sentinel_workspaces, search_tables)
  • Sentinel Triage MCP — not integrated (no RunAdvancedHuntingQuery, GetDefenderMachine, GetDefenderMachineLoggedOnUsers, etc.)
  • Microsoft Graph MCP — not integrated (no microsoft_graph_get, suggest_queries)

Why these MCP servers are absent: Sentinel Data Lake MCP, Sentinel Triage MCP, and Microsoft Graph MCP cannot currently be connected to Azure SRE Agent. This does not mean the underlying data is inaccessible — the data exposed by these servers (Sentinel Data Lake, Defender XDR / Advanced Hunting, Microsoft Graph) can be reached via direct API calls. However, direct API access to Sentinel Data Lake and Microsoft Graph as a replacement for these MCP servers has not yet been studied and implemented in this skill.

Data sources (Log Analytics): DeviceInfo, DeviceProcessEvents, DeviceNetworkEvents, DeviceFileEvents, DeviceRegistryEvents, DeviceLogonEvents, SigninLogs, SecurityAlert, SecurityIncident, ThreatIntelIndicators.

NOT available (require Advanced Hunting): DeviceTvmSoftwareInventory, DeviceTvmSoftwareVulnerabilities.

NOT available (require MDE API): Defender riskScore (per machine), automated investigations, remediation activities.

Device types supported: Entra Joined, Hybrid Joined, and Entra Registered devices.

Skill files:

  • SKILL.md — this file (investigation workflow, KQL queries, report templates)
  • generate_html_report.py — consolidated HTML report generator (dataclasses + HTML engine + JSON transformer, single self-contained file)
  • enrich_ips.py — IP enrichment script (source: user-investigation/enrich_ips.py, resolved via File Resolution cascade)
  • get-device-context-via-cli.md — step-by-step Graph API reference for device data collection

File Resolution (codeRefs-first)

Before executing any skill file (scripts, data files, companion files), resolve its location using this mandatory cascade:

1. codeRefs/sec-sre-ag/computer-investigation/<filename>
   → If found: use/execute directly from this path (companion files are co-located here)
2. tmp/computer-investigation/<filename>
   → If found: use from this path
3. Neither found:
   → read_skill_file("computer-investigation", "<filename>") from Builder
      (for enrich_ips.py: read_skill_file("user-investigation", "enrich_ips.py"))
   → CreateFile("tmp/computer-investigation/<filename>", <content>)
   → Repeat for ALL companion files referenced by the script

Rules:

  • When a file is found in codeRefs/, execute it directly from there — do NOT copy it to tmp/.
  • When materializing from Builder (step 3), materialize ALL companion files the script depends on, not just the script itself.
  • This cascade applies to every file listed in the Skill Files table above.

Pre-requisite: Environment Configuration (config.json)

Before executing any script resolved via the File Resolution cascade, the agent MUST ensure that config.json exists at the workspace root (the top-level directory of the agent workspace, NOT inside codeRefs/ or skill-specific directories).

Procedure:

  1. Check: Verify that config.json exists at the workspace root and that all required fields are non-empty: tenant_name, sentinel_workspace_id, subscription_id, azure_mcp.subscription_id, azure_mcp.resource_group, and azure_mcp.workspace_name. If the file is complete, proceed to step 3.

  2. If config.json is missing or incomplete, create it: a. Ask the user for the tenant name using AskUserQuestion with header "Tenant" and question: "What is your tenant name? (e.g., contoso.onmicrosoft.com or contoso.it)?" b. Extract from agent system prompt settings:

    • subscription_id → from the <azure_resource_access> section (the subscription ID the agent has access to)
    • sentinel_workspace_id → from the <log_analytics_access> section (the workspace GUID after workspace=)
    • workspace_name → from the <log_analytics_access> section (the workspace name before the colon) c. Discover the workspace resource group with RunAzCliReadCommands (never run az in the sandbox terminal), using:
    az monitor log-analytics workspace show --workspace-name <workspace_name> --subscription <subscription_id> --query resourceGroup -o tsv
    

    d. Create config.json at the workspace root with this structure:

    {
      "tenant_name": "<tenant_name>",
      "sentinel_workspace_id": "<workspace_guid>",
      "subscription_id": "<subscription_id>",
      "azure_mcp": {
        "subscription_id": "<subscription_id>",
        "resource_group": "<discovered_resource_group>",
        "workspace_name": "<workspace_name>"
      },
      "api_tokens": {}
    }

    e. Validate: Re-read the created file and verify that every required field listed in step 1 is non-empty. If platform settings or discovery did not provide a value, stop and report the missing field; never guess it.

  3. Proceed with the skill workflow. All Python scripts find config.json by walking up from their own directory (max 6 levels), so the workspace root is the correct and expected location.

Rules:

  • Do NOT write config.json inside codeRefs/ or inside skill-specific directories.
  • Do NOT hardcode any environment-specific values in this skill file — all values are derived at runtime from the agent's own settings and user input.
  • The api_tokens object is left empty — API tokens are loaded from Key Vault or environment variables at runtime.

📑 TABLE OF CONTENTS

  1. Prerequisites
  2. Environment Configuration
  3. Secrets Management (API Tokens)
  4. Phase 0: Investigation Cache Check — Cache reuse logic
  5. Critical Workflow Rules
  6. Device Types Reference
  7. Investigation Types
  8. Output Modes
  9. Quick Start
  10. Execution Workflow
  11. KQL Execution Reference
  12. Sample KQL Queries
  13. Entra ID Device Data Collection
  14. Markdown Report Template
  15. JSON Export Structure
  16. Error Handling
  17. Device Trust Type Analysis
  18. Risk Assessment Framework

Investigation shortcuts:

  • Device with behavioral drift: Q3 (suspicious processes) → Q9 (logon events) → Q7 (incidents) → Q8 (device info)
  • Internet-facing critical asset: Q8 (device info + internet-facing) → Q4 (outbound connections) → Q9 (logon events)
  • Device in active incident: Q2 (security alerts) → Q3 (process execution) → Q5 (file events) → Q6 (registry persistence) → Q7 (incidents)
  • Brute-forced endpoint: Q9 (logon events) → Q4 (outbound connections) → Q10 (TI IP matches)

⛔ Shortcut Default Rule: When a matching shortcut exists, use it — don't run the full workflow. Only run the full query set when the user explicitly requests "full investigation", "comprehensive", or "deep dive". Shortcuts render only the report sections relevant to their query chain (plus Executive Summary and Recommendations, always).


Prerequisites

Dependency Required Fallback Notes
Azure Monitor MCP (monitor-client_monitor_workspace_log_query) ✅ Yes None — core dependency Must be configured and connected to the target Log Analytics workspace
Azure CLI / RunAzCliReadCommands tool ⚠️ Optional KQL-only mode Used for Graph API calls via az rest. If unavailable, all device context from KQL
Graph API permissions ⚠️ Optional KQL-based fallback (Q0) Device.Read.All for device profile, owners, compliance
Python 3.x ⚠️ Optional Q10 (TI matches) + KQL IP context enrich_ips.py (source: user-investigation/enrich_ips.py, materialized to tmp/computer-investigation/) for external API enrichment. Requires API tokens. If unavailable, use KQL-only IP analysis.

Environment Configuration

Primary: Agent Settings Auto-Discovery (Recommended)

Workspace parameters are automatically available from the agent's system context:

  1. <log_analytics_access> section provides:
    • Workspace name (display name)
    • Workspace ID (GUID) — use as the workspace parameter
  2. <azure_resource_access> section provides:
    • Subscription ID
  3. <agent_identity> section provides:
    • Resource group (extractable from the agent's ARM resource ID)

How to extract parameters from agent context:

Parameter Source Example
workspace <log_analytics_access> → workspace GUID the workspace ID from <log_analytics_access>
subscription <azure_resource_access> → subscription ID the agent's subscription from <azure_resource_access>
resource-group <agent_identity> → extract from ARM resource ID the agent's resource group, extracted from its ARM resource ID in <agent_identity>

When making Monitor MCP calls, always pass subscription from agent settings. The workspace parameter accepts the workspace GUID directly.

Secondary: config.json (Optional)

If a config.json file exists at the workspace root, it can provide additional configuration:

Field Used By Purpose
azure_mcp.resource_group Monitor MCP Resource group containing the Log Analytics workspace
azure_mcp.workspace_name Monitor MCP Log Analytics workspace display name
azure_mcp.tenant Monitor MCP, az rest Entra ID tenant
azure_mcp.subscription Monitor MCP Target Azure subscription
sentinel_workspace_id CLI fallback Log Analytics workspace GUID
tenant_id Portal URLs Entra ID tenant ID

Configuration Resolution Order

  1. Agent settings (<log_analytics_access>, <azure_resource_access>) — always available
  2. config.json — read if present, skip if absent
  3. Never prompt the user for workspace parameters if either source is available

Secrets Management (API Tokens)

enrich_ips.py (source: user-investigation/enrich_ips.py, materialized to tmp/computer-investigation/enrich_ips.py) uses 4 external threat intelligence APIs. See the user-investigation SKILL.md for full token configuration details.

Quick summary:

Variable API What it provides
IPINFO_TOKEN ipinfo.io Geolocation, ASN, VPN/proxy/Tor
ABUSEIPDB_TOKEN AbuseIPDB Abuse score, reports
VPNAPI_TOKEN vpnapi.io VPN/proxy/Tor/relay detection
SHODAN_TOKEN Shodan Open ports, services, CVEs

Behavior without tokens: Shodan InternetDB (free, no key) still provides open ports, CVEs, and tags. Q10 (ThreatIntelIndicators) and Q11 (KQL IP context) are SUPPLEMENTS, not replacements for enrich_ips.py.


Phase 0: Investigation Cache Check (MANDATORY)

This phase MUST execute BEFORE any data collection. It determines whether to reuse cached investigation data or start a fresh investigation.

0.1 Cache File Convention

Investigation results are stored as JSON files following this naming pattern:

temp/investigation_device_<device_name>_<YYYYMMDD_HHMMSS>.json

0.2 Cache Check Workflow

Step 0.1: Search for existing cache files matching the device name
          → Use: ls temp/investigation_device_<DEVICE_NAME>_*.json
          → If NO cache file exists → proceed to Phase 1 (fresh investigation)

Step 0.2: If one or more cache files exist, select the MOST RECENT one (latest timestamp)

Step 0.3: Calculate the cache age:
          → Extract timestamp from filename (YYYYMMDD_HHMMSS format)
          → age = current_UTC_time − cache_file_timestamp
          → If age > 4 hours → IGNORE cache entirely, proceed to Phase 1 (fresh investigation)
          → If age ≤ 4 hours → proceed to Step 0.4

Step 0.4: Analyze the user's ORIGINAL prompt for implicit intent:

          REDO KEYWORDS (trigger fresh investigation):
            "repeat", "redo", "refresh", "update", "re-investigate",
            "start over", "from scratch", "new analysis"
          → If ANY redo keyword is detected → IGNORE cache, proceed to Phase 1

          USE-CACHE KEYWORDS (trigger cache reuse):
            "complete", "continue", "finish", "use cached", "use existing",
            "resume", "generate report"
          → If ANY use-cache keyword is detected → LOAD cache, skip to Step 0.6

          NO IMPLICIT INTENT DETECTED:
          → Proceed to Step 0.5 (ask the user)

Step 0.5: ASK the user:

          Question: "I found results from a previous investigation for device
                     <DEVICE_NAME>, completed <TIME_AGO> ago (at <HH:MM> UTC).
                     Would you like to use this data or repeat the investigation
                     from scratch?"
          Options:
            1. "Use existing data" — Resume from the previous investigation
            2. "Repeat from scratch" — Ignore the cache and restart

          → If user selects "Use existing data" → proceed to Step 0.6
          → If user selects "Repeat from scratch" → proceed to Phase 1

Step 0.6: LOAD cached data:
          → Read the JSON file
          → Present a brief inline summary of cached findings
          → Offer HTML/report generation or further investigation

0.3 Cache Decision Summary

Cache Exists? Age User Prompt Action
No Fresh investigation
Yes > 4 hours Fresh investigation — cache expired
Yes ≤ 4 hours Contains REDO keyword Fresh investigation
Yes ≤ 4 hours Contains USE-CACHE keyword Load cache
Yes ≤ 4 hours No implicit intent ASK user

0.4 Important Rules

  • NEVER silently reuse cached data — always either detect explicit intent from the prompt or ask the user.
  • NEVER ask the user if the prompt already contains an implicit answer — detect keywords first.
  • When loading cache, always show what was already completed — the user must understand what data is from cache vs. new queries.
  • Cache files from a DIFFERENT thread/session are still valid — the 4-hour TTL is the only expiration criterion.
  • If the user later requests a fresh investigation after loading cache — discard all cached data and restart from Phase 1.

Critical Workflow Rules

Before starting ANY computer investigation:

  1. ALWAYS complete Phase 0 (Cache Check) first — Before any data collection, check for cached investigation results. See Phase 0 for full logic.
  2. ALWAYS get Device IDs FIRST — try Graph API via Azure CLI, fall back to KQL (Q0)
  3. ALWAYS determine device type (Entra Joined, Hybrid Joined, or Entra Registered)
  4. ALWAYS calculate date ranges correctly — see Date Range Quick Reference
  5. Default to inline output. Markdown file, HTML report, and JSON export are generated ONLY if the user explicitly requests them. Do NOT ask — just deliver inline unless told otherwise.
  6. ALWAYS track and report time after each major step
  7. ALWAYS run independent queries in parallel
  8. ALWAYS use create_file for JSON export and markdown reports — NEVER use terminal commands for file output
  9. Read workspace parameters from agent settings first, then config.json if needed
  10. ⛔ ALWAYS use RunAzCliReadCommands for Graph API calls — NEVER use RunAzCliWriteCommands for az rest --method GET requests. The two tools have DIFFERENT authorization flows: RunAzCliReadCommands authenticates via Managed Identity directly (works with Application permissions). RunAzCliWriteCommands falls back to On-Behalf-Of (OBO) flow when MI fails, and OBO requires Delegated permissions that are NOT configured. Using the wrong tool causes 403 errors.
  11. ⛔ ALWAYS run enrich_ips.py for IP enrichment — This is MANDATORY, not optional. Before running: (a) try reading API tokens from Key Vault via RunAzCliReadCommands, (b) if Key Vault unavailable, ASK the user for API tokens, (c) if no tokens at all, run anyway — Shodan InternetDB (free, no key) still provides open ports, CVEs, and tags. Q11 (KQL IP context) is a SUPPLEMENT, not a replacement.
  12. ⛔ ALWAYS generate the complete formatted report — Every investigation MUST produce the full report following the Markdown Report Template, with ALL sections populated (use ✅ No <X> detected... for empty sections). Never skip the report, never abbreviate, never omit sections.

Device Context Retrieval Strategy

The skill uses a cascading strategy to retrieve device context data:

Strategy 1: Azure CLI (az rest) for Graph API
    ↓ If 403/401 or CLI unavailable
Strategy 2: KQL Fallback Queries (Q0a, Q0b) from DeviceInfo + SigninLogs
    ↓ If DeviceInfo empty (device not onboarded)
Strategy 3: Report "Device not onboarded to Defender or not found in Log Analytics"

IMPORTANT: If Graph API fails with 403, do NOT abort the investigation. Proceed immediately with KQL fallback — DeviceInfo and SigninLogs contain most of the critical device context data.

Device ID Types

  • Entra Device Object ID: Used for Graph API queries (owners, users, compliance) — GUID format
  • Entra Device Registration ID (deviceId in Graph): Different GUID from object ID
  • Defender Device ID: Internal MDE identifier — found in DeviceInfo table (DeviceId column). Different from both Entra IDs.
  • Device Name/Hostname: Human-readable name, use for initial search. Use startswith in KQL to match both hostname and FQDN.

Device Types Reference

Entra Joined Devices

  • trustType: AzureAd
  • Characteristics: Cloud-only, no on-premises AD connection
  • Identity: Uses Entra ID for authentication
  • Common scenarios: Cloud-native organizations, Windows Autopilot deployments

Hybrid Joined Devices

  • trustType: ServerAd (indicates hybrid join with on-premises AD)
  • Characteristics: Joined to both on-premises AD and Entra ID
  • Identity: Uses both on-premises AD and Entra ID
  • Common scenarios: Traditional enterprise environments migrating to cloud

Entra Registered Devices

  • trustType: Workplace
  • Characteristics: Personal/BYOD devices, user adds work account
  • Identity: User authenticates with Entra ID, device not fully managed
  • Common scenarios: BYOD policies, personal device access to corporate resources

Investigation Types

Standard Investigation (7 days)

General security reviews, routine investigations.

Quick Investigation (1 day)

Urgent cases, active malware alerts, recent suspicious activity.

Comprehensive Investigation (30 days)

Deep-dive analysis, lateral movement detection, thorough forensics.

All types include: Security alerts, device inventory, sign-in patterns from device, logged-on users, process execution, network connections, file events, registry modifications, security incidents, threat intelligence matches, and recommendations.

NOT included (data not available via Monitor MCP): Software inventory (TVM), vulnerability assessment (TVM), Defender riskScore, automated investigations, remediation activities.


Output Modes

Default: Inline. Always provide the full investigation results inline in chat. Markdown file and HTML report are generated only if the user explicitly requests them. Multiple modes may be active simultaneously.

Mode 1: Inline Chat Summary (Default — always active)

  • Render the full analysis directly in the chat response
  • No file output — results stay in chat context
  • This is ALWAYS the primary output — never skip it

Mode 2: Markdown File Report (only if explicitly requested)

  • Save to reports/computer-investigations/computer_investigation_<device_name>_<YYYYMMDD_HHMMSS>.md
  • Uses the Markdown Report Template below
  • Use create_file tool — NEVER use terminal commands for file output
  • Trigger: User says "save markdown", "generate MD", "markdown file", "save report", or similar

Mode 3: HTML Report (only if explicitly requested — conditional materialization)

  • Export investigation data to JSON, then generate a styled HTML report via generate_html_report.py
  • Self-contained HTML with embedded CSS/JS — dark theme, two-column layout, interactive IP cards, process/network/file/registry sections, timeline modal
  • Single consolidated script: generate_html_report.py — contains dataclasses, HTML generator, and JSON transformer in one file. No external dependencies beyond Python 3 stdlib.
  • Pipeline: JSON export → materialize generate_html_report.py → run → HTML report
  • Output location: reports/computer-investigations/Investigation_Report_<device_name>_<timestamp>.html
  • ⚠️ Conditional materialization: The script is resolved via the File Resolution cascade ONLY when the user requests HTML output.
    1. Resolve generate_html_report.py via cascade (codeRefs → tmp → Builder)
    2. Run: python3 <resolved_path>/generate_html_report.py temp/investigation_device_<device_name>_<timestamp>.json
  • Trigger: User says "generate HTML", "HTML report", "create HTML", or similar

Mode 4: JSON Export (only if explicitly requested)

  • Export to temp/investigation_device_<device_name>_<timestamp>.json
  • Uses the JSON Export Structure below
  • Trigger: User says "export JSON", "JSON file", or similar

Markdown Rendering Notes

  • ✅ ASCII tables, box-drawing characters, and bar charts render perfectly in markdown code blocks
  • ✅ Unicode block characters (, ) display correctly in monospaced fonts
  • ✅ Emoji indicators (🔴🟢🟡⚠️✅) render natively in GitHub-flavored markdown
  • ✅ Standard markdown tables (| col |) render as formatted tables

Quick Start

  1. Discover workspace parameters from agent settings (<log_analytics_access>, <azure_resource_access>). If not found, read config.json.

  2. Get Device Context (cascading strategy):

    • Try Graph API via Azure CLI:
      az rest --method GET --url "https://graph.microsoft.com/v1.0/devices?$filter=displayName eq '<DEVICE_NAME>'&$select=id,deviceId,displayName,operatingSystem,trustType,isCompliant,isManaged,..." --subscription <subId>
      
    • If 403/fails → KQL Fallback (run Q0a, Q0b in parallel):
      • Q0a: Device identity from DeviceInfo (Defender Device ID, OS, sensor health, exposure)
      • Q0b: Device sign-in context from SigninLogs (trust type, compliance, managed)
  3. Output Mode: Default to inline. Generate MD/HTML/JSON only if explicitly requested.

  4. Run KQL Queries (via Monitor MCP):

    • Batch 1: Q1 (sign-ins), Q2 (alerts), Q3 (processes), Q4 (network), Q5 (files), Q6 (registry), Q7 (incidents), Q8 (device info), Q9 (logon events)
    • Batch 2: Q10 (TI matches), Q11 (KQL IP context) — depends on Batch 1 IPs
  5. Run IP Enrichment (MANDATORY): See Phase 3.

  6. Generate Output based on selected mode — ALWAYS produce inline report. If user requested MD/HTML/JSON, generate those additionally.


Execution Workflow

🚨 MANDATORY: Time Tracking

[MM:SS] ✓ Step description (XX seconds)

Report after: Device ID retrieval, parallel data collection, IP enrichment, report generation, and total elapsed time.


Phase 1: Get Device Context (Cascading Strategy)

Strategy 1: Graph API via Azure CLI

Use Azure CLI (az rest) or RunAzCliReadCommands tool to call Graph API. See get-device-context-via-cli.md for the full step-by-step reference.

Step 1a — Find Device:

az rest --method GET --url "https://graph.microsoft.com/v1.0/devices?$filter=displayName eq '<DEVICE_NAME>'&$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,trustType,isCompliant,isManaged,registrationDateTime,approximateLastSignInDateTime,mdmAppId,profileType,manufacturer,model" --subscription <subId>

Steps 1b–1d (Owners, Registered Users, Intune) — see get-device-context-via-cli.md.

⛔ CRITICAL — Tool Selection:

  • ALWAYS use RunAzCliReadCommands for ALL az rest --method GET calls to Graph API.
  • NEVER use RunAzCliWriteCommands — it has a different auth flow (MI → OBO fallback) that fails without Delegated permissions.
  • NEVER use RunInTerminal with az commands — the az binary may not be in the shell PATH.

If Step 1a returns 403: Stop Graph API calls immediately. Proceed to Strategy 2.

Strategy 2: KQL Fallback (when Graph API is unavailable)

Run Q0a and Q0b queries in parallel via monitor-client_monitor_workspace_log_query. These extract device context from DeviceInfo and SigninLogs.

See Q0 queries below for the full KQL.

Data coverage comparison:

Data Point Graph API KQL Fallback (Q0) Notes
Device Name displayName DeviceName from DeviceInfo Identical
OS operatingSystem OSPlatform from DeviceInfo Identical
OS Version operatingSystemVersion OSVersion from DeviceInfo Identical
Trust Type trustType ⚠️ JoinType from DeviceInfo or SigninLogs Similar but different field names
Compliance isCompliant ⚠️ From SigninLogs DeviceDetail Only for devices with sign-in activity
Managed isManaged ⚠️ From SigninLogs DeviceDetail Only for devices with sign-in activity
Manufacturer manufacturer ❌ Not in DeviceInfo Report as "Unknown (Graph unavailable)"
Model model ❌ Not in DeviceInfo Report as "Unknown (Graph unavailable)"
Registration Date registrationDateTime ❌ Not in DeviceInfo Report as "Unknown (Graph unavailable)"
Defender Device ID ❌ Not in Graph DeviceId from DeviceInfo Only from KQL
Sensor Health ❌ Not in Graph SensorHealthState from DeviceInfo Only from KQL
Exposure Level ❌ Not in Graph ExposureLevel from DeviceInfo Only from KQL
Internet Facing ❌ Not in Graph IsInternetFacing from DeviceInfo Only from KQL
Device Owners /registeredOwners ❌ Not in KQL Report as "Unknown (Graph unavailable)"
Defender riskScore ❌ Only in MDE API ❌ Not in DeviceInfo Data gap — not available in this environment

Best practice: Run BOTH Graph API AND Q0a. Graph provides owners/manufacturer; DeviceInfo provides sensor health/exposure/internet-facing. Merge results.


Phase 2: Parallel KQL Data Collection (Monitor MCP)

Execute KQL queries via monitor-client_monitor_workspace_log_query.

Required parameters for every call (from agent settings):

  • subscription — from <azure_resource_access>
  • resource-group — from agent ARM resource ID or config
  • workspace — workspace GUID from <log_analytics_access>
  • table — primary table for the query
  • query — KQL query string
  • hours — time range in hours (168 for 7d, 24 for 1d, 720 for 30d)

Batch 1 (run in parallel):

Query Description Table Depends On
Q0a/Q0b Device context (if Graph failed) DeviceInfo, SigninLogs Nothing
Q1 Device sign-in events SigninLogs Nothing
Q2 Security alerts SecurityAlert Nothing
Q3 Process execution events DeviceProcessEvents Nothing
Q4 Network connection events DeviceNetworkEvents Nothing
Q5 File events DeviceFileEvents Nothing
Q6 Registry events DeviceRegistryEvents Nothing
Q7 Security incidents SecurityAlert, SecurityIncident Nothing
Q8 Device inventory DeviceInfo Nothing
Q9 Logon events DeviceLogonEvents Nothing

After Batch 1: Extract IPs

  • Extract public IPs from Q4 results (network connections) and Q1 results (sign-in IPs)
  • Build IP array: ["ip1", "ip2", "ip3", ...]

Batch 2 (run in parallel, depends on Batch 1):

Query Description Table
Q10 Threat intelligence IP matches ThreatIntelIndicators, DeviceNetworkEvents
Q11 KQL-based IP context analysis SigninLogs

Phase 3: IP Enrichment (⛔ MANDATORY)

⚠️ This phase is NOT optional. Every investigation MUST include IP enrichment via enrich_ips.py. Skipping this phase is a critical workflow violation.

Step 3a: Retrieve API Tokens

Before running enrich_ips.py, attempt to retrieve API tokens from Key Vault using RunAzCliReadCommands:

az rest --method GET --url "https://<vault-name>.vault.azure.net/secrets/<secret-name>?api-version=7.4" --resource "https://vault.azure.net" --subscription <subId>

⚠️ Key Vault access: Use az rest --resource "https://vault.azure.net" — NOT az keyvault (which is unsupported by RunAzCliReadCommands). If Key Vault returns ForbiddenByConnection, ask the user to temporarily enable public access or add Azure Services firewall bypass.

  • If Key Vault succeeds: extract token value from the JSON response value field, pass as env var.
  • If Key Vault fails (403/ForbiddenByConnection): ASK the user for API tokens.
  • If user has no tokens: proceed anyway — Shodan InternetDB (free, no key) still provides open ports, CVEs, and tags.

Step 3b: Resolve and Run enrich_ips.py

Resolve enrich_ips.py using the File Resolution cascade:

  1. Check codeRefs/sec-sre-ag/computer-investigation/enrich_ips.py → if found, run from there.
  2. Else check tmp/computer-investigation/enrich_ips.py → if found, run from there.
  3. Else: read_skill_file("user-investigation", "enrich_ips.py")CreateFile("tmp/computer-investigation/enrich_ips.py", <content>) → run from tmp/.
  4. Run:
# Pass available tokens as environment variables
ABUSEIPDB_TOKEN=<value> IPINFO_TOKEN=<value> python3 <resolved_path>/enrich_ips.py <ip1> <ip2> <ip3>

Output: JSON file in temp/ip_enrichment_<timestamp>.json + text report in temp/ip_enrichment_<timestamp>.txt

Step 3c: KQL Supplements (already in Batch 2)

Q10 (ThreatIntelIndicators) and Q11 (KQL IP context) — already executed in Batch 2 — are SUPPLEMENTS to enrich_ips.py. They do NOT replace it.


Phase 4: Export & Generate Report (⛔ MANDATORY)

⚠️ This phase is NOT optional. Every investigation MUST produce the complete formatted report. Never skip the report, never abbreviate, never omit sections.

Mode 1 — Inline Chat Summary

Render analysis directly in chat using the complete section structure from the Markdown Report Template. All sections must be present.

Mode 2 — Markdown File Report

  1. Build the markdown report using the template below — ALL sections must be populated
  2. Save: create_file("reports/computer-investigations/computer_investigation_<device_name>_YYYYMMDD_HHMMSS.md", content)

Mode 3 — HTML Report (Conditional — File Resolution Cascade)

⚠️ Resolve generate_html_report.py ONLY when the user requests HTML output.

  1. Export to JSON: create_file("temp/investigation_device_<device_name>_<timestamp>.json", content)
  2. Resolve generate_html_report.py via the File Resolution cascade:
    • Check codeRefs/sec-sre-ag/computer-investigation/generate_html_report.py → if found, use that path.
    • Else check tmp/computer-investigation/generate_html_report.py → if found, use that path.
    • Else: read_skill_file("computer-investigation", "generate_html_report.py")CreateFile("tmp/computer-investigation/generate_html_report.py", <content>)
  3. Run: python3 <resolved_path>/generate_html_report.py temp/investigation_device_<device_name>_<timestamp>.json

Sending the HTML Report by Email

If the user asks to send the report by email, use the office365_SendEmailV2 tool.

CRITICAL: The attachment ContentBytes field accepts ONLY a file path in the workspace (a plain string), NOT direct base64 content or JSON objects. The tool reads and encodes the file automatically. If the tool returns isError: false, the email was sent successfully — DO NOT send it again.

{
  "To": "<recipient-email>",
  "Subject": "<subject with skill name, tenant, and primary result>",
  "Importance": "High",
  "Body": "<p>HTML summary of the results.</p>",
  "Attachments": [
    {
      "Name": "<readable-name>.html",
      "ContentBytes": "reports/<skill-name>/<generated-file-name>.html"
    }
  ]
}

See docs/email-html-report.md for complete documentation of this pattern.

Sending the Report through Teams

If the user asks to send the report through Teams, use PostMessageToConversation with poster, location, AND the body parameter. The schema does not declare body, but the server accepts it. The body contains recipient (groupId and channelId for channels, a chat ID for group chats, or an email address for a Flow bot DM) and messageBody (HTML).

CRITICAL: ALWAYS include the body parameter even though the tool schema does not declare it. Without body, the server returns "Message body is missing." or "Group ID does not exist.". If the tool returns success, the message was sent — DO NOT send it again.

See docs/teams-delivery.md for complete JSON patterns for every poster/location combination.

Mode 4 — JSON Export

  1. Export: create_file("temp/investigation_device_<device_name>_<timestamp>.json", content)
  2. Merge all results into one dict (see JSON Export Structure)

KQL Execution Reference

Primary: Azure Monitor MCP Tool

Use monitor-client_monitor_workspace_log_query for all KQL queries.

Tool parameters:

Parameter Required Source
workspace Yes Workspace GUID from <log_analytics_access>
resource-group Yes From agent ARM resource ID or config
subscription Yes From <azure_resource_access>
table Yes Primary table for the query (e.g., DeviceInfo)
query Yes KQL query string
hours Optional Lookback period in hours (overrides in-query time filters)

Fallback: Azure CLI

If the Monitor MCP tool fails, use Azure CLI with:

az monitor log-analytics query --workspace "<workspace_GUID>" --analytics-query "<KQL_QUERY>" --timespan "P7D" --subscription <subId>

⚠️ Shell az vs Tool: If RunAzCliReadCommands tool is available, prefer it. The az CLI binary may NOT be in the shell PATH in some environments.

Timestamp Column

All tables used in this skill use TimeGenerated — no adaptation needed.

Retention

Log Analytics workspace retention is typically 90 days (configurable). No 30-day cap.

Known Table Pitfalls (Log Analytics)

Table Pitfall Fix
DeviceInfo LoggedOnUsers is a JSON array, not a simple string mv-expand parse_json(LoggedOnUsers)
DeviceNetworkEvents Some columns like SentBytes, ReceivedBytes may not be present Handle missing columns gracefully
SigninLogs DeviceDetail, LocationDetails, Status may be dynamic OR string Always use tostring(parse_json(...)) pattern
SigninLogs Location is string, not dynamic — Location.countryOrRegion fails Use parse_json(LocationDetails).countryOrRegion
SecurityAlert Status field is immutable — always "New" Join with SecurityIncident for real status
SecurityIncident AlertIds contains alert GUIDs, NOT entity names Filter SecurityAlert first, then join
ThreatIntelIndicators Can be very large (100K+ rows) Filter IsActive/ValidUntil before string transformations
DeviceProcessEvents ProcessCommandLine can be very long Use strlen() checks; truncate in output
DeviceRegistryEvents RegistryKey paths can be very verbose Focus on persistence-related keys only

Common KQL Anti-Patterns

Anti-Pattern Fix
mv-expand on string column containing JSON mv-expand parsed = parse_json(StringColumn)
dcount() on dynamic column dcount(tostring(DynamicColumn))
bin() missing argument Always: bin(TimeGenerated, 1h)
iff() with mismatched branch types Cast both: iff(cond, todouble(x), todouble(y))
Joining on dynamic column Cast before join: extend AlertId = tostring(AlertId)

Data Gaps (Not Available via Monitor MCP)

Data Reason Alternative
Software Inventory DeviceTvmSoftwareInventory is Advanced Hunting only Note as data gap in report
Vulnerabilities (CVEs) DeviceTvmSoftwareVulnerabilities is Advanced Hunting only Note as data gap in report
Defender riskScore Only available via MDE API (GetDefenderMachine) Use ExposureLevel from DeviceInfo as proxy
Automated Investigations Only via MDE API (ListDefenderInvestigations) Note as data gap
Remediation Activities Only via MDE API (ListDefenderRemediationActivities) Note as data gap

📅 Date Range Quick Reference

🔴 STEP 0: GET CURRENT DATE FIRST (MANDATORY) 🔴 Check the current date from the context header BEFORE calculating date ranges. NEVER use hardcoded years.

RULE 1: Real-Time/Recent Searches: Add +2 days to current date for end range (+1 timezone offset + +1 inclusive end-of-day).

RULE 2: Historical Searches: Add +1 day to user's specified end date.

User Request <StartDate> <EndDate> Rule
"Last 7 days" current − 7d current + 2d Rule 1
"Last 30 days" current − 30d current + 2d Rule 1
"May 20 to May 23" 2026-05-20 2026-05-24 Rule 2

Sample KQL Queries

Replace <DEVICE_NAME>, <StartDate>, <EndDate> in these patterns. Execute via Monitor MCP tool.

⚠️ CRITICAL: START WITH THESE EXACT QUERY PATTERNS These queries have been tested and validated. Use them as your PRIMARY reference.


Q0. KQL-Based Device Context Extraction (Graph API Fallback)

When to use: Graph API is unavailable (403 or CLI missing). These queries extract device context from DeviceInfo and SigninLogs.

Run Q0a and Q0b in parallel.

Q0a. Device Identity from DeviceInfo

Extracts Defender Device ID, OS, sensor health, exposure level, and key properties.

let deviceName = '<DEVICE_NAME>';
DeviceInfo
| where DeviceName startswith deviceName
| summarize arg_max(TimeGenerated, *) by DeviceId
| project 
    TimeGenerated,
    DeviceId,
    DeviceName,
    OSPlatform,
    OSVersion,
    OSBuild,
    OSArchitecture,
    MachineGroup,
    OnboardingStatus,
    SensorHealthState,
    ExposureLevel,
    IsAzureADJoined,
    IsInternetFacing,
    JoinType,
    PublicIP,
    DeviceManualTags,
    DeviceDynamicTags,
    RegistryDeviceTag,
    LoggedOnUsers

Q0b. Device Compliance & Trust from SigninLogs

Extracts compliance status, managed status, and trust type from sign-in activity — useful when Graph API is unavailable.

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
SigninLogs
| where TimeGenerated between (start .. end)
| extend DeviceDetailStr = tostring(DeviceDetail)
| where DeviceDetailStr has deviceName
| extend ParsedDevice = parse_json(DeviceDetailStr)
| where ResultType == '0'
| top 1 by TimeGenerated desc
| project 
    TimeGenerated,
    DeviceName = tostring(ParsedDevice.displayName),
    DeviceOS = tostring(ParsedDevice.operatingSystem),
    TrustType = tostring(ParsedDevice.trustType),
    IsCompliant = tostring(ParsedDevice.isCompliant),
    IsManaged = tostring(ParsedDevice.isManaged),
    UserPrincipalName

Q1. Device Sign-In Events (Who authenticated on this device)

Note: DeviceDetail is dynamic in SigninLogs but string in AADNonInteractiveUserSignInLogs. Query SigninLogs only for device context (interactive sign-ins contain device info). Do NOT use union with DeviceDetail filtering.

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
SigninLogs
| where TimeGenerated between (start .. end)
| extend DeviceDetailStr = tostring(DeviceDetail)
| where DeviceDetailStr has deviceName
| extend ParsedDevice = parse_json(DeviceDetailStr)
| extend DeviceName = tostring(ParsedDevice.displayName)
| extend DeviceId = tostring(ParsedDevice.deviceId)
| extend DeviceOS = tostring(ParsedDevice.operatingSystem)
| extend DeviceTrustType = tostring(ParsedDevice.trustType)
| extend DeviceCompliant = tostring(ParsedDevice.isCompliant)
| summarize 
    SignInCount = count(),
    SuccessCount = countif(ResultType == '0'),
    FailureCount = countif(ResultType != '0'),
    UniqueUsers = dcount(UserPrincipalName),
    Users = make_set(UserPrincipalName, 10),
    Applications = make_set(AppDisplayName, 10),
    IPAddresses = make_set(IPAddress, 10),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by DeviceName, DeviceOS, DeviceTrustType, DeviceCompliant
| order by SignInCount desc

Q2. Device Security Alerts (SecurityAlert table)

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
SecurityAlert
| where TimeGenerated between (start .. end)
| where Entities has deviceName or CompromisedEntity has deviceName
| summarize arg_max(TimeGenerated, *) by SystemAlertId
| project 
    TimeGenerated,
    AlertName,
    AlertSeverity,
    Status,
    Description,
    ProviderName,
    Tactics,
    Techniques,
    CompromisedEntity,
    RemediationSteps
| order by TimeGenerated desc
| take 20

Q3. Process Execution Events (Suspicious processes)

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
DeviceProcessEvents
| where TimeGenerated between (start .. end)
| where DeviceName startswith deviceName
| where ActionType in ("ProcessCreated", "ProcessCreatedUsingWmiQuery")
| extend CommandLineLength = strlen(ProcessCommandLine)
| extend IsSuspicious = case(
    ProcessCommandLine has_any ("powershell", "cmd", "wscript", "cscript") and ProcessCommandLine has_any ("-enc", "-e ", "bypass", "hidden", "downloadstring", "invoke-expression", "iex"), true,
    ProcessCommandLine has_any ("certutil", "bitsadmin") and ProcessCommandLine has_any ("download", "transfer", "urlcache"), true,
    ProcessCommandLine has_any ("reg", "registry") and ProcessCommandLine has_any ("add", "delete") and ProcessCommandLine has_any ("run", "runonce"), true,
    FileName in~ ("mimikatz.exe", "procdump.exe", "psexec.exe", "cobaltstrike", "beacon.exe"), true,
    CommandLineLength > 500, true,
    false)
| summarize 
    ProcessCount = count(),
    SuspiciousCount = countif(IsSuspicious),
    UniqueProcesses = dcount(FileName),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    SampleCommands = make_set(ProcessCommandLine, 5)
    by FileName, FolderPath, AccountName, AccountDomain
| where SuspiciousCount > 0 or ProcessCount > 50
| order by SuspiciousCount desc, ProcessCount desc
| take 20

Q4. Network Connection Events (Outbound connections)

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
DeviceNetworkEvents
| where TimeGenerated between (start .. end)
| where DeviceName startswith deviceName
| where ActionType == "ConnectionSuccess"
| where RemoteIPType != "Private"
| summarize 
    ConnectionCount = count(),
    UniqueRemoteIPs = dcount(RemoteIP),
    UniqueRemotePorts = dcount(RemotePort),
    Protocols = make_set(Protocol, 5),
    InitiatingProcesses = make_set(InitiatingProcessFileName, 10),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by RemoteIP, RemotePort, RemoteUrl
| order by ConnectionCount desc
| take 30

Q5. File Events (File creation/modification/deletion)

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
DeviceFileEvents
| where TimeGenerated between (start .. end)
| where DeviceName startswith deviceName
| where ActionType in ("FileCreated", "FileModified", "FileDeleted", "FileRenamed")
| extend FileExtension = tostring(split(FileName, ".")[-1])
| extend IsSuspicious = case(
    FileExtension in~ ("exe", "dll", "bat", "cmd", "ps1", "vbs", "js", "hta", "scr", "pif"), true,
    FolderPath has_any ("\\temp\\", "\\tmp\\", "\\appdata\\local\\temp", "\\programdata\\", "\\users\\public\\"), true,
    false)
| summarize 
    FileEventCount = count(),
    SuspiciousCount = countif(IsSuspicious),
    CreatedCount = countif(ActionType == "FileCreated"),
    ModifiedCount = countif(ActionType == "FileModified"),
    DeletedCount = countif(ActionType == "FileDeleted"),
    UniqueFiles = dcount(FileName),
    FileExtensions = make_set(FileExtension, 10),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by FolderPath, InitiatingProcessFileName
| where SuspiciousCount > 0 or FileEventCount > 100
| order by SuspiciousCount desc, FileEventCount desc
| take 20

Q6. Registry Events (Registry modifications)

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
DeviceRegistryEvents
| where TimeGenerated between (start .. end)
| where DeviceName startswith deviceName
| where ActionType in ("RegistryValueSet", "RegistryKeyCreated")
| extend IsPersistence = case(
    RegistryKey has_any ("\\CurrentVersion\\Run", "\\CurrentVersion\\RunOnce", "\\CurrentVersion\\RunServices"), true,
    RegistryKey has_any ("\\Policies\\Explorer\\Run", "\\Active Setup\\Installed Components"), true,
    RegistryKey has_any ("\\Image File Execution Options\\", "\\Winlogon\\", "\\BootExecute"), true,
    RegistryKey has_any ("\\Services\\", "\\Drivers\\"), true,
    false)
| summarize 
    RegistryEventCount = count(),
    PersistenceCount = countif(IsPersistence),
    UniqueKeys = dcount(RegistryKey),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by RegistryKey, RegistryValueName, InitiatingProcessFileName
| where PersistenceCount > 0
| order by PersistenceCount desc, RegistryEventCount desc
| take 20

Q7. Security Incidents Containing Device

let deviceName = '<DEVICE_NAME>';
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let relevantAlerts = SecurityAlert
| where TimeGenerated between (start .. end)
| where Entities has deviceName or CompromisedEntity has deviceName
| summarize arg_max(TimeGenerated, *) by SystemAlertId
| project SystemAlertId, AlertName, AlertSeverity, ProviderName, Tactics;
SecurityIncident
| where CreatedTime between (start .. end)
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| where not(tostring(Labels) has "Redirected")
| mv-expand AlertId = AlertIds
| extend AlertId = tostring(AlertId)
| join kind=inner relevantAlerts on $left.AlertId == $right.SystemAlertId
| extend ProviderIncidentUrl = tostring(AdditionalData.providerIncidentUrl)
| extend OwnerUPN = tostring(Owner.userPrincipalName)
| summarize 
    Title = any(Title),
    Severity = any(Severity),
    Status = any(Status),
    Classification = any(Classification),
    CreatedTime = any(CreatedTime),
    LastModifiedTime = any(LastModifiedTime),
    OwnerUPN = any(OwnerUPN),
    ProviderIncidentUrl = any(ProviderIncidentUrl),
    AlertCount = count(),
    Tactics = make_set(Tactics)
    by ProviderIncidentId
| order by LastModifiedTime desc
| take 10

Q8. Device Inventory and Configuration

Note: RiskScore is NOT available via DeviceInfo. Use ExposureLevel and SensorHealthState as indicators.

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
DeviceInfo
| where TimeGenerated between (start .. end)
| where DeviceName startswith deviceName
| summarize arg_max(TimeGenerated, *) by DeviceId
| project 
    TimeGenerated,
    DeviceId,
    DeviceName,
    OSPlatform,
    OSVersion,
    OSBuild,
    OSArchitecture,
    LoggedOnUsers,
    MachineGroup,
    DeviceCategory,
    OnboardingStatus,
    SensorHealthState,
    ExposureLevel,
    IsAzureADJoined,
    IsInternetFacing,
    JoinType,
    PublicIP,
    DeviceManualTags,
    DeviceDynamicTags,
    RegistryDeviceTag

Q9. Logon Events on Device

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
DeviceLogonEvents
| where TimeGenerated between (start .. end)
| where DeviceName startswith deviceName
| summarize 
    LogonCount = count(),
    SuccessCount = countif(ActionType == "LogonSuccess"),
    FailureCount = countif(ActionType == "LogonFailed"),
    UniqueAccounts = dcount(AccountName),
    LogonTypes = make_set(LogonType, 5),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    RemoteIPs = make_set(RemoteIP, 10)
    by AccountName, AccountDomain, LogonType
| order by LogonCount desc
| take 20

IP Enrichment via enrich_ips.py (External APIs)

When to use: ALWAYS — this is mandatory (see Critical Workflow Rules #10). See Secrets Management for token configuration.

Execution via RunInTerminal:

python3 tmp/computer-investigation/enrich_ips.py <ip1> <ip2> <ip3>

Output: JSON file in temp/ip_enrichment_<timestamp>.json + text report in temp/ip_enrichment_<timestamp>.txt


Q10. Threat Intelligence IP Matches (Device Network Traffic)

Performance notes: ThreatIntelIndicators can be large. Filter IsActive/ValidUntil before string transformations — reduce data first, transform later.

let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let deviceName = '<DEVICE_NAME>';
let device_ips = DeviceNetworkEvents
| where TimeGenerated between (start .. end)
| where DeviceName startswith deviceName
| where RemoteIPType != "Private"
| distinct RemoteIP;
ThreatIntelIndicators
| where IsActive and (ValidUntil > now() or isempty(ValidUntil))
| where tostring(split(ObservableKey, ":")[0]) in ("ipv4-addr", "ipv6-addr", "network-traffic")
| where ObservableValue in (device_ips)
| extend Description = tostring(parse_json(Data).description)
| where Description !contains_cs "State: inactive;" and Description !contains_cs "State: falsepos;"
| summarize arg_max(TimeGenerated, *) by ObservableValue
| project 
    TimeGenerated,
    IPAddress = ObservableValue,
    ThreatDescription = Description,
    Confidence,
    ValidUntil,
    IsActive
| order by Confidence desc
| take 20

Q11. KQL-Based IP Context Analysis (Supplement to enrich_ips.py)

When to use: Always run as a supplement in Batch 2. This provides workspace-specific context (how many users share an IP, failure patterns) that external APIs cannot provide. This does NOT replace enrich_ips.py.

let target_ips = dynamic(["<IP_1>", "<IP_2>", "<IP_3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
let ip_user_diversity = 
    union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
    | where TimeGenerated between (start .. end)
    | where IPAddress in (target_ips)
    | summarize 
        TotalUsers = dcount(UserPrincipalName),
        UserList = make_set(UserPrincipalName, 5),
        TotalSignIns = count(),
        UniqueApps = dcount(AppDisplayName),
        TopApps = make_set(AppDisplayName, 3),
        Locations = make_set(Location, 3)
        by IPAddress;
let ip_failure_patterns =
    union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
    | where TimeGenerated between (start .. end)
    | where IPAddress in (target_ips)
    | where ResultType != '0'
    | summarize 
        FailedUsers = dcount(UserPrincipalName),
        TotalFailures = count(),
        FailureCodes = make_set(ResultType, 5)
        by IPAddress;
ip_user_diversity
| join kind=leftouter ip_failure_patterns on IPAddress
| extend RiskIndicator = case(
    TotalUsers > 10 and TotalFailures > 50, "🔴 High — shared IP with many failures (possible brute-force source)",
    TotalUsers > 5 and TotalFailures > 20, "🟠 Medium — shared IP with notable failures",
    TotalUsers == 1 and TotalFailures == 0, "🟢 Low — dedicated IP, no failures from other users",
    TotalUsers == 1 and TotalFailures > 0, "🟡 Low — dedicated IP with some failures",
    TotalUsers > 1 and TotalFailures == 0, "🟢 Low — shared corporate/VPN IP, no failures",
    "🟡 Requires review")
| extend IPType = case(
    TotalUsers > 5, "Shared (corporate/VPN/proxy)",
    TotalUsers > 1, "Small group (team/office)",
    "Dedicated (individual)")
| project IPAddress, IPType, TotalUsers, TotalSignIns, UniqueApps, Locations,
    FailedUsers = coalesce(FailedUsers, 0), TotalFailures = coalesce(TotalFailures, 0),
    FailureCodes = coalesce(FailureCodes, dynamic([])),
    RiskIndicator, UserList, TopApps
| order by TotalFailures desc, TotalUsers desc

Interpretation guidance:

  • Dedicated IP (1 user): Likely personal or home connection. Low risk if no failures.
  • Small group (2-5 users): Likely office or small team. Normal.
  • Shared (>5 users): Corporate VPN, proxy, or NAT. Check failure patterns.
  • Azure IP ranges (4.x.x.x, 13.x.x.x, 20.x.x.x, 40.x.x.x, 52.x.x.x, 104.x.x.x, 168.x.x.x, 172.x.x.x): Often Azure datacenter IPs. Failures may be automated token refresh, not attacks.

Entra ID Device Data Collection

Method 1: Azure CLI (Primary)

Use Azure CLI (az rest) or RunAzCliReadCommands tool to call Microsoft Graph API. This is the preferred method when Graph API permissions are available.

See get-device-context-via-cli.md for the complete step-by-step reference with all 5 calls.

Summary of calls (Step 1 must complete before Steps 2–5):

Step API Call Key Output
1 az rest --method GET --url ".../v1.0/devices?$filter=displayName eq '...'&$select=..." Device IDs, OS, trust type, compliance
2 az rest --method GET --url ".../devices/<OBJECT_ID>/registeredOwners" Device owners
3 az rest --method GET --url ".../devices/<OBJECT_ID>/registeredUsers" Registered users
4 az rest --method GET --url ".../deviceManagement/managedDevices?$filter=..." Intune details (optional)
5 az rest --method GET --url ".../informationProtection/bitlocker/recoveryKeys?$filter=..." BitLocker keys (optional)

⛔ CRITICAL — Tool Selection:

  • ALWAYS use RunAzCliReadCommands for ALL az rest --method GET calls.
  • NEVER use RunAzCliWriteCommands — it triggers an OBO fallback flow that fails without Delegated permissions, causing 403 errors even when Application permissions are correctly assigned.
  • NEVER use RunInTerminal with az commands — the az binary may not be in the shell PATH.

If Step 1 returns 403: Stop Graph API calls immediately. Proceed to Method 2.

Method 2: KQL Fallback (When Graph is Unavailable)

Run Q0a and Q0b in parallel (see Q0 queries).

DeviceInfo provides most of the critical operational data (sensor health, exposure level, internet-facing status, tags) that Graph API does NOT provide. Always run Q0a/Q8 even when Graph API succeeds.


Markdown Report Template

When outputting to markdown file (Mode 2), use this template. Populate ALL sections with actual query data. For sections with no data, use the explicit absence confirmation pattern.

Filename: reports/computer-investigations/computer_investigation_<device_name>_YYYYMMDD_HHMMSS.md

# Computer Security Investigation Report

**Generated:** YYYY-MM-DD HH:MM UTC
**Workspace:** <workspace_name>
**Device:** `<DEVICE_NAME>`
**OS:** <operating_system> <os_version>
**Trust Type:** <Entra Joined / Hybrid Joined / Entra Registered> (`<trustType>`)
**Compliance:** <Compliant/Non

*Truncated - read the full file at https://github.com/stefanpems/sec-sre-ag/blob/9bba45cbfc1d6d7701ddf04c6b1b8bf7e84ecd8b/.builder/computer-investiga

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/stefanpems-sec-sre-ag-computer-investigation/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

stefanpems-sec-sre-ag-computer-investigation.ocm.jsonjson
{
  "ocm": "1",
  "id": "stefanpems-sec-sre-ag-computer-investigation",
  "kind": "skill",
  "name": "computer-investigation",
  "description": "Computer/device security investigation skill for environments with Azure Monitor MCP (Log Analytics workspace queries) and Azure CLI access — currently without Sentinel Data Lake MCP, Sentinel Triage MCP, or Microsoft Graph MCP (these cannot be connected to Azure SRE Agent yet; direct API access to Sentinel Data Lake and Microsoft Graph not yet implemented). Device data from Entra ID is collected via Azure CLI (`az rest` for Graph API) or KQL fallback queries from DeviceInfo/SigninLogs. KQL queries run against Log Analytics tables through the Azure Monitor MCP tool. TVM tables (software inventory, vulnerabilities) are NOT available through Log Analytics.",
  "publisher": "stefanpems",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "coding"
    ],
    "tags": [
      "skill-md",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "Computer/device security investigation skill for environments with Azure Monitor MCP (Log Analytics workspace queries) and Azure CLI access — currently without Sentinel Data Lake MCP, Sentinel Triage MCP, or Microsoft Graph MCP (these cannot be connected to Azure SRE Agent yet; direct API access to Sentinel Data Lake and Microsoft Graph not yet implemented). Device data from Entra ID is collected via Azure CLI (`az rest` for Graph API) or KQL fallback queries from DeviceInfo/SigninLogs. KQL queries run against Log Analytics tables through the Azure Monitor MCP tool. TVM tables (software inventory, vulnerabilities) are NOT available through Log Analytics."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/stefanpems/sec-sre-ag",
      "path": ".builder/computer-investigation/SKILL.md",
      "ref": "9bba45cbfc1d6d7701ddf04c6b1b8bf7e84ecd8b",
      "url": "https://github.com/stefanpems/sec-sre-ag/blob/9bba45cbfc1d6d7701ddf04c6b1b8bf7e84ecd8b/.builder/computer-investigation/SKILL.md",
      "key": "stefanpems/sec-sre-ag/.builder/computer-investigation/SKILL.md"
    }
  },
  "instructions": "> 📏 **Result & Token Guardrails** — See [shared-guardrails.md](../shared-guardrails.md) for universal result-cap, time-window, and aggregation rules that apply to all skills.\n\n> ⚠️ **CRITICAL OUTPUT RULE — ALWAYS PRESENT RESULTS INLINE FIRST**\n>\n> The investigation results MUST be presented **inline in chat** as the primary output. This is mandatory and non-negotiable.\n> - Phase 2 findings: present the full analysis inline (timeline, alerts, key findings, risk assessment)\n> - HTML report generation: ONLY when the user explicitly requests it (e.g., \"generate HTML\", \"create report\")\n> - JSON exp",
  "cost": {
    "context_tokens": 19898
  }
}

Fetch it by URL: GET /api/v1/registry/stefanpems-sec-sre-ag-computer-investigation/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.