Agent skill

Geomap Visualization

by SCStelz in SCStelz/security-investigator

A skill your agent uses when asked to create geographic maps, visualize attack origins on a world map, show location-based data, or display IP geolocation.

MITAuto-check passedAgent Workflows

Install Geomap Visualization

skills CLI
$ npx skills add SCStelz/security-investigator --skill geomap-visualization -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install SCStelz/security-investigator geomap-visualization --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.github/skills/geomap-visualization .claude/skills/geomap-visualization && rm -rf skills-src

Use ~/.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/

Facts

Skill name
geomap-visualization
GitHub stars
249
Token cost
~7.6k tokens
SKILL.md length
1,298 words
Files
1
Skills in repo
22
Repo updated
First seen
Licence
MIT

At a glance

A skill your agent uses when asked to create geographic maps, visualize attack origins on a world map, show location-based data, or display IP geolocation.

  • Works in 8 steps: Quick Start - Minimal example to get… → MCP Tool Reference - Parameters and… → Data Sources - Tables with native vs… → …
  • Asked to create geographic maps
  • SKILL.md covers Purpose, 📑 TABLE OF CONTENTS, Quick Start and MCP Tool Reference, plus 4 more sections
  • Calls python

What it does

Geomap Visualization is an agent skill from SCStelz/security-investigator. Use this skill when asked to create geographic maps, visualize attack origins on a world map, show location-based data, or display IP geolocation. Triggers on keywords like "geomap", "world map", "geographic", "attack map", "show on map", "visualize locations", "attack origins", or when analyzing data with latitude/longitude coordinates.

Its SKILL.md is about 7.6k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Agent Workflows, covering Data analysis and MCP servers. It works with Microsoft Sentinel. 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.

When your agent uses it

  • Asked to create geographic maps
  • Visualize attack origins on a world map
  • Show location-based data
  • Display IP geolocation

Example prompts

  • “geomap”
  • “world map”
  • “geographic”
  • “/geomap-visualization”

Requirements

  • Python 3

Workflow steps

8 steps, taken from the first numbered list in SKILL.md.

  1. Quick Start - Minimal example to get started
  2. MCP Tool Reference - Parameters and schemas
  3. Data Sources - Tables with native vs enriched geolocation
  4. KQL Query Patterns - Ready-to-use queries by scenario
  5. Enrichment Integration - Adding threat intel drill-down
  6. Examples - End-to-end workflows
  7. Follow-Up Investigation Queries - Queries for selected IPs
  8. Interactive Selection Feature - Multi-select and chat integration

What it can do on your machine

Read from SKILL.md and the folder at commit b38152e. It shows what the files ask for, not the result of running them.

  • Tool permissions

    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.

  • Runs code

    Shell commands in SKILL.md call:

    • python

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    No URLs in SKILL.md.

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Geomap Visualization loads about 7.6k tokens when it runs. Until then it costs about 90 tokens; SKILL.md has 1,298 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~90
When it runs · the whole SKILL.md, loaded when a task matches
~7.6k

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.

Safety

Auto-check passed

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.

SKILL.md

The full file from SCStelz/security-investigator at commit b38152e, republished under its MIT licence (© SCStelz). 1,298 words, ~7,596 tokens.

