Kql
microsoft/fabric-rti-mcp
KQL language expertise for writing correct, efficient Kusto queries using the Fabric RTI MCP tools.
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.
$ npx skills add SCStelz/security-investigator --skill geomap-visualization -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install SCStelz/security-investigator geomap-visualization --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.github/skills/geomap-visualization .claude/skills/geomap-visualization && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "geomap-visualization" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/geomap-visualization into .claude/skills/geomap-visualization/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "geomap-visualization", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/SCStelz/security-investigator/tree/main/.github/skills/geomap-visualizationType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add SCStelz/security-investigator --skill geomap-visualization -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install SCStelz/security-investigator geomap-visualization --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.github/skills/geomap-visualization .agents/skills/geomap-visualization && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "geomap-visualization" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/geomap-visualization into .agents/skills/geomap-visualization/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "geomap-visualization", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add SCStelz/security-investigator --skill geomap-visualization -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install SCStelz/security-investigator geomap-visualization --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.github/skills/geomap-visualization .cursor/skills/geomap-visualization && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "geomap-visualization" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/geomap-visualization into .cursor/skills/geomap-visualization/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "geomap-visualization", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/SCStelz/security-investigator.git --path .github/skills/geomap-visualization--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add SCStelz/security-investigator --skill geomap-visualization -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install SCStelz/security-investigator geomap-visualization --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.github/skills/geomap-visualization .gemini/skills/geomap-visualization && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "geomap-visualization" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/geomap-visualization into .gemini/skills/geomap-visualization/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "geomap-visualization", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install SCStelz/security-investigator geomap-visualizationInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add SCStelz/security-investigator --skill geomap-visualization -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .github/skills && cp -r skills-src/.github/skills/geomap-visualization .github/skills/geomap-visualization && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "geomap-visualization" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/geomap-visualization into .github/skills/geomap-visualization/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "geomap-visualization", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add SCStelz/security-investigator --skill geomap-visualization -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install SCStelz/security-investigator geomap-visualization --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/SCStelz/security-investigator.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.github/skills/geomap-visualization .opencode/skills/geomap-visualization && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "geomap-visualization" agent skill from https://github.com/SCStelz/security-investigator/tree/main/.github/skills/geomap-visualization into .opencode/skills/geomap-visualization/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "geomap-visualization", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
geomap-visualizationA 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. 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.
8 steps, taken from the first numbered list in SKILL.md.
Read from SKILL.md and the folder at commit b38152e. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
pythonFrom the folder's file list and the shell code blocks in SKILL.md.
No URLs in SKILL.md.
From URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
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.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from SCStelz/security-investigator at commit b38152e, republished under its MIT licence (© SCStelz). 1,298 words, ~7,596 tokens.
.claude/skills/geomap-visualization/SKILL.md (or your agent's skills folder).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.
# 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_sentinel-geom_show-attack-map| Parameter | Required | Type | Description |
|---|---|---|---|
data | ✅ | array | Array of {ip, lat, lon, value} objects |
title | ❌ | string | Title displayed above map (default: "Attack Origin Map") |
valueLabel | ❌ | string | Label for values (default: "Attacks") |
colorScale | ❌ | string | blue-red (threats), green-red, or blue-yellow |
enrichment | ❌ | array | IP enrichment data for click-to-expand panels |
{
"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": [
{
"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"]
}
]
}When providing enrichment data, ALWAYS include ALL IPs - never a subset.
| Scenario | Correct Action |
|---|---|
| Queried 50 IPs from Sentinel | Include enrichment for ALL 50 IPs |
| Enriched 25 IPs | Include ALL 25 in enrichment array |
| Some IPs failed enrichment | Include them with empty fields, or filter from both data AND enrichment |
python enrich_ips.py <all_ips> or python enrich_ips.py --file <ips.json>data array exactlydata and enrichment arrays must have same IPsimport 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# 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# 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 bothSome Sentinel tables include lat/lon directly from Microsoft's GeoIP enrichment:
| Table | Latitude Column | Longitude Column | Country Column |
|---|---|---|---|
| W3CIISLog | RemoteIPLatitude | RemoteIPLongitude | RemoteIPCountry |
| CommonSecurityLog | DeviceGeoLatitude | DeviceGeoLongitude | DeviceGeoCountry |
| AzureDiagnostics | varies by source | varies by source | varies by source |
| AzureNetworkAnalytics | SrcGeoLatitude | SrcGeoLongitude | SrcGeoCountry |
Use these when available - no enrichment needed for coordinates.
These tables have IP addresses but no coordinates:
| Table | IP Column | Enrichment Required |
|---|---|---|
| SigninLogs | IPAddress | Yes - use enrich_ips.py |
| SecurityEvent | IpAddress | Yes - use enrich_ips.py |
| Syslog | extract from message | Yes - use enrich_ips.py |
| DeviceNetworkEvents | RemoteIP | Yes - use enrich_ips.py |
| OfficeActivity | ClientIP | Yes - use enrich_ips.py |
Enrichment script now captures latitude and longitude from ipinfo.io.
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 descCommonSecurityLog
| 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<Table>
| where TimeGenerated between (datetime(<start>) .. datetime(<end>))
| where <filter_condition>
| summarize value = count() by ip = <IP_column>
| order by value desc
| take 100Then run enrich_ips.py to get lat/lon.
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 descW3CIISLog
| 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 100CommonSecurityLog
| 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 100Step 1: Query IPs and values
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType != 0 // Failed
| summarize value = count() by ip = IPAddress
| order by value desc
| take 50Step 2: Enrich IPs
python enrich_ips.py <ip1> <ip2> <ip3> ...Step 3: Build map data from enrichment JSON (includes lat/lon)
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 50Then enrich to get coordinates.
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 50For tables without native geo fields, use the enrichment script:
Step 1: Run your KQL query to get IPs and values
Step 2: Enrich IPs:
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.jsonStep 3: Load enrichment JSON and build map data:
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]
})When enrichment is provided:
| Scale | Low Value | High Value | Best For |
|---|---|---|---|
blue-red | Blue | Red | Threats (attacks, failures) - DEFAULT |
green-red | Teal | Green | Positive activity (benign traffic) |
blue-yellow | Blue | Yellow | Neutral data distributions |
For threat/attack maps, always use blue-red.
# 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"]}
]
})# 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>]
})# 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"
})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.
All queries use this dynamic IP filter:
let target_ips = dynamic(["<IP1>", "<IP2>", "<IP3>", ...]);Replace with the actual IPs selected from the geomap.
Purpose: Show all network connections from selected IPs to any device in the environment.
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 descColumns returned:
RemoteIP: Attacker IPConnectionCount: Total connectionsFirstSeen/LastSeen: Activity time rangeTargetDevices: Devices contactedTargetPorts: Ports targeted (LocalPort = service ports on your devices)Actions: Connection types (Success, Blocked, etc.)Purpose: Show Windows authentication attempts from selected IPs.
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 descKey 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 failed4776: NTLM credential validationPurpose: Show HTTP requests from selected IPs including attack patterns.
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 descPurpose: Show Azure AD sign-in attempts from selected IPs.
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 descCRITICAL: Check SuccessCount > 0 - This indicates the attacker successfully authenticated!
Purpose: Check if selected IPs match threat intelligence databases.
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 descKey Fields:
Confidence: 0-100 threat confidence scoreActivityGroupNames: APT/threat actor attribution (e.g., "PHOSPHORUS", "NOBELIUM")ThreatDescription: Details about the threatPurpose: 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.
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 25Why This Matters:
Entities JSON Structure Example:
[
{"$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"}
]Purpose: If attacker IPs had successful connections, check for suspicious process execution.
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 50Purpose: Check for file creation/modification on devices contacted by attacker IPs.
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 50When user selects IPs and clicks "Investigate in Chat":
Phase 1 (Parallel):
Phase 2 (If connections found):
Response Format:
Summarize findings with:
The geomap supports multi-select mode for follow-up investigations:
When you click "Investigate in Chat":
For each selected IP:
✅ Good Use Cases:
❌ Skip Geomaps When:
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
Just SKILL.md in .github/skills/geomap-visualization of SCStelz/security-investigator.
Open the folder on GitHubat commit b38152e
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.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Geomap Visualization this skillSCStelz/security-investigator | 249 | — | ~7.6k | Automated safety check: Pass | MIT | |
| Kqlmicrosoft/fabric-rti-mcp | 131 | — | ~6.2k | Automated safety check: Pass | MIT | |
| Bagofwordsbagofwords1/bagofwords | 458 | — | ~1.5k | Automated safety check: Pass | Custom licence | |
| Apex Azure Kustojonathan-vella/apex | 217 | — | ~984 | Automated safety check: Pass | MIT | |
| Skill Seekers Builderyusufkaraaslan/Skill_Seekers | 15k | — | ~760 | Automated safety check: Pass | MIT | |
| Verified Researchsweetcornna/free-search-mcp | 116 | — | ~1.8k | Automated safety check: Pass | MIT |
microsoft/fabric-rti-mcp
KQL language expertise for writing correct, efficient Kusto queries using the Fabric RTI MCP tools.
bagofwords1/bagofwords
Query, visualize, and analyze data in a Bag of Words (BOW) workspace via the bagofwords MCP tools.
jonathan-vella/apex
ANALYSIS SKILL — Query and analyze data in Azure Data Explorer (Kusto/ADX) using KQL.
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.
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…
sidequery/sidemantic
Answer analytical, KPI, metric, trend, cohort, and business-performance questions through a Sidemantic semantic layer.
SCStelz/security-investigator
A skill your agent uses when asked to investigate Conditional Access policy changes, sign-in failures related to CA policies (error codes 53000, 50074, 530032), or suspected policy…
SCStelz/security-investigator
Weekly review of an investigation tenant-context memory file against the most recent SOC scan reports (e.g.
SCStelz/security-investigator
A skill your agent uses when asked to create heatmaps, visualize patterns over time, show activity grids, or display aggregated data in a matrix format.
SCStelz/security-investigator
Report/investigate RUNTIME ACTIVITY of AI agents (Agent 365 / Copilot Studio / M365 Copilot / Work IQ) — agents used, tools/connectors, channels, tokens, prompt/reply content, and Prompt Shield…
SCStelz/security-investigator
Audit or report on AI agent security posture across Copilot Studio, Microsoft 365 Copilot, Microsoft Foundry, and third-party agents.
SCStelz/security-investigator
Audit Entra ID app registration and service principal security posture.
Works with
Categories
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.
Geomap Visualization fits situations like: asked to create geographic maps; visualize attack origins on a world map; show location-based data; display IP geolocation.
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.
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.
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.
Going by SKILL.md and its folder, Geomap Visualization needs the command-line tools its instructions call (python). Our summary lists: Python 3.
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.
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.
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.
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.
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.
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.