Download SKILL.mdSave it as .claude/skills/geomap-visualization/SKILL.md (or your agent's skills folder).
name
geomap-visualization
description
Use this skill when asked to create geographic maps, visualize attack origins on a world map, show location-based data, or display IP geolocation. Triggers on keywords like "geomap", "world map", "geographic", "attack map", "show on map", "visualize locations", "attack origins", or when analyzing data with latitude/longitude coordinates.

Geomap Visualization Skill

Purpose

Generate interactive world map visualizations from Microsoft Sentinel data using the Sentinel Geomap MCP App. Geomaps display markers on a world map with coordinates, ideal for visualizing attack origins, geographic distribution of threats, or location-based security data.


📑 TABLE OF CONTENTS

  1. Quick Start - Minimal example to get started
  2. MCP Tool Reference - Parameters and schemas
  3. Data Sources - Tables with native vs enriched geolocation
  4. KQL Query Patterns - Ready-to-use queries by scenario
  5. Enrichment Integration - Adding threat intel drill-down
  6. Examples - End-to-end workflows
  7. Follow-Up Investigation Queries - Queries for selected IPs
  8. Interactive Selection Feature - Multi-select and chat integration

Quick Start

Minimal Geomap (3 Steps)
# 1. Query Sentinel for data with coordinates
mcp_sentinel-data_query_lake({
  "query": "W3CIISLog | where TimeGenerated > ago(7d) | where scStatus == '401' | summarize value = count(), lat = take_any(RemoteIPLatitude), lon = take_any(RemoteIPLongitude) by ip = cIP | where lat != 0 | project ip, lat, lon, value"
})

# 2. Display geomap
mcp_sentinel-geom_show-attack-map({
  "data": [<query results>],
  "title": "Attack Origins (Last 7 Days)",
  "valueLabel": "Failed Logins",
  "colorScale": "blue-red"
})

MCP Tool Reference

Tool: mcp_sentinel-geom_show-attack-map
ParameterRequiredTypeDescription
data✅arrayArray of {ip, lat, lon, value} objects
title❌stringTitle displayed above map (default: "Attack Origin Map")
valueLabel❌stringLabel for values (default: "Attacks")
colorScale❌stringblue-red (threats), green-red, or blue-yellow
enrichment❌arrayIP enrichment data for click-to-expand panels
Data Schema
json
{
  "data": [
    {"ip": "101.36.107.228", "lat": 22.25, "lon": 114.15, "value": 44},
    {"ip": "193.142.147.209", "lat": 52.35, "lon": 4.92, "value": 13},
    {"ip": "170.64.158.196", "lat": -33.90, "lon": 151.19, "value": 9}
  ]
}
Enrichment Schema
json
{
  "enrichment": [
    {
      "ip": "101.36.107.228",
      "city": "Hong Kong",
      "country": "HK",
      "org": "AS135377 UCLOUD INFORMATION TECHNOLOGY",
      "is_vpn": true,
      "is_proxy": false,
      "is_tor": false,
      "abuse_confidence_score": 100,
      "total_reports": 4612,
      "last_reported": "2026-01-29",
      "threat_categories": ["SSH", "Brute-Force", "Web App Attack"]
    }
  ]
}

⚠️ CRITICAL: Complete Enrichment Requirement

When providing enrichment data, ALWAYS include ALL IPs - never a subset.

Rule: 100% Enrichment Coverage
ScenarioCorrect Action
Queried 50 IPs from SentinelInclude enrichment for ALL 50 IPs
Enriched 25 IPsInclude ALL 25 in enrichment array
Some IPs failed enrichmentInclude them with empty fields, or filter from both data AND enrichment
Why This Matters
  • Users click markers expecting threat intel panels
  • Missing enrichment = empty panels = broken UX
  • Partial enrichment misleads security analysts
Workflow to Ensure Complete Enrichment
  1. Query Sentinel → Get N IPs with coordinates
  2. Batch enrich IPs → python enrich_ips.py <all_ips> or python enrich_ips.py --file <ips.json>
  3. Parse enrichment JSON → Extract ALL enriched entries
  4. Build enrichment array → One entry per IP, matching data array exactly
  5. Call geomap → Both data and enrichment arrays must have same IPs
Example: Building Complete Enrichment
python
import json

# Load enrichment from batch operation
with open('temp/ip_enrichment_<timestamp>.json', 'r') as f:
    raw_enrichment = json.load(f)

# Build geomap enrichment array - INCLUDE ALL
enrichment = []
for e in raw_enrichment:
    threat_cats = []
    for c in e.get('recent_comments', [])[:5]:
        threat_cats.extend(c.get('categories', []))
    
    enrichment.append({
        'ip': e['ip'],
        'city': e.get('city', 'Unknown'),
        'country': e.get('country', '??'),
        'org': e.get('org', 'Unknown'),
        'is_vpn': e.get('is_vpn') or e.get('vpnapi_security_vpn', False),
        'is_proxy': e.get('is_proxy') or e.get('vpnapi_security_proxy', False),
        'is_tor': e.get('is_tor') or e.get('vpnapi_security_tor', False),
        'abuse_confidence_score': e.get('abuse_confidence_score', 0),
        'total_reports': e.get('total_reports', 0),
        'last_reported': e.get('recent_comments', [{}])[0].get('date', '')[:10] if e.get('recent_comments') else '',
        'threat_categories': list(set(threat_cats))[:5]
    })

# Verify coverage
print(f"Enrichment entries: {len(enrichment)}")  # Must match data array length
❌ NEVER Do This
python
# BAD: Only including first 25 IPs
enrichment = enrichment[:25]  # WRONG

# BAD: Skipping IPs without abuse scores
enrichment = [e for e in enrichment if e['abuse_confidence_score'] > 0]  # WRONG
✅ ALWAYS Do This
python
# GOOD: Include all IPs, even if some fields are empty
enrichment = [transform(e) for e in raw_enrichment]  # All entries

# GOOD: If filtering, filter BOTH data and enrichment consistently
valid_ips = set(e['ip'] for e in enrichment if e.get('city'))
data = [d for d in data if d['ip'] in valid_ips]  # Filter both

Data Sources

Tables with Native Geolocation

Some Sentinel tables include lat/lon directly from Microsoft's GeoIP enrichment:

TableLatitude ColumnLongitude ColumnCountry Column
W3CIISLogRemoteIPLatitudeRemoteIPLongitudeRemoteIPCountry
CommonSecurityLogDeviceGeoLatitudeDeviceGeoLongitudeDeviceGeoCountry
AzureDiagnosticsvaries by sourcevaries by sourcevaries by source
AzureNetworkAnalyticsSrcGeoLatitudeSrcGeoLongitudeSrcGeoCountry

Use these when available - no enrichment needed for coordinates.

Tables Requiring IP Enrichment

These tables have IP addresses but no coordinates:

TableIP ColumnEnrichment Required
SigninLogsIPAddressYes - use enrich_ips.py
SecurityEventIpAddressYes - use enrich_ips.py
Syslogextract from messageYes - use enrich_ips.py
DeviceNetworkEventsRemoteIPYes - use enrich_ips.py
OfficeActivityClientIPYes - use enrich_ips.py

Enrichment script now captures latitude and longitude from ipinfo.io.


KQL Query Patterns

Pattern 1: Native Geolocation (W3CIISLog)
kql
W3CIISLog
| where TimeGenerated between (datetime(<start>) .. datetime(<end>))
| where <filter_condition>
| summarize 
    value = count(),
    lat = take_any(RemoteIPLatitude),
    lon = take_any(RemoteIPLongitude),
    country = take_any(RemoteIPCountry)
    by ip = cIP
| where lat != 0 and lon != 0  // Filter unknown locations
| project ip, lat, lon, value
| order by value desc
Pattern 2: Native Geolocation (CommonSecurityLog)
kql
CommonSecurityLog
| where TimeGenerated between (datetime(<start>) .. datetime(<end>))
| where <filter_condition>
| summarize 
    value = count(),
    lat = take_any(DeviceGeoLatitude),
    lon = take_any(DeviceGeoLongitude)
    by ip = SourceIP
| where lat != 0 and lon != 0
| project ip, lat, lon, value
| order by value desc
Pattern 3: Enrichment Required (Extract IPs Only)
kql
<Table>
| where TimeGenerated between (datetime(<start>) .. datetime(<end>))
| where <filter_condition>
| summarize value = count() by ip = <IP_column>
| order by value desc
| take 100

Then run enrich_ips.py to get lat/lon.


Scenario-Specific KQL Queries

Scenario: W3CIISLog - Failed Logins (Native Geo)
kql
W3CIISLog
| where TimeGenerated > ago(90d)
| where Computer startswith "<honeypot_name>"
| where scStatus == "401"  // Failed auth
| where cIP != "127.0.0.1"
| summarize 
    value = count(),
    lat = take_any(RemoteIPLatitude),
    lon = take_any(RemoteIPLongitude),
    country = take_any(RemoteIPCountry)
    by ip = cIP
| where lat != 0 and lon != 0
| project ip, lat, lon, value
| order by value desc
Scenario: W3CIISLog - Web Attacks (Native Geo)
kql
W3CIISLog
| where TimeGenerated > ago(30d)
| where tolong(scStatus) >= 400
| where csUriStem has_any ("'", "union", "select", "script", "../", "cmd.exe")
| where cIP != "127.0.0.1"
| summarize 
    value = count(),
    lat = take_any(RemoteIPLatitude),
    lon = take_any(RemoteIPLongitude)
    by ip = cIP
| where lat != 0
| project ip, lat, lon, value
| order by value desc
| take 100
Scenario: CommonSecurityLog - Firewall Blocks (Native Geo)
kql
CommonSecurityLog
| where TimeGenerated > ago(7d)
| where DeviceAction == "Deny" or Activity has "blocked"
| summarize 
    value = count(),
    lat = take_any(DeviceGeoLatitude),
    lon = take_any(DeviceGeoLongitude)
    by ip = SourceIP
| where lat != 0 and lon != 0
| project ip, lat, lon, value
| order by value desc
| take 100
Scenario: SigninLogs - Failed Sign-ins (Requires Enrichment)

Step 1: Query IPs and values

kql
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType != 0  // Failed
| summarize value = count() by ip = IPAddress
| order by value desc
| take 50

Step 2: Enrich IPs

powershell
python enrich_ips.py <ip1> <ip2> <ip3> ...

Step 3: Build map data from enrichment JSON (includes lat/lon)

Scenario: SecurityEvent - RDP Brute Force (Requires Enrichment)
kql
SecurityEvent
| where TimeGenerated > ago(7d)
| where EventID == 4625
| where LogonType == 10  // RDP
| where IpAddress != "-" and IpAddress != "127.0.0.1"
| summarize value = count() by ip = IpAddress
| order by value desc
| take 50

Then enrich to get coordinates.

Scenario: DeviceNetworkEvents - Inbound Attacks (Requires Enrichment)
kql
DeviceNetworkEvents
| where TimeGenerated > ago(7d)
| where DeviceName =~ "<device_name>"
| where ActionType in ("ConnectionSuccess", "InboundConnectionAccepted")
| where LocalPort in (3389, 22, 445, 80, 443)
| where RemoteIP !startswith "192.168." and RemoteIP !startswith "10."
| summarize value = count() by ip = RemoteIP
| order by value desc
| take 50

Enrichment Integration

When Coordinates Are Not in Sentinel

For tables without native geo fields, use the enrichment script:

Step 1: Run your KQL query to get IPs and values

Step 2: Enrich IPs:

powershell
python enrich_ips.py 203.0.113.42 198.51.100.10 192.0.2.1
# Or from file:
python enrich_ips.py --file temp/attack_ips.json

Step 3: Load enrichment JSON and build map data:

python
import json

# Load enrichment (now includes latitude/longitude from ipinfo.io)
with open('temp/ip_enrichment_<timestamp>.json', 'r') as f:
    enrichment = json.load(f)

# Build map data
map_data = []
enrichment_out = []

for e in enrichment:
    ip = e['ip']
    lat = e.get('latitude')
    lon = e.get('longitude')
    
    if lat is None or lon is None:
        continue  # Skip IPs without coordinates
    
    # Get value from your KQL results (create a lookup dict)
    value = attack_counts.get(ip, 1)
    
    map_data.append({
        'ip': ip,
        'lat': lat,
        'lon': lon,
        'value': value
    })
    
    # Build enrichment for drill-down
    threat_cats = []
    for c in e.get('recent_comments', [])[:5]:
        threat_cats.extend(c.get('categories', []))
    
    enrichment_out.append({
        'ip': ip,
        'city': e.get('city', 'Unknown'),
        'country': e.get('country', '??'),
        'org': e.get('org', 'Unknown'),
        'is_vpn': e.get('is_vpn') or e.get('vpnapi_security_vpn', False),
        'abuse_confidence_score': e.get('abuse_confidence_score', 0),
        'total_reports': e.get('total_reports', 0),
        'last_reported': e.get('recent_comments', [{}])[0].get('date', '')[:10] if e.get('recent_comments') else '',
        'threat_categories': list(set(threat_cats))[:5]
    })
Interactive Features with Enrichment

When enrichment is provided:

  • Click any marker → Opens threat intel panel showing:
    • 📍 Location (city, country)
    • 🏢 Organization/ISP
    • 🏷️ VPN/Proxy/Tor badges
    • 📊 AbuseIPDB confidence meter
    • 📈 Total reports count
    • 🔴 Threat category tags

Color Scale Guide

ScaleLow ValueHigh ValueBest For
blue-redBlueRedThreats (attacks, failures) - DEFAULT
green-redTealGreenPositive activity (benign traffic)
blue-yellowBlueYellowNeutral data distributions

For threat/attack maps, always use blue-red.


Complete Examples

Example 1: 90-Day Honeypot Attack Map (Native Geo)
# 1. Query with native lat/lon from W3CIISLog
mcp_sentinel-data_query_lake({
  "query": "W3CIISLog | where TimeGenerated > ago(90d) | where Computer startswith '<HONEYPOT_SERVER>' | where scStatus == '401' | summarize value = count(), lat = take_any(RemoteIPLatitude), lon = take_any(RemoteIPLongitude), country = take_any(RemoteIPCountry) by ip = cIP | where lat != 0 and lon != 0 | project ip, lat, lon, value | order by value desc"
})

# 2. Enrich top IPs for threat intel drill-down
python enrich_ips.py 101.36.107.228 193.142.147.209 80.190.82.185

# 3. Display geomap
mcp_sentinel-geom_show-attack-map({
  "data": [
    {"ip": "101.36.107.228", "lat": 22.25, "lon": 114.15, "value": 44},
    {"ip": "80.190.82.185", "lat": 50.97, "lon": 6.83, "value": 44},
    {"ip": "193.142.147.209", "lat": 52.35, "lon": 4.92, "value": 13},
    {"ip": "170.64.158.196", "lat": -33.9, "lon": 151.19, "value": 9}
  ],
  "title": "Honeypot Attack Origins - 90 Day Analysis",
  "valueLabel": "Failed Logins",
  "colorScale": "blue-red",
  "enrichment": [
    {"ip": "101.36.107.228", "city": "Hong Kong", "country": "HK", "org": "AS135377 UCLOUD", "is_vpn": true, "abuse_confidence_score": 100, "total_reports": 4612, "threat_categories": ["SSH", "Brute-Force"]},
    {"ip": "193.142.147.209", "city": "Amsterdam", "country": "NL", "org": "AS213438 ColocaTel", "is_vpn": true, "abuse_confidence_score": 100, "total_reports": 30973, "threat_categories": ["Web App Attack", "Hacking"]}
  ]
})
Example 2: SigninLogs Attack Map (Enrichment Required)
# 1. Query IPs with failed sign-ins
mcp_sentinel-data_query_lake({
  "query": "SigninLogs | where TimeGenerated > ago(7d) | where ResultType != 0 | summarize value = count() by ip = IPAddress | order by value desc | take 50"
})

# 2. Enrich all IPs (script now captures lat/lon)
python enrich_ips.py <ip1> <ip2> ...

# 3. Load enrichment JSON and build map data
# (See Python code in Enrichment Integration section)

# 4. Display geomap
mcp_sentinel-geom_show-attack-map({
  "data": [<map_data from enrichment>],
  "title": "Failed Sign-In Origins (Last 7 Days)",
  "valueLabel": "Failed Attempts",
  "colorScale": "blue-red",
  "enrichment": [<enrichment_out>]
})
Example 3: Firewall Blocks (Native Geo)
# 1. Query blocked traffic with geo
mcp_sentinel-data_query_lake({
  "query": "CommonSecurityLog | where TimeGenerated > ago(24h) | where DeviceAction == 'Deny' | summarize value = count(), lat = take_any(DeviceGeoLatitude), lon = take_any(DeviceGeoLongitude) by ip = SourceIP | where lat != 0 | project ip, lat, lon, value | order by value desc | take 100"
})

# 2. Display geomap
mcp_sentinel-geom_show-attack-map({
  "data": [<query results>],
  "title": "Blocked Traffic Origins (Last 24h)",
  "valueLabel": "Blocked Connections",
  "colorScale": "blue-red"
})

Follow-Up Investigation Queries

When users select IPs from the geomap and click "🔍 Investigate in Chat", run these queries to provide comprehensive threat analysis. Execute queries in parallel where possible.

Multi-IP Filter Pattern

All queries use this dynamic IP filter:

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>", ...]);

Replace with the actual IPs selected from the geomap.


Query 1: DeviceNetworkEvents (Network Activity)

Purpose: Show all network connections from selected IPs to any device in the environment.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
DeviceNetworkEvents
| where TimeGenerated between (start .. end)
| where RemoteIP in (target_ips)
| summarize 
    ConnectionCount = count(),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    TargetDevices = make_set(DeviceName, 10),
    TargetPorts = make_set(LocalPort, 20),
    Actions = make_set(ActionType, 5)
    by RemoteIP
| extend Duration = LastSeen - FirstSeen
| order by ConnectionCount desc

Columns returned:

  • RemoteIP: Attacker IP
  • ConnectionCount: Total connections
  • FirstSeen/LastSeen: Activity time range
  • TargetDevices: Devices contacted
  • TargetPorts: Ports targeted (LocalPort = service ports on your devices)
  • Actions: Connection types (Success, Blocked, etc.)

Query 2: SecurityEvent (Windows Authentication)

Purpose: Show Windows authentication attempts from selected IPs.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
SecurityEvent
| where TimeGenerated between (start .. end)
| where IpAddress in (target_ips)
| where EventID in (4624, 4625, 4648, 4771, 4776)
| summarize 
    EventCount = count(),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    TargetComputers = make_set(Computer, 10),
    TargetAccounts = make_set(Account, 20),
    LogonTypes = make_set(LogonType, 5)
    by IpAddress, EventID
| extend EventType = case(
    EventID == 4624, "Successful Logon",
    EventID == 4625, "Failed Logon",
    EventID == 4648, "Explicit Credentials",
    EventID == 4771, "Kerberos Pre-Auth Failed",
    EventID == 4776, "NTLM Auth Attempt",
    "Other")
| project IpAddress, EventType, EventCount, TargetComputers, TargetAccounts, LogonTypes, FirstSeen, LastSeen
| order by EventCount desc

Key Event IDs:

  • 4624: Successful logon (ALERT: attacker got in!)
  • 4625: Failed logon (brute force indicator)
  • 4648: Explicit credentials used (lateral movement)
  • 4771: Kerberos pre-auth failed
  • 4776: NTLM credential validation

Show full SKILL.md (519 more words)Show less
Query 3: W3CIISLog (Web Attacks)

Purpose: Show HTTP requests from selected IPs including attack patterns.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
W3CIISLog
| where TimeGenerated between (start .. end)
| where cIP in (target_ips)
| summarize 
    RequestCount = count(),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    TargetServers = make_set(Computer, 10),
    URIs = make_set(csUriStem, 20),
    StatusCodes = make_set(tolong(scStatus), 10),
    Methods = make_set(csMethod, 5),
    UserAgents = make_set(csUserAgent, 5)
    by cIP
| extend AttackPatterns = case(
    URIs has_any ("'", "union", "select"), "SQL Injection",
    URIs has "script", "XSS",
    URIs has_any ("../", "..\\"), "Path Traversal",
    URIs has_any ("cmd.exe", "powershell"), "Command Injection",
    "Reconnaissance")
| project IP = cIP, RequestCount, AttackPatterns, TargetServers, StatusCodes, Methods, URIs, FirstSeen, LastSeen
| order by RequestCount desc

Query 4: SigninLogs (Azure AD Activity)

Purpose: Show Azure AD sign-in attempts from selected IPs.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
SigninLogs
| where TimeGenerated between (start .. end)
| where IPAddress in (target_ips)
| summarize 
    SignInCount = count(),
    SuccessCount = countif(ResultType == 0),
    FailureCount = countif(ResultType != 0),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated),
    TargetUsers = make_set(UserPrincipalName, 20),
    TargetApps = make_set(AppDisplayName, 10),
    ErrorCodes = make_set(ResultType, 10),
    ClientApps = make_set(ClientAppUsed, 5)
    by IPAddress
| extend SuccessRate = round(100.0 * SuccessCount / SignInCount, 1)
| project IPAddress, SignInCount, SuccessCount, FailureCount, SuccessRate, TargetUsers, TargetApps, ErrorCodes, FirstSeen, LastSeen
| order by SignInCount desc

CRITICAL: Check SuccessCount > 0 - This indicates the attacker successfully authenticated!


Query 5: ThreatIntelIndicators (Known Threats)

Purpose: Check if selected IPs match threat intelligence databases.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
ThreatIntelIndicators
| extend IndicatorType = replace_string(replace_string(replace_string(tostring(split(ObservableKey, ":", 0)), "[", ""), "]", ""), "\"", "")
| where IndicatorType in ("ipv4-addr", "ipv6-addr", "network-traffic")
| extend NetworkSourceIP = toupper(ObservableValue)
| where NetworkSourceIP in (target_ips)
| where IsActive and (ValidUntil > now() or isempty(ValidUntil))
| extend Description = tostring(parse_json(Data).description)
| where Description !contains_cs "State: inactive;" and Description !contains_cs "State: falsepos;"
| extend TrafficLightProtocolLevel = tostring(parse_json(AdditionalFields).TLPLevel)
| extend ActivityGroupNames = extract(@"ActivityGroup:(\S+)", 1, tostring(parse_json(Data).labels))
| summarize arg_max(TimeGenerated, *) by NetworkSourceIP
| project 
    IPAddress = NetworkSourceIP,
    ThreatDescription = Description,
    ActivityGroupNames,
    Confidence,
    ValidUntil,
    TrafficLightProtocolLevel,
    IsActive,
    TimeGenerated
| order by Confidence desc

Key Fields:

  • Confidence: 0-100 threat confidence score
  • ActivityGroupNames: APT/threat actor attribution (e.g., "PHOSPHORUS", "NOBELIUM")
  • ThreatDescription: Details about the threat

Query 6: SecurityAlert with Incident Status

Purpose: Find security alerts that reference selected IPs, with the actual status from SecurityIncident (not the immutable alert status).

⚠️ IMPORTANT: SecurityAlert.Status is immutable ("New" at creation time). The actual status is on the SecurityIncident table. This query joins to get the real incident status.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
// Step 1: Find alerts containing target IPs as entities
let matched_alerts = SecurityAlert
| where TimeGenerated between (start .. end)
| extend EntitiesParsed = parse_json(Entities)
| mv-expand Entity = EntitiesParsed
| where Entity.["Type"] == "ip"
| extend EntityIP = tostring(Entity.Address)
| where EntityIP in (target_ips)
| summarize MatchedIPs = make_set(EntityIP) by SystemAlertId;
// Step 2: Get latest incident status for these alerts (keep AlertIds)
let incident_status = SecurityIncident
| where TimeGenerated between (start .. end)
| summarize arg_max(TimeGenerated, Status, Classification, IncidentNumber, AlertIds) by IncidentName
| mv-expand AlertId = AlertIds
| extend AlertId = tostring(AlertId)
| project AlertId, IncidentStatus = Status, Classification, IncidentNumber;
// Step 3: Join alerts with matched IPs and incident status
SecurityAlert
| where TimeGenerated between (start .. end)
| where SystemAlertId in (matched_alerts)
| join kind=leftouter matched_alerts on $left.SystemAlertId == $right.SystemAlertId
| join kind=leftouter incident_status on $left.SystemAlertId == $right.AlertId
| summarize arg_max(TimeGenerated, AlertName, AlertSeverity, Status, ProviderName, Tactics, Description, MatchedIPs, IncidentStatus, Classification, IncidentNumber) by SystemAlertId
| extend FinalStatus = coalesce(IncidentStatus, Status)  // Use incident status if available
| project 
    TimeGenerated,
    AlertName,
    AlertSeverity,
    Status = FinalStatus,
    Classification,
    IncidentNumber,
    ProviderName,
    Tactics,
    MatchedIPs,
    Description
| order by TimeGenerated desc
| take 25

Why This Matters:

  • SecurityAlert.Status = "New" is the creation status (immutable)
  • SecurityIncident.Status shows the current status (New/Active/Closed)
  • SecurityIncident.Classification shows the closure reason (TruePositive/FalsePositive/BenignPositive)
  • Alerts without incidents keep their original "New" status

Entities JSON Structure Example:

json
[
  {"$id":"3","HostName":"contoso-server","Type":"host"},
  {"$id":"4","Address":"203.0.113.10","Type":"ip"},
  {"$id":"5","Address":"198.51.100.20","Type":"ip"}
]

Query 7: DeviceProcessEvents (Process Execution Post-Compromise)

Purpose: If attacker IPs had successful connections, check for suspicious process execution.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
// First, find devices that had connections from target IPs
let compromised_devices = DeviceNetworkEvents
| where TimeGenerated between (start .. end)
| where RemoteIP in (target_ips)
| where ActionType in ("ConnectionSuccess", "InboundConnectionAccepted")
| distinct DeviceName;
// Then check for suspicious processes on those devices
DeviceProcessEvents
| where TimeGenerated between (start .. end)
| where DeviceName in (compromised_devices)
| where FileName in~ ("powershell.exe", "cmd.exe", "wscript.exe", "cscript.exe", "mshta.exe", "certutil.exe", "bitsadmin.exe", "regsvr32.exe", "rundll32.exe")
    or ProcessCommandLine has_any ("Invoke-", "IEX", "DownloadString", "WebClient", "-enc", "-encoded", "bypass", "hidden")
| project TimeGenerated, DeviceName, FileName, ProcessCommandLine, AccountName, InitiatingProcessFileName
| order by TimeGenerated desc
| take 50

Query 8: DeviceFileEvents (Malware Drops)

Purpose: Check for file creation/modification on devices contacted by attacker IPs.

kql
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>"]);
let start = datetime(<StartDate>);
let end = datetime(<EndDate>);
// Find devices that had connections from target IPs
let compromised_devices = DeviceNetworkEvents
| where TimeGenerated between (start .. end)
| where RemoteIP in (target_ips)
| where ActionType in ("ConnectionSuccess", "InboundConnectionAccepted")
| distinct DeviceName;
// Check for suspicious file activity
DeviceFileEvents
| where TimeGenerated between (start .. end)
| where DeviceName in (compromised_devices)
| where ActionType in ("FileCreated", "FileModified")
| where FileName endswith_cs ".exe" or FileName endswith_cs ".dll" or FileName endswith_cs ".ps1" 
    or FileName endswith_cs ".bat" or FileName endswith_cs ".vbs" or FileName endswith_cs ".js"
| where FolderPath has_any ("\\Temp\\", "\\AppData\\", "\\Downloads\\", "\\ProgramData\\", "\\Users\\Public\\")
| project TimeGenerated, DeviceName, FileName, FolderPath, ActionType, InitiatingProcessFileName, SHA256
| order by TimeGenerated desc
| take 50

When user selects IPs and clicks "Investigate in Chat":

Phase 1 (Parallel):

  • Query 1: DeviceNetworkEvents
  • Query 2: SecurityEvent
  • Query 3: W3CIISLog
  • Query 4: SigninLogs
  • Query 5: ThreatIntelIndicators
  • Query 6: SecurityAlert

Phase 2 (If connections found):

  • Query 7: DeviceProcessEvents (post-compromise activity)
  • Query 8: DeviceFileEvents (malware indicators)

Response Format:

Summarize findings with:

  1. Threat Level Assessment (Critical/High/Medium/Low)
  2. Attack Summary - What the IPs did, which devices/users were targeted
  3. Successful Access - ALERT if any successful logins (4624) or Azure AD success (ResultType=0)
  4. Threat Intel Matches - Known APT groups, malware campaigns
  5. Recommendations - Block IPs, investigate users, isolate devices

Interactive Selection Feature

The geomap supports multi-select mode for follow-up investigations:

How to Use
  1. Click "☑ Select" button (top of map) to enter selection mode
  2. Click markers to add/remove IPs from selection (green checkmark ✓)
  3. Review selection panel showing selected IPs with enrichment summary
  4. Click "🔍 Investigate in Chat" to send selected IPs for investigation
What Happens

When you click "Investigate in Chat":

  1. All selected IPs are formatted with enrichment context
  2. Message is sent to chat as a user message
  3. LLM runs the follow-up queries above automatically
  4. Results are summarized with threat assessment
Selection Panel Shows

For each selected IP:

  • IP address
  • City, Country
  • Abuse confidence score (color-coded badge)
  • Attack value from the map

Technical Notes

  • Projection: Robinson projection for accurate world map display
  • Map Source: SimpleMaps.com world SVG (MIT license)
  • Bundle Size: ~650 KB (includes embedded world map)
  • CSP Compliance: No external resources - all assets embedded inline
  • Coordinate System: Standard WGS84 (latitude: -90 to 90, longitude: -180 to 180)

When to Use Geomaps

✅ Good Use Cases:

  • Attack origin visualization (honeypots, firewalls)
  • Geographic threat distribution
  • Anomalous sign-in locations
  • VPN/anonymization analysis across regions
  • Executive briefings on global threats

❌ Skip Geomaps When:

  • Fewer than 3 unique locations (too sparse)
  • All IPs from same region (use heatmap instead)
  • Time-based patterns needed (use heatmap)
  • No geographic data available and enrichment not feasible

Last Updated: January 29, 2026

© SCStelz, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in .github/skills/geomap-visualization of SCStelz/security-investigator.

Open the folder on GitHubat commit b38152e

Compare with similar skills

Geomap Visualization 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.

Geomap Visualization compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Geomap Visualization this skillSCStelz/security-investigator249—~7.6kAutomated safety check: PassMIT
Kqlmicrosoft/fabric-rti-mcp131—~6.2kAutomated safety check: PassMIT
Bagofwordsbagofwords1/bagofwords458—~1.5kAutomated safety check: PassCustom licence
Apex Azure Kustojonathan-vella/apex217—~984Automated safety check: PassMIT
Skill Seekers Builderyusufkaraaslan/Skill_Seekers15k—~760Automated safety check: PassMIT
Verified Researchsweetcornna/free-search-mcp116—~1.8kAutomated safety check: PassMIT

Similar skills

  • Kql

    microsoft/fabric-rti-mcp

    Official

    KQL language expertise for writing correct, efficient Kusto queries using the Fabric RTI MCP tools.

    131 GitHub stars~6.2k tokensUpdated 7 days ago
    Data & AnalyticsAuto-check passed
  • Bagofwords

    bagofwords1/bagofwords

    Query, visualize, and analyze data in a Bag of Words (BOW) workspace via the bagofwords MCP tools.

    458 GitHub stars~1.5k tokensUpdated today
    Data & AnalyticsAuto-check passed
  • Apex Azure Kusto

    jonathan-vella/apex

    ANALYSIS SKILL — Query and analyze data in Azure Data Explorer (Kusto/ADX) using KQL.

    217 GitHub stars~984 tokensUpdated yesterday
    Data & AnalyticsAuto-check passed
  • Skill Seekers Builder

    yusufkaraaslan/Skill_Seekers

    Detects the type of a knowledge source and uses the Skill Seekers MCP tools to turn docs, repos, PDFs or videos into packaged AI skills.

    15k GitHub stars~760 tokensUpdated 8 days ago
    Agent WorkflowsAuto-check passed
  • Verified Research

    sweetcornna/free-search-mcp

    Use with the free-search MCP tools whenever a web lookup must yield facts someone will rely on: dates, deadlines, prices, prizes, fees, rules, eligibility, schedules, versions, statistics, news, or…

    116 GitHub stars~1.8k tokensUpdated 4 days ago
    Agent WorkflowsAuto-check passed
  • Semantic Analyst

    sidequery/sidemantic

    Answer analytical, KPI, metric, trend, cohort, and business-performance questions through a Sidemantic semantic layer.

    129 GitHub stars~982 tokensUpdated yesterday
    DatabasesAuto-check passed

More from SCStelz/security-investigator

All 22 skills in this repo
  • Ca Policy Investigation

    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…

    249 GitHub stars~3.8k tokensUpdated 2 days ago
    Auto-check passed
  • Context Memory Review

    SCStelz/security-investigator

    Weekly review of an investigation tenant-context memory file against the most recent SOC scan reports (e.g.

    249 GitHub stars~3.7k tokensUpdated 2 days ago
    Auto-check passed
  • Heatmap Visualization

    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.

    249 GitHub stars~3.4k tokensUpdated 2 days ago
    Auto-check passed
  • AI Agent Activity

    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…

    249 GitHub stars~17k tokensUpdated 2 days ago
    Auto-check passed
  • AI Agent Posture

    SCStelz/security-investigator

    Audit or report on AI agent security posture across Copilot Studio, Microsoft 365 Copilot, Microsoft Foundry, and third-party agents.

    249 GitHub stars~21k tokensUpdated 2 days ago
    Auto-check passed
  • App Registration Posture

    SCStelz/security-investigator

    Audit Entra ID app registration and service principal security posture.

    249 GitHub stars~21k tokensUpdated 2 days ago
    Auto-check passed

Questions about Geomap Visualization

What does Geomap Visualization do?

A skill your agent uses when asked to create geographic maps, visualize attack origins on a world map, show location-based data, or display IP geolocation. Geomap Visualization is an agent skill from SCStelz/security-investigator. Use this skill when asked to create geographic maps, visualize attack origins on a world map, show location-based data, or display IP geolocation.

When should I use Geomap Visualization?

Geomap Visualization fits situations like: asked to create geographic maps; visualize attack origins on a world map; show location-based data; display IP geolocation.

How do I install Geomap Visualization in Claude Code?

Run `npx skills add SCStelz/security-investigator --skill geomap-visualization -a claude-code`. Or copy the skill folder (.github/skills/geomap-visualization in SCStelz/security-investigator) into .claude/skills/geomap-visualization in your project. Claude Code loads it when a task matches its description.

How do I install Geomap Visualization in Codex?

Run `npx skills add SCStelz/security-investigator --skill geomap-visualization -a codex`. Or copy the skill folder (.github/skills/geomap-visualization in SCStelz/security-investigator) into .agents/skills/geomap-visualization in your project. Codex loads it when a task matches its description.

Can I use Geomap Visualization in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add SCStelz/security-investigator --skill geomap-visualization -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/geomap-visualization, .gemini/skills/geomap-visualization, .github/skills/geomap-visualization and .opencode/skills/geomap-visualization in your project.

What does Geomap Visualization need to run?

Going by SKILL.md and its folder, Geomap Visualization needs the command-line tools its instructions call (python). Our summary lists: Python 3.

Does Geomap Visualization access the network?

SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.

Is Geomap Visualization safe to install?

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.

What licence does Geomap Visualization use?

Geomap Visualization is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Geomap Visualization use?

About 7.6k tokens (SKILL.md is roughly 30k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Geomap Visualization?

Skills that share tags, products or a category with Geomap Visualization: Kql (microsoft/fabric-rti-mcp, 131 stars), Bagofwords (bagofwords1/bagofwords, 458 stars), Apex Azure Kusto (jonathan-vella/apex, 217 stars) and Skill Seekers Builder (yusufkaraaslan/Skill_Seekers, 15k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Geomap Visualization?

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 6, 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.