Text Generation
PEFT
Safetensors
GGUF
English
lora
cisco
networking
security
wazuh
incident-response
network-automation
restconf
ospf
bgp
conversational
Instructions to use JoeiBanana/ai-network-llms with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use JoeiBanana/ai-network-llms with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use JoeiBanana/ai-network-llms with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf JoeiBanana/ai-network-llms:Q4_K_M # Run inference directly in the terminal: llama cli -hf JoeiBanana/ai-network-llms:Q4_K_M
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf JoeiBanana/ai-network-llms:Q4_K_M # Run inference directly in the terminal: llama cli -hf JoeiBanana/ai-network-llms:Q4_K_M
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf JoeiBanana/ai-network-llms:Q4_K_M # Run inference directly in the terminal: ./llama-cli -hf JoeiBanana/ai-network-llms:Q4_K_M
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf JoeiBanana/ai-network-llms:Q4_K_M # Run inference directly in the terminal: ./build/bin/llama-cli -hf JoeiBanana/ai-network-llms:Q4_K_M
Use Docker
docker model run hf.co/JoeiBanana/ai-network-llms:Q4_K_M
- LM Studio
- Jan
- vLLM
How to use JoeiBanana/ai-network-llms with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "JoeiBanana/ai-network-llms" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "JoeiBanana/ai-network-llms", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/JoeiBanana/ai-network-llms:Q4_K_M
- Ollama
How to use JoeiBanana/ai-network-llms with Ollama:
ollama run hf.co/JoeiBanana/ai-network-llms:Q4_K_M
- Unsloth Desktop
- Pi
How to use JoeiBanana/ai-network-llms with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf JoeiBanana/ai-network-llms:Q4_K_M
Configure the model in Pi
# Install Pi: npm install -g @earendil-works/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "JoeiBanana/ai-network-llms:Q4_K_M" } ] } } }Run Pi
# Start Pi in your project directory: pi
- Docker Model Runner
How to use JoeiBanana/ai-network-llms with Docker Model Runner:
docker model run hf.co/JoeiBanana/ai-network-llms:Q4_K_M
- Lemonade
How to use JoeiBanana/ai-network-llms with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull JoeiBanana/ai-network-llms:Q4_K_M
Run and chat with the model
lemonade run user.ai-network-llms-Q4_K_M
List all available models
lemonade list
- Hermes Agent
How to use JoeiBanana/ai-network-llms with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf JoeiBanana/ai-network-llms:Q4_K_M
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default JoeiBanana/ai-network-llms:Q4_K_M
Run Hermes
hermes
- Atomic Chat
- OpenClaw
How to use JoeiBanana/ai-network-llms with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf JoeiBanana/ai-network-llms:Q4_K_M
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "JoeiBanana/ai-network-llms:Q4_K_M" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
Upload batch 8/8
Browse files- README.md +217 -89
- runners/run_fix.py +461 -15
README.md
CHANGED
|
@@ -20,59 +20,103 @@ pipeline_tag: text-generation
|
|
| 20 |
|
| 21 |
# Wazuh-LLM Cisco Network Incident Response
|
| 22 |
|
| 23 |
-
Automated network incident response
|
| 24 |
-
Takes Wazuh SIEM alerts, classifies them, queries live device
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
## Architecture
|
| 28 |
|
|
|
|
|
|
|
| 29 |
```
|
| 30 |
-
Wazuh
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
```
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
## LoRA Adapters
|
| 53 |
|
| 54 |
18 domain-specific adapters fine-tuned on Hermes-3-Llama-3.1-8B (r=8, alpha=32):
|
| 55 |
|
| 56 |
-
| Domain
|
| 57 |
-
|--------|---------|-------------------|
|
| 58 |
-
| OSPF
|
| 59 |
-
| OSPF
|
| 60 |
-
| OSPF
|
| 61 |
-
| OSPF
|
| 62 |
-
| BGP
|
| 63 |
-
| BGP
|
| 64 |
-
| BGP
|
| 65 |
-
| Sec
|
| 66 |
-
| Sec
|
| 67 |
-
| Sec
|
| 68 |
-
| Switch
|
| 69 |
-
| Switch
|
| 70 |
-
| Switch
|
| 71 |
-
| Service| `
|
| 72 |
-
| Service| `
|
| 73 |
-
| Sys
|
| 74 |
-
| Sys
|
| 75 |
-
| Sys
|
|
|
|
|
|
|
| 76 |
|
| 77 |
## Requirements
|
| 78 |
|
|
@@ -81,71 +125,142 @@ pip install -r requirements.txt
|
|
| 81 |
```
|
| 82 |
|
| 83 |
The Wazuh LLM stage uses **Ollama** with a local `wazuh-llama` model:
|
|
|
|
| 84 |
```bash
|
| 85 |
# Install Ollama: https://ollama.com
|
| 86 |
ollama create wazuh-llama -f Modelfile
|
| 87 |
```
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
| 90 |
|
| 91 |
```bash
|
| 92 |
-
# Step 1 β filter
|
| 93 |
-
python prefilter.py --input alerts.json --output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
-
#
|
| 99 |
-
|
| 100 |
-
# (using the 'filename' field as the save name)
|
| 101 |
|
| 102 |
-
#
|
| 103 |
-
python
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
```
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
## Device Configuration
|
| 107 |
|
| 108 |
-
Edit the maps at the top of `pipeline.py` to match your
|
| 109 |
|
| 110 |
```python
|
| 111 |
SOURCE_IP_DEVICE_MAP = {
|
| 112 |
"10.10.10.10": "R1",
|
| 113 |
"2.2.2.2": "R2",
|
| 114 |
-
...
|
| 115 |
}
|
| 116 |
DEVICE_MGMT_IP = {
|
| 117 |
-
"R1": "10.10.10.10", #
|
| 118 |
"R2": "2.2.2.2",
|
| 119 |
-
...
|
| 120 |
}
|
| 121 |
```
|
| 122 |
|
| 123 |
-
Router credentials: `ROUTER_USER` / `ROUTER_PASS` constants
|
|
|
|
|
|
|
| 124 |
|
| 125 |
## RESTCONF
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
`
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
## Training
|
| 146 |
|
| 147 |
Training scripts and datasets are in `training/` and `datasets/`.
|
| 148 |
-
Each adapter was trained
|
| 149 |
targeting `q_proj` and `v_proj` on Hermes-3-Llama-3.1-8B.
|
| 150 |
|
| 151 |
```bash
|
|
@@ -154,24 +269,37 @@ python training/train_bgp1.py
|
|
| 154 |
# etc.
|
| 155 |
```
|
| 156 |
|
|
|
|
|
|
|
| 157 |
## Lab Topology
|
| 158 |
|
| 159 |
-
Tested on a 4-router +
|
| 160 |
-
- R1 / R2 / R3 / R4 β IOS-XE routers (OSPF + BGP)
|
| 161 |
-
- SW1 / SW2 β distribution switches
|
| 162 |
-
- ACCESS-SW1 / ACCESS-SW2 β access switches
|
| 163 |
-
- R4 β DHCP server (192.168.40.0/24)
|
| 164 |
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|------|-------------|
|
| 169 |
-
| `
|
| 170 |
-
| `
|
| 171 |
-
| `run_fix.py` | Phase 2
|
| 172 |
-
| `
|
| 173 |
-
| `
|
| 174 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
## Citation
|
| 177 |
|
|
|
|
| 20 |
|
| 21 |
# Wazuh-LLM Cisco Network Incident Response
|
| 22 |
|
| 23 |
+
Automated network incident response system for Cisco IOS-XE routers and switches.
|
| 24 |
+
Takes Wazuh SIEM alerts, classifies them with a fine-tuned LLM, queries live device
|
| 25 |
+
state via RESTCONF, generates CLI fix commands using domain-specific LoRA adapters,
|
| 26 |
+
and applies the fixes automatically via RESTCONF PATCH.
|
| 27 |
+
|
| 28 |
+
Two backends are provided: a **manual 3-script pipeline** for operator-controlled
|
| 29 |
+
workflows, and a **fully autonomous daemon** that watches Wazuh logs and resolves
|
| 30 |
+
incidents end-to-end without human intervention.
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
|
| 34 |
## Architecture
|
| 35 |
|
| 36 |
+
### Backend 1 β Manual Pipeline (3 scripts)
|
| 37 |
+
|
| 38 |
```
|
| 39 |
+
Wazuh alerts file
|
| 40 |
+
|
|
| 41 |
+
v
|
| 42 |
+
[runners/prefilter.py] filter level-12, drop auth noise
|
| 43 |
+
|
|
| 44 |
+
v
|
| 45 |
+
[runners/pipeline.py]
|
| 46 |
+
Stage 1 Wazuh LLM (Ollama) incident_type, severity, IOCs
|
| 47 |
+
Stage 2 Show command lookup deterministic table (incident -> show cmds)
|
| 48 |
+
Stage 3 RESTCONF mapping deterministic table (show cmd -> YANG path)
|
| 49 |
+
Stage 4 Domain routing selects correct LoRA adapter
|
| 50 |
+
|
|
| 51 |
+
v creates show_outputs/alert_NNN_DEVICE_INCIDENT/
|
| 52 |
+
restconf_commands.json <- execute these GETs against routers
|
| 53 |
+
alert_info.json
|
| 54 |
+
|
| 55 |
+
(operator executes RESTCONF GETs and drops response JSONs into the folder)
|
| 56 |
+
|
| 57 |
+
|
|
| 58 |
+
v
|
| 59 |
+
[runners/run_fix.py] polls folders, runs domain LoRA
|
| 60 |
+
|
|
| 61 |
+
|-- fix_commands.txt CLI fix commands (human readable)
|
| 62 |
+
+-- restconf_fix_commands.json RESTCONF PATCH ops to apply the fix
|
| 63 |
+
+-- pipeline_fix_output.jsonl full record of all alerts processed
|
| 64 |
```
|
| 65 |
|
| 66 |
+
### Backend 2 β Autonomous Daemon (always-on)
|
| 67 |
+
|
| 68 |
+
```
|
| 69 |
+
/var/ossec/logs/alerts/alerts.json (Wazuh live output, tailed continuously)
|
| 70 |
+
|
|
| 71 |
+
v
|
| 72 |
+
[backend/daemon.py]
|
| 73 |
+
Watch tail alerts file, detect new entries (level 7-12)
|
| 74 |
+
Detect SSH brute-force: 5+ failures from same IP within 5-minute window
|
| 75 |
+
|
|
| 76 |
+
v per alert, fully automatic:
|
| 77 |
+
[1] Extract device IP -> device map
|
| 78 |
+
[2] Classify Ollama wazuh-llama -> incident_type, IOCs, severity
|
| 79 |
+
[3] Show commands deterministic table lookup
|
| 80 |
+
[4] RESTCONF GETs execute live against device, collect YANG state
|
| 81 |
+
[5] Domain LoRA generate CLI fix commands
|
| 82 |
+
[6] RESTCONF PATCHes apply fixes directly to device
|
| 83 |
+
[7] Log result managed_incidents.jsonl + managed_incidents.log
|
| 84 |
+
|
|
| 85 |
+
v per-alert folder:
|
| 86 |
+
restconf_get_results.json what the device reported
|
| 87 |
+
fix_commands.txt CLI commands the LLM generated
|
| 88 |
+
restconf_fix_commands.json PATCH ops with YANG bodies
|
| 89 |
+
patch_results.json HTTP status of each PATCH
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
## LoRA Adapters
|
| 95 |
|
| 96 |
18 domain-specific adapters fine-tuned on Hermes-3-Llama-3.1-8B (r=8, alpha=32):
|
| 97 |
|
| 98 |
+
| Domain | Adapter | Incidents covered |
|
| 99 |
+
|---------|----------------------------------|--------------------------------------------------------|
|
| 100 |
+
| OSPF | `incidents/ospf/ospf1` | Neighbor down, full-to-down, adjacency loss |
|
| 101 |
+
| OSPF | `incidents/ospf/ospf2` | ExStart/Exchange stuck, Init stuck, 2-Way |
|
| 102 |
+
| OSPF | `incidents/ospf/ospf3` | Auth, hello/dead mismatch, area, network-type, MTU |
|
| 103 |
+
| OSPF | `incidents/ospf/ospf4` | LSA flood, LSDB inconsistency, redistribution |
|
| 104 |
+
| BGP | `incidents/bgp/lora_llm_bgp1` | Session flap, hold-timer expiry, neighbor reset |
|
| 105 |
+
| BGP | `incidents/bgp/lora_llm_bgp2` | Prefix limit, route leak, path selection |
|
| 106 |
+
| BGP | `incidents/bgp/lora_llm_bgp3` | Missing RIB routes, next-hop-self, AFI-SAFI mismatch |
|
| 107 |
+
| Sec | `incidents/security/lora_llm_sec1` | ACL blocking legitimate traffic |
|
| 108 |
+
| Sec | `incidents/security/lora_llm_sec2` | ACL misconfiguration (protocol, wildcard, direction)|
|
| 109 |
+
| Sec | `incidents/security/lora_llm_sec3` | Excessive denies, brute force, port scan |
|
| 110 |
+
| Switch | `incidents/switch/lora_llm_switch1` | MAC flapping, STP topology change, err-disable |
|
| 111 |
+
| Switch | `incidents/switch/lora_llm_switch2` | Port security, duplex/speed mismatch |
|
| 112 |
+
| Switch | `incidents/switch/lora_llm_switch3` | VLAN mismatch, trunk negotiation, storm control |
|
| 113 |
+
| Service | `incidents/service/lora_llm_service1` | DHCP conflict, starvation, IP conflict |
|
| 114 |
+
| Service | `incidents/service/lora_llm_service2` | DNS failure, NTP unsync, ARP spoofing |
|
| 115 |
+
| Sys | `incidents/sys/lora_llm_sys1` | High CPU, memory exhaustion, process crash |
|
| 116 |
+
| Sys | `incidents/sys/lora_llm_sys2` | Interface flap, duplex/speed mismatch |
|
| 117 |
+
| Sys | `incidents/sys/lora_llm_sys3` | Reload, environmental, general system health |
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
|
| 121 |
## Requirements
|
| 122 |
|
|
|
|
| 125 |
```
|
| 126 |
|
| 127 |
The Wazuh LLM stage uses **Ollama** with a local `wazuh-llama` model:
|
| 128 |
+
|
| 129 |
```bash
|
| 130 |
# Install Ollama: https://ollama.com
|
| 131 |
ollama create wazuh-llama -f Modelfile
|
| 132 |
```
|
| 133 |
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## Quick Start β Manual Pipeline
|
| 137 |
|
| 138 |
```bash
|
| 139 |
+
# Step 1 β pre-filter raw Wazuh export
|
| 140 |
+
python runners/prefilter.py --input alerts.json --output s_alerts.jsonl
|
| 141 |
+
|
| 142 |
+
# Step 2 β classify + build RESTCONF show commands + create per-alert folders
|
| 143 |
+
python runners/pipeline.py --input s_alerts.jsonl --show-dir show_outputs
|
| 144 |
+
|
| 145 |
+
# Step 3 β execute the RESTCONF GETs listed in each folder's restconf_commands.json
|
| 146 |
+
# and save JSON responses into the same folder
|
| 147 |
+
|
| 148 |
+
# Step 4 β run domain LoRA, generate fix commands + RESTCONF PATCH ops
|
| 149 |
+
python runners/run_fix.py --show-dir show_outputs
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
Per-alert output folder after all steps:
|
| 153 |
|
| 154 |
+
```
|
| 155 |
+
show_outputs/alert_001_R1_ospf_neighbor_down/
|
| 156 |
+
alert_info.json alert metadata
|
| 157 |
+
restconf_commands.json GET commands (written by pipeline.py)
|
| 158 |
+
show_ip_ospf_neighbor.json RESTCONF GET response (you drop this)
|
| 159 |
+
fix_commands.txt CLI fix commands (written by run_fix.py)
|
| 160 |
+
restconf_fix_commands.json RESTCONF PATCH ops (written by run_fix.py)
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## Quick Start β Autonomous Daemon
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
# Standard β watch live Wazuh alerts, auto-apply fixes (level 7-12)
|
| 169 |
+
python backend/daemon.py
|
| 170 |
|
| 171 |
+
# Dry run β classify + generate commands, but do NOT PATCH devices
|
| 172 |
+
python backend/daemon.py --dry-run
|
|
|
|
| 173 |
|
| 174 |
+
# Custom alert file and thresholds
|
| 175 |
+
python backend/daemon.py \
|
| 176 |
+
--alerts /var/ossec/logs/alerts/alerts.json \
|
| 177 |
+
--min-level 7 \
|
| 178 |
+
--ssh-threshold 5 \
|
| 179 |
+
--ssh-window 300 \
|
| 180 |
+
--poll 3
|
| 181 |
```
|
| 182 |
|
| 183 |
+
The daemon tail-follows the Wazuh alerts file and processes each alert
|
| 184 |
+
automatically end-to-end with no operator involvement.
|
| 185 |
+
|
| 186 |
+
### Daemon output files
|
| 187 |
+
|
| 188 |
+
| File | Content |
|
| 189 |
+
|------|---------|
|
| 190 |
+
| `managed_incidents.jsonl` | Machine-readable: one JSON record per handled alert |
|
| 191 |
+
| `managed_incidents.log` | Human-readable: one summary block per alert |
|
| 192 |
+
| `daemon.log` | Full debug log of all daemon activity |
|
| 193 |
+
| `show_outputs/alert_NNN_*/` | Per-alert audit folder (GET results, fix, patch results) |
|
| 194 |
+
|
| 195 |
+
### SSH Brute-Force Detection
|
| 196 |
+
|
| 197 |
+
The daemon tracks failed SSH login alerts independently of the level filter.
|
| 198 |
+
When **5 or more failures from the same source IP occur within 5 minutes**,
|
| 199 |
+
a synthetic `ssh_brute_force` incident is triggered and the full pipeline runs β
|
| 200 |
+
even if each individual alert is below the level threshold.
|
| 201 |
+
|
| 202 |
+
Tune with `--ssh-threshold N --ssh-window SECONDS`.
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
## Device Configuration
|
| 207 |
|
| 208 |
+
Edit the maps at the top of `runners/pipeline.py` and `backend/daemon.py` to match your network:
|
| 209 |
|
| 210 |
```python
|
| 211 |
SOURCE_IP_DEVICE_MAP = {
|
| 212 |
"10.10.10.10": "R1",
|
| 213 |
"2.2.2.2": "R2",
|
| 214 |
+
# ...
|
| 215 |
}
|
| 216 |
DEVICE_MGMT_IP = {
|
| 217 |
+
"R1": "10.10.10.10", # management IP used for RESTCONF
|
| 218 |
"R2": "2.2.2.2",
|
| 219 |
+
# ...
|
| 220 |
}
|
| 221 |
```
|
| 222 |
|
| 223 |
+
Router credentials: `ROUTER_USER` / `ROUTER_PASS` constants (default: `admin` / `cisco123!`).
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
|
| 227 |
## RESTCONF
|
| 228 |
|
| 229 |
+
All communication uses RESTCONF over HTTPS (port 443) with Cisco IOS-XE YANG models.
|
| 230 |
+
|
| 231 |
+
**Read (GET)** β `_RESTCONF_RULES` table maps each show command to its YANG path:
|
| 232 |
+
|
| 233 |
+
```
|
| 234 |
+
show ip ospf neighbor -> /restconf/data/Cisco-IOS-XE-ospf-oper:ospf-oper-data/ospf-state
|
| 235 |
+
show ip bgp summary -> /restconf/data/Cisco-IOS-XE-bgp-oper:bgp-state-data/bgp-route-vrfs
|
| 236 |
+
show interfaces -> /restconf/data/Cisco-IOS-XE-interfaces-oper:interfaces
|
| 237 |
+
...
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
**Write (PATCH)** β CLI fix commands are parsed into context blocks and converted
|
| 241 |
+
to RESTCONF PATCH operations with YANG-native JSON bodies:
|
| 242 |
+
|
| 243 |
+
```
|
| 244 |
+
interface GigabitEthernet0/0 -> PATCH .../interface/GigabitEthernet=0%2F0
|
| 245 |
+
ip ospf hello-interval 10 body: Cisco-IOS-XE-ospf:ospf hello-interval 10
|
| 246 |
+
router bgp 65001 -> PATCH .../router/bgp=65001
|
| 247 |
+
neighbor 10.0.0.2 timers 30 90 body: neighbor timers keepalive 30 holdtime 90
|
| 248 |
+
ip access-list extended INBOUND -> PATCH .../ip/access-list/extended=INBOUND
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
Supported YANG modules include:
|
| 252 |
+
`Cisco-IOS-XE-ospf-oper`, `Cisco-IOS-XE-bgp-oper`, `Cisco-IOS-XE-interfaces-oper`,
|
| 253 |
+
`Cisco-IOS-XE-acl-oper`, `Cisco-IOS-XE-spanning-tree-oper`, `Cisco-IOS-XE-vlan-oper`,
|
| 254 |
+
`Cisco-IOS-XE-dhcp-oper`, `Cisco-IOS-XE-arp-oper`, `Cisco-IOS-XE-nat-oper`,
|
| 255 |
+
`Cisco-IOS-XE-ntp-oper`, `Cisco-IOS-XE-process-cpu-oper`,
|
| 256 |
+
`Cisco-IOS-XE-process-memory-oper`, `Cisco-IOS-XE-native`
|
| 257 |
+
|
| 258 |
+
---
|
| 259 |
|
| 260 |
## Training
|
| 261 |
|
| 262 |
Training scripts and datasets are in `training/` and `datasets/`.
|
| 263 |
+
Each adapter was trained on 900β1500 examples with LoRA (r=8, alpha=32, dropout=0.1)
|
| 264 |
targeting `q_proj` and `v_proj` on Hermes-3-Llama-3.1-8B.
|
| 265 |
|
| 266 |
```bash
|
|
|
|
| 269 |
# etc.
|
| 270 |
```
|
| 271 |
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
## Lab Topology
|
| 275 |
|
| 276 |
+
Tested on a 4-router + 4-switch Cisco IOS-XE lab:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
+
- **R1 / R2 / R3 / R4** β IOS-XE routers running OSPF + BGP
|
| 279 |
+
- **SW1 / SW2** β distribution switches
|
| 280 |
+
- **ACCESS-SW1 / ACCESS-SW2** β access switches
|
| 281 |
+
- **R4** β DHCP server (192.168.40.0/24 pool)
|
| 282 |
+
- Wazuh manager collecting syslog from all devices
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
|
| 286 |
+
## Project Files
|
| 287 |
+
|
| 288 |
+
| Path | Description |
|
| 289 |
|------|-------------|
|
| 290 |
+
| `runners/prefilter.py` | Pre-filter raw Wazuh export (level filter + brute-force detection) |
|
| 291 |
+
| `runners/pipeline.py` | Manual backend Phase 1: classify, map RESTCONF GETs, create folders |
|
| 292 |
+
| `runners/run_fix.py` | Manual backend Phase 2: run domain LoRA, generate and map fix commands |
|
| 293 |
+
| `backend/daemon.py` | Autonomous daemon: watches live Wazuh alerts, resolves incidents end-to-end |
|
| 294 |
+
| `Modelfile` | Ollama model definition for the Wazuh alert classifier |
|
| 295 |
+
| `models/base/` | Hermes-3-Llama-3.1-8B base model |
|
| 296 |
+
| `models/incidents/` | 18 domain-specific LoRA adapters |
|
| 297 |
+
| `models/registry.json` | Adapter name -> path registry |
|
| 298 |
+
| `datasets/` | Training datasets (JSONL, one incident type per file) |
|
| 299 |
+
| `training/` | Training scripts (one per adapter) |
|
| 300 |
+
| `cases/` | Incident test cases used for evaluation |
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
|
| 304 |
## Citation
|
| 305 |
|
runners/run_fix.py
CHANGED
|
@@ -53,6 +53,17 @@ WORK_DIR = Path(__file__).parent # project-root/runners/
|
|
| 53 |
PROJECT_ROOT = WORK_DIR.parent # project-root/
|
| 54 |
BASE_MODEL = str(PROJECT_ROOT / "models" / "base" / "Hermes-3-Llama-3.1-8B")
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
# DOMAIN INSTRUCTIONS
|
| 58 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -138,7 +149,8 @@ def load_show_outputs_from_folder(folder: Path) -> dict[str, str]:
|
|
| 138 |
|
| 139 |
File naming: show_ip_ospf_neighbor.json (underscores = spaces)
|
| 140 |
"""
|
| 141 |
-
SKIP = {"restconf_commands.json", "alert_info.json",
|
|
|
|
| 142 |
result: dict[str, str] = {}
|
| 143 |
for fp in sorted(folder.glob("*.json")):
|
| 144 |
if fp.name in SKIP:
|
|
@@ -156,7 +168,8 @@ def load_show_outputs_from_folder(folder: Path) -> dict[str, str]:
|
|
| 156 |
|
| 157 |
def folder_has_responses(folder: Path) -> bool:
|
| 158 |
"""True if the folder contains at least one RESTCONF response JSON file."""
|
| 159 |
-
SKIP = {"restconf_commands.json", "alert_info.json",
|
|
|
|
| 160 |
return any(
|
| 161 |
fp.suffix == ".json" and fp.name not in SKIP
|
| 162 |
for fp in folder.glob("*.json")
|
|
@@ -245,6 +258,422 @@ class FixRunner:
|
|
| 245 |
return self._tokenizer.decode(out[0], skip_special_tokens=True)
|
| 246 |
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 249 |
# MAIN
|
| 250 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -348,27 +777,44 @@ def run(stage1_file: Path, show_base: Path, output_file: Path, poll_secs: int):
|
|
| 348 |
fix_file.write_text(fix, encoding="utf-8")
|
| 349 |
log.info("Fix saved to: %s", fix_file)
|
| 350 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
# ββ Write to main output file ββββββββββββββββββββββββββββββββββββ
|
| 352 |
out_rec = {
|
| 353 |
-
"alert_index":
|
| 354 |
-
"timestamp":
|
| 355 |
-
"device":
|
| 356 |
-
"incident_type":
|
| 357 |
-
"domain":
|
| 358 |
-
"sub_id":
|
| 359 |
-
"lora_used":
|
| 360 |
-
"alert_folder":
|
| 361 |
-
"wazuh_alert":
|
| 362 |
-
"show_commands_used":
|
| 363 |
-
"fix_commands":
|
|
|
|
|
|
|
| 364 |
}
|
| 365 |
fout.write(json.dumps(out_rec, ensure_ascii=False) + "\n")
|
| 366 |
fout.flush()
|
| 367 |
|
| 368 |
print("\n" + "=" * 65)
|
| 369 |
print(f" All alerts processed.")
|
| 370 |
-
print(f" Results
|
| 371 |
-
print(f"
|
|
|
|
| 372 |
print("=" * 65 + "\n")
|
| 373 |
|
| 374 |
|
|
|
|
| 53 |
PROJECT_ROOT = WORK_DIR.parent # project-root/
|
| 54 |
BASE_MODEL = str(PROJECT_ROOT / "models" / "base" / "Hermes-3-Llama-3.1-8B")
|
| 55 |
|
| 56 |
+
ROUTER_USER = "admin"
|
| 57 |
+
ROUTER_PASS = "cisco123!"
|
| 58 |
+
RESTCONF_PORT = 443
|
| 59 |
+
|
| 60 |
+
import base64
|
| 61 |
+
|
| 62 |
+
def _make_auth_header() -> str:
|
| 63 |
+
return "Basic " + base64.b64encode(
|
| 64 |
+
f"{ROUTER_USER}:{ROUTER_PASS}".encode()
|
| 65 |
+
).decode()
|
| 66 |
+
|
| 67 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
# DOMAIN INSTRUCTIONS
|
| 69 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 149 |
|
| 150 |
File naming: show_ip_ospf_neighbor.json (underscores = spaces)
|
| 151 |
"""
|
| 152 |
+
SKIP = {"restconf_commands.json", "alert_info.json",
|
| 153 |
+
"fix_commands.json", "restconf_fix_commands.json"}
|
| 154 |
result: dict[str, str] = {}
|
| 155 |
for fp in sorted(folder.glob("*.json")):
|
| 156 |
if fp.name in SKIP:
|
|
|
|
| 168 |
|
| 169 |
def folder_has_responses(folder: Path) -> bool:
|
| 170 |
"""True if the folder contains at least one RESTCONF response JSON file."""
|
| 171 |
+
SKIP = {"restconf_commands.json", "alert_info.json",
|
| 172 |
+
"fix_commands.json", "restconf_fix_commands.json"}
|
| 173 |
return any(
|
| 174 |
fp.suffix == ".json" and fp.name not in SKIP
|
| 175 |
for fp in folder.glob("*.json")
|
|
|
|
| 258 |
return self._tokenizer.decode(out[0], skip_special_tokens=True)
|
| 259 |
|
| 260 |
|
| 261 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 262 |
+
# CLI FIX COMMANDS β RESTCONF WRITE OPERATIONS
|
| 263 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 264 |
+
|
| 265 |
+
# Context-opening patterns (must NOT be indented in the CLI text)
|
| 266 |
+
_CTX_PATTERNS = [
|
| 267 |
+
re.compile(r"^interface\s+(\S+)", re.I),
|
| 268 |
+
re.compile(r"^router\s+(ospf|bgp|eigrp|isis|rip)\s+(\S+)", re.I),
|
| 269 |
+
re.compile(r"^ip\s+access-list\s+(extended|standard)\s+(\S+)", re.I),
|
| 270 |
+
re.compile(r"^ip\s+dhcp\s+pool\s+(\S+)", re.I),
|
| 271 |
+
re.compile(r"^line\s+(con|vty|aux)\s+", re.I),
|
| 272 |
+
]
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _parse_cli_blocks(fix_text: str) -> list[dict]:
|
| 276 |
+
"""
|
| 277 |
+
Parse CLI config text into context blocks.
|
| 278 |
+
Returns [{"context": "global" | "interface X" | "router ospf 1" | ...,
|
| 279 |
+
"lines": [cmd, ...]}]
|
| 280 |
+
"""
|
| 281 |
+
blocks: list[dict] = []
|
| 282 |
+
current_ctx = "global"
|
| 283 |
+
current_lines: list[str] = []
|
| 284 |
+
|
| 285 |
+
for raw_line in fix_text.splitlines():
|
| 286 |
+
stripped = raw_line.strip()
|
| 287 |
+
if not stripped:
|
| 288 |
+
continue
|
| 289 |
+
|
| 290 |
+
# "!" closes a block
|
| 291 |
+
if stripped == "!":
|
| 292 |
+
if current_lines:
|
| 293 |
+
blocks.append({"context": current_ctx, "lines": list(current_lines)})
|
| 294 |
+
current_lines = []
|
| 295 |
+
current_ctx = "global"
|
| 296 |
+
continue
|
| 297 |
+
|
| 298 |
+
# Non-indented line might open a new context
|
| 299 |
+
if not raw_line.startswith((" ", "\t")):
|
| 300 |
+
is_ctx = any(p.match(stripped) for p in _CTX_PATTERNS)
|
| 301 |
+
if is_ctx:
|
| 302 |
+
if current_lines:
|
| 303 |
+
blocks.append({"context": current_ctx, "lines": list(current_lines)})
|
| 304 |
+
current_lines = []
|
| 305 |
+
current_ctx = stripped
|
| 306 |
+
continue
|
| 307 |
+
|
| 308 |
+
current_lines.append(stripped)
|
| 309 |
+
|
| 310 |
+
if current_lines:
|
| 311 |
+
blocks.append({"context": current_ctx, "lines": list(current_lines)})
|
| 312 |
+
|
| 313 |
+
return blocks
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _url_encode_slot(slot: str) -> str:
|
| 317 |
+
"""Encode interface slot for RESTCONF URL: 0/0 -> 0%2F0"""
|
| 318 |
+
return slot.replace("/", "%2F").replace(".", "%2E")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _parse_interface_name(iface: str) -> tuple[str, str]:
|
| 322 |
+
"""
|
| 323 |
+
'GigabitEthernet0/0' -> ('GigabitEthernet', '0/0')
|
| 324 |
+
'Loopback0' -> ('Loopback', '0')
|
| 325 |
+
"""
|
| 326 |
+
m = re.match(r"([A-Za-z]+)([\d/\.]+)", iface.strip())
|
| 327 |
+
if m:
|
| 328 |
+
return m.group(1), m.group(2)
|
| 329 |
+
return iface, "0"
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _get_mgmt_ip_from_folder(folder: Path) -> str:
|
| 333 |
+
"""Read mgmt_ip from restconf_commands.json written by pipeline.py."""
|
| 334 |
+
rc_file = folder / "restconf_commands.json"
|
| 335 |
+
if rc_file.exists():
|
| 336 |
+
try:
|
| 337 |
+
cmds = json.loads(rc_file.read_text(encoding="utf-8"))
|
| 338 |
+
if cmds and isinstance(cmds, list):
|
| 339 |
+
return cmds[0].get("mgmt_ip", "unknown")
|
| 340 |
+
except Exception:
|
| 341 |
+
pass
|
| 342 |
+
return "unknown"
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# ββ YANG body generators ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 346 |
+
|
| 347 |
+
def _body_interface(itype: str, islot: str, lines: list[str]) -> dict:
|
| 348 |
+
inner: dict = {"name": islot}
|
| 349 |
+
|
| 350 |
+
for line in lines:
|
| 351 |
+
l = line.strip().lower()
|
| 352 |
+
|
| 353 |
+
if l in ("no shutdown", "no shut"):
|
| 354 |
+
inner["shutdown"] = False
|
| 355 |
+
elif l in ("shutdown", "shut"):
|
| 356 |
+
inner["shutdown"] = True
|
| 357 |
+
|
| 358 |
+
m = re.match(r"(?:no\s+)?ip\s+ospf\s+hello-interval\s+(\d+)", l)
|
| 359 |
+
if m:
|
| 360 |
+
ospf = inner.setdefault("ip", {}).setdefault("Cisco-IOS-XE-ospf:ospf", {})
|
| 361 |
+
ospf["hello-interval"] = int(m.group(1))
|
| 362 |
+
|
| 363 |
+
m = re.match(r"(?:no\s+)?ip\s+ospf\s+dead-interval\s+(\d+)", l)
|
| 364 |
+
if m:
|
| 365 |
+
ospf = inner.setdefault("ip", {}).setdefault("Cisco-IOS-XE-ospf:ospf", {})
|
| 366 |
+
ospf["dead-interval"] = int(m.group(1))
|
| 367 |
+
|
| 368 |
+
m = re.match(r"ip\s+ospf\s+(\d+)\s+area\s+(\S+)", l)
|
| 369 |
+
if m:
|
| 370 |
+
ospf = inner.setdefault("ip", {}).setdefault("Cisco-IOS-XE-ospf:ospf", {})
|
| 371 |
+
ospf["process-id"] = int(m.group(1))
|
| 372 |
+
ospf["area"] = m.group(2)
|
| 373 |
+
|
| 374 |
+
m = re.match(r"ip\s+ospf\s+network\s+(\S+)", l)
|
| 375 |
+
if m:
|
| 376 |
+
ospf = inner.setdefault("ip", {}).setdefault("Cisco-IOS-XE-ospf:ospf", {})
|
| 377 |
+
ospf["network"] = {"network-type": m.group(1)}
|
| 378 |
+
|
| 379 |
+
if re.match(r"ip\s+ospf\s+mtu-ignore", l):
|
| 380 |
+
inner.setdefault("ip", {}).setdefault("Cisco-IOS-XE-ospf:ospf", {})["mtu-ignore"] = True
|
| 381 |
+
|
| 382 |
+
if re.match(r"ip\s+ospf\s+authentication", l):
|
| 383 |
+
inner.setdefault("ip", {}).setdefault("Cisco-IOS-XE-ospf:ospf", {})["authentication"] = {}
|
| 384 |
+
|
| 385 |
+
m = re.match(r"switchport\s+mode\s+(\S+)", l)
|
| 386 |
+
if m:
|
| 387 |
+
(inner.setdefault("Cisco-IOS-XE-switch:switchport-conf", {})
|
| 388 |
+
.setdefault("switchport", {})
|
| 389 |
+
.setdefault("mode", {}))[m.group(1)] = {}
|
| 390 |
+
|
| 391 |
+
m = re.match(r"switchport\s+access\s+vlan\s+(\d+)", l)
|
| 392 |
+
if m:
|
| 393 |
+
(inner.setdefault("Cisco-IOS-XE-switch:switchport-conf", {})
|
| 394 |
+
.setdefault("switchport", {})
|
| 395 |
+
.setdefault("access", {}))["vlan"] = {"vlan": int(m.group(1))}
|
| 396 |
+
|
| 397 |
+
m = re.match(r"switchport\s+trunk\s+native\s+vlan\s+(\d+)", l)
|
| 398 |
+
if m:
|
| 399 |
+
(inner.setdefault("Cisco-IOS-XE-switch:switchport-conf", {})
|
| 400 |
+
.setdefault("switchport", {})
|
| 401 |
+
.setdefault("trunk", {}))["native"] = {"vlan": {"vlan-id": int(m.group(1))}}
|
| 402 |
+
|
| 403 |
+
if re.match(r"spanning-tree\s+portfast", l):
|
| 404 |
+
inner.setdefault("Cisco-IOS-XE-spanning-tree:spanning-tree", {})["portfast"] = {}
|
| 405 |
+
|
| 406 |
+
m = re.match(r"duplex\s+(\S+)", l)
|
| 407 |
+
if m:
|
| 408 |
+
inner["duplex"] = {"Cisco-IOS-XE-ethernet:duplex-enum": m.group(1)}
|
| 409 |
+
|
| 410 |
+
m = re.match(r"speed\s+(\d+)", l)
|
| 411 |
+
if m:
|
| 412 |
+
inner["speed"] = {"Cisco-IOS-XE-ethernet:value": int(m.group(1))}
|
| 413 |
+
|
| 414 |
+
m = re.match(r"description\s+(.+)", l)
|
| 415 |
+
if m:
|
| 416 |
+
inner["description"] = m.group(1)
|
| 417 |
+
|
| 418 |
+
m = re.match(r"ip\s+address\s+(\S+)\s+(\S+)", l)
|
| 419 |
+
if m:
|
| 420 |
+
inner.setdefault("ip", {}).setdefault("address", {})["primary"] = {
|
| 421 |
+
"address": m.group(1), "mask": m.group(2)
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
m = re.match(r"storm-control\s+broadcast\s+level\s+([\d\.]+)", l)
|
| 425 |
+
if m:
|
| 426 |
+
(inner.setdefault("Cisco-IOS-XE-storm-control:storm-control", {})
|
| 427 |
+
.setdefault("action", {}))["broadcast"] = {
|
| 428 |
+
"level": {"threshold": float(m.group(1))}
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
return {f"Cisco-IOS-XE-native:{itype}": [inner]}
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def _body_ospf(process_id: str, lines: list[str]) -> dict:
|
| 435 |
+
inner: dict = {"id": int(process_id) if process_id.isdigit() else process_id}
|
| 436 |
+
|
| 437 |
+
for line in lines:
|
| 438 |
+
l = line.strip().lower()
|
| 439 |
+
|
| 440 |
+
m = re.match(r"area\s+(\S+)\s+authentication(\s+message-digest)?", l)
|
| 441 |
+
if m:
|
| 442 |
+
area_entry: dict = {"area-id": m.group(1)}
|
| 443 |
+
area_entry["authentication"] = {"message-digest": {}} if m.group(2) else {}
|
| 444 |
+
inner.setdefault("area", []).append(area_entry)
|
| 445 |
+
|
| 446 |
+
m = re.match(r"area\s+(\S+)\s+stub", l)
|
| 447 |
+
if m:
|
| 448 |
+
inner.setdefault("area", []).append({"area-id": m.group(1), "stub": {}})
|
| 449 |
+
|
| 450 |
+
m = re.match(r"network\s+(\S+)\s+(\S+)\s+area\s+(\S+)", l)
|
| 451 |
+
if m:
|
| 452 |
+
inner.setdefault("network", []).append({
|
| 453 |
+
"ip": m.group(1), "mask": m.group(2), "area": m.group(3)
|
| 454 |
+
})
|
| 455 |
+
|
| 456 |
+
m = re.match(r"router-id\s+(\S+)", l)
|
| 457 |
+
if m:
|
| 458 |
+
inner["router-id"] = m.group(1)
|
| 459 |
+
|
| 460 |
+
m = re.match(r"timers\s+spf\s+(\d+)\s+(\d+)", l)
|
| 461 |
+
if m:
|
| 462 |
+
inner["timers"] = {"throttle": {"spf": {
|
| 463 |
+
"initial-spf-delay": int(m.group(1)),
|
| 464 |
+
"minimum-hold": int(m.group(2))
|
| 465 |
+
}}}
|
| 466 |
+
|
| 467 |
+
m = re.match(r"redistribute\s+(\S+)", l)
|
| 468 |
+
if m:
|
| 469 |
+
inner.setdefault("redistribute", []).append({"source": m.group(1)})
|
| 470 |
+
|
| 471 |
+
return {"Cisco-IOS-XE-native:ospf": [inner]}
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _body_bgp(asn: str, lines: list[str]) -> dict:
|
| 475 |
+
inner: dict = {"id": int(asn) if asn.isdigit() else asn}
|
| 476 |
+
neighbors: dict[str, dict] = {} # ip -> neighbor dict
|
| 477 |
+
|
| 478 |
+
for line in lines:
|
| 479 |
+
l = line.strip().lower()
|
| 480 |
+
|
| 481 |
+
m = re.match(r"neighbor\s+(\S+)\s+timers\s+(\d+)\s+(\d+)", l)
|
| 482 |
+
if m:
|
| 483 |
+
nbr = neighbors.setdefault(m.group(1), {"id": m.group(1)})
|
| 484 |
+
nbr["timers"] = {"keepalive": int(m.group(2)), "holdtime": int(m.group(3))}
|
| 485 |
+
|
| 486 |
+
m = re.match(r"neighbor\s+(\S+)\s+maximum-prefix\s+(\d+)", l)
|
| 487 |
+
if m:
|
| 488 |
+
nbr = neighbors.setdefault(m.group(1), {"id": m.group(1)})
|
| 489 |
+
nbr["maximum-prefix"] = int(m.group(2))
|
| 490 |
+
|
| 491 |
+
m = re.match(r"neighbor\s+(\S+)\s+next-hop-self", l)
|
| 492 |
+
if m:
|
| 493 |
+
nbr = neighbors.setdefault(m.group(1), {"id": m.group(1)})
|
| 494 |
+
nbr["next-hop-self"] = {}
|
| 495 |
+
|
| 496 |
+
m = re.match(r"neighbor\s+(\S+)\s+remote-as\s+(\d+)", l)
|
| 497 |
+
if m:
|
| 498 |
+
nbr = neighbors.setdefault(m.group(1), {"id": m.group(1)})
|
| 499 |
+
nbr["remote-as"] = int(m.group(2))
|
| 500 |
+
|
| 501 |
+
m = re.match(r"neighbor\s+(\S+)\s+route-map\s+(\S+)\s+(in|out)", l)
|
| 502 |
+
if m:
|
| 503 |
+
nbr = neighbors.setdefault(m.group(1), {"id": m.group(1)})
|
| 504 |
+
nbr.setdefault("route-map", []).append({"inout": m.group(3), "route-map-name": m.group(2)})
|
| 505 |
+
|
| 506 |
+
m = re.match(r"bgp\s+router-id\s+(\S+)", l)
|
| 507 |
+
if m:
|
| 508 |
+
inner["bgp"] = {"router-id": {"ip": m.group(1)}}
|
| 509 |
+
|
| 510 |
+
if neighbors:
|
| 511 |
+
inner["neighbor"] = list(neighbors.values())
|
| 512 |
+
|
| 513 |
+
return {"Cisco-IOS-XE-native:bgp": [inner]}
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def _body_acl(acl_type: str, acl_name: str, lines: list[str]) -> dict:
|
| 517 |
+
entries = []
|
| 518 |
+
seq = 10
|
| 519 |
+
for line in lines:
|
| 520 |
+
m = re.match(r"(permit|deny)\s+(.+)", line.strip(), re.I)
|
| 521 |
+
if m:
|
| 522 |
+
entries.append({"sequence": seq, "action": m.group(1).lower(), "rule": m.group(2)})
|
| 523 |
+
seq += 10
|
| 524 |
+
|
| 525 |
+
return {f"Cisco-IOS-XE-native:{acl_type}": [{"name": acl_name, "access-list-seq-rule": entries}]}
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def _body_global_lines(lines: list[str]) -> list[dict]:
|
| 529 |
+
"""
|
| 530 |
+
Parse global-level CLI commands.
|
| 531 |
+
Returns list of {"path_suffix": str, "body": dict}.
|
| 532 |
+
"""
|
| 533 |
+
ops: list[dict] = []
|
| 534 |
+
|
| 535 |
+
for line in lines:
|
| 536 |
+
l = line.strip().lower()
|
| 537 |
+
|
| 538 |
+
m = re.match(r"ntp\s+server\s+(\S+)", l)
|
| 539 |
+
if m:
|
| 540 |
+
ops.append({"path_suffix": "/ntp", "body": {
|
| 541 |
+
"Cisco-IOS-XE-native:ntp": {"server": {"server-list": [{"ip-address": m.group(1)}]}}
|
| 542 |
+
}})
|
| 543 |
+
|
| 544 |
+
m = re.match(r"ip\s+route\s+(\S+)\s+(\S+)\s+(\S+)", l)
|
| 545 |
+
if m:
|
| 546 |
+
ops.append({"path_suffix": "/ip/route", "body": {
|
| 547 |
+
"Cisco-IOS-XE-native:route": {"ip-route-interface-forwarding-list": [{
|
| 548 |
+
"prefix": m.group(1), "mask": m.group(2),
|
| 549 |
+
"fwd-list": [{"fwd": m.group(3)}]
|
| 550 |
+
}]}
|
| 551 |
+
}})
|
| 552 |
+
|
| 553 |
+
if re.match(r"no\s+ip\s+http\s+secure-server", l):
|
| 554 |
+
ops.append({"path_suffix": "/ip/http", "body": {
|
| 555 |
+
"Cisco-IOS-XE-native:http": {"secure-server": False}
|
| 556 |
+
}})
|
| 557 |
+
elif re.match(r"no\s+ip\s+http\s+server", l):
|
| 558 |
+
ops.append({"path_suffix": "/ip/http", "body": {
|
| 559 |
+
"Cisco-IOS-XE-native:http": {"server": False}
|
| 560 |
+
}})
|
| 561 |
+
|
| 562 |
+
m = re.match(r"logging\s+buffered\s+(\d+)", l)
|
| 563 |
+
if m:
|
| 564 |
+
ops.append({"path_suffix": "/logging", "body": {
|
| 565 |
+
"Cisco-IOS-XE-native:logging": {"buffered": {"size": int(m.group(1))}}
|
| 566 |
+
}})
|
| 567 |
+
|
| 568 |
+
if re.match(r"no\s+logging\s+console", l):
|
| 569 |
+
ops.append({"path_suffix": "/logging", "body": {
|
| 570 |
+
"Cisco-IOS-XE-native:logging": {"console": False}
|
| 571 |
+
}})
|
| 572 |
+
|
| 573 |
+
m = re.match(r"exception\s+crashinfo\s+(?:file\s+)?(.+)", l)
|
| 574 |
+
if m:
|
| 575 |
+
ops.append({"path_suffix": "/exception", "body": {
|
| 576 |
+
"Cisco-IOS-XE-native:exception": {"crashinfo": {"filepath": m.group(1).strip()}}
|
| 577 |
+
}})
|
| 578 |
+
|
| 579 |
+
m = re.match(r"ip\s+access-list\s+(extended|standard)\s+(\S+)", l)
|
| 580 |
+
if m:
|
| 581 |
+
ops.append({"path_suffix": f"/ip/access-list/{m.group(1)}={m.group(2)}", "body": {
|
| 582 |
+
f"Cisco-IOS-XE-native:{m.group(1)}": [{"name": m.group(2)}]
|
| 583 |
+
}})
|
| 584 |
+
|
| 585 |
+
return ops
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def _cli_to_restconf_fix_ops(device: str, mgmt_ip: str, fix_text: str) -> list[dict]:
|
| 589 |
+
"""
|
| 590 |
+
Parse CLI fix commands and return a list of RESTCONF PATCH operations
|
| 591 |
+
with best-effort YANG JSON bodies.
|
| 592 |
+
"""
|
| 593 |
+
headers = {
|
| 594 |
+
"Accept": "application/yang-data+json",
|
| 595 |
+
"Content-Type": "application/yang-data+json",
|
| 596 |
+
"Authorization": _make_auth_header(),
|
| 597 |
+
}
|
| 598 |
+
base_url = f"https://{mgmt_ip}:{RESTCONF_PORT}/restconf/data/Cisco-IOS-XE-native:native"
|
| 599 |
+
|
| 600 |
+
blocks = _parse_cli_blocks(fix_text)
|
| 601 |
+
ops: list[dict] = []
|
| 602 |
+
|
| 603 |
+
for block in blocks:
|
| 604 |
+
ctx = block["context"]
|
| 605 |
+
lines = block["lines"]
|
| 606 |
+
if not lines:
|
| 607 |
+
continue
|
| 608 |
+
|
| 609 |
+
ctx_l = ctx.strip().lower()
|
| 610 |
+
|
| 611 |
+
# ββ interface ββ
|
| 612 |
+
m = re.match(r"interface\s+(\S+)", ctx_l)
|
| 613 |
+
if m:
|
| 614 |
+
iface = m.group(1)
|
| 615 |
+
itype, islot = _parse_interface_name(iface)
|
| 616 |
+
url = f"{base_url}/interface/{itype}={_url_encode_slot(islot)}"
|
| 617 |
+
body = _body_interface(itype, islot, lines)
|
| 618 |
+
ops.append({"method": "PATCH", "url": url, "headers": headers,
|
| 619 |
+
"body": body, "cli_context": ctx, "cli_commands": lines})
|
| 620 |
+
continue
|
| 621 |
+
|
| 622 |
+
# ββ router ospf ββ
|
| 623 |
+
m = re.match(r"router\s+ospf\s+(\S+)", ctx_l)
|
| 624 |
+
if m:
|
| 625 |
+
pid = m.group(1)
|
| 626 |
+
url = f"{base_url}/router/ospf={pid}"
|
| 627 |
+
body = _body_ospf(pid, lines)
|
| 628 |
+
ops.append({"method": "PATCH", "url": url, "headers": headers,
|
| 629 |
+
"body": body, "cli_context": ctx, "cli_commands": lines})
|
| 630 |
+
continue
|
| 631 |
+
|
| 632 |
+
# ββ router bgp ββ
|
| 633 |
+
m = re.match(r"router\s+bgp\s+(\S+)", ctx_l)
|
| 634 |
+
if m:
|
| 635 |
+
asn = m.group(1)
|
| 636 |
+
url = f"{base_url}/router/bgp={asn}"
|
| 637 |
+
body = _body_bgp(asn, lines)
|
| 638 |
+
ops.append({"method": "PATCH", "url": url, "headers": headers,
|
| 639 |
+
"body": body, "cli_context": ctx, "cli_commands": lines})
|
| 640 |
+
continue
|
| 641 |
+
|
| 642 |
+
# ββ ip access-list ββ
|
| 643 |
+
m = re.match(r"ip\s+access-list\s+(extended|standard)\s+(\S+)", ctx_l)
|
| 644 |
+
if m:
|
| 645 |
+
acl_type, acl_name = m.group(1), m.group(2)
|
| 646 |
+
url = f"{base_url}/ip/access-list/{acl_type}={acl_name}"
|
| 647 |
+
body = _body_acl(acl_type, acl_name, lines)
|
| 648 |
+
ops.append({"method": "PATCH", "url": url, "headers": headers,
|
| 649 |
+
"body": body, "cli_context": ctx, "cli_commands": lines})
|
| 650 |
+
continue
|
| 651 |
+
|
| 652 |
+
# ββ global ββ
|
| 653 |
+
if ctx_l == "global":
|
| 654 |
+
global_ops = _body_global_lines(lines)
|
| 655 |
+
if global_ops:
|
| 656 |
+
for gop in global_ops:
|
| 657 |
+
ops.append({"method": "PATCH",
|
| 658 |
+
"url": base_url + gop["path_suffix"],
|
| 659 |
+
"headers": headers, "body": gop["body"],
|
| 660 |
+
"cli_context": "global", "cli_commands": lines})
|
| 661 |
+
else:
|
| 662 |
+
# Unknown global commands β include as note
|
| 663 |
+
ops.append({"method": "PATCH", "url": base_url, "headers": headers,
|
| 664 |
+
"body": None, "cli_context": "global",
|
| 665 |
+
"cli_commands": lines,
|
| 666 |
+
"note": "Could not map to YANG - apply manually via CLI"})
|
| 667 |
+
continue
|
| 668 |
+
|
| 669 |
+
# ββ unrecognised context ββ
|
| 670 |
+
ops.append({"method": "PATCH", "url": base_url, "headers": headers,
|
| 671 |
+
"body": None, "cli_context": ctx, "cli_commands": lines,
|
| 672 |
+
"note": "Context not mapped to RESTCONF - apply manually"})
|
| 673 |
+
|
| 674 |
+
return ops
|
| 675 |
+
|
| 676 |
+
|
| 677 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 678 |
# MAIN
|
| 679 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 777 |
fix_file.write_text(fix, encoding="utf-8")
|
| 778 |
log.info("Fix saved to: %s", fix_file)
|
| 779 |
|
| 780 |
+
# ββ Convert CLI fix commands to RESTCONF PATCH operations βββββββββ
|
| 781 |
+
mgmt_ip = _get_mgmt_ip_from_folder(folder)
|
| 782 |
+
restconf_ops = _cli_to_restconf_fix_ops(device, mgmt_ip, fix)
|
| 783 |
+
rc_fix_file = folder / "restconf_fix_commands.json"
|
| 784 |
+
rc_fix_file.write_text(
|
| 785 |
+
json.dumps(restconf_ops, indent=2, ensure_ascii=False),
|
| 786 |
+
encoding="utf-8"
|
| 787 |
+
)
|
| 788 |
+
log.info("RESTCONF fix operations (%d) written to: %s",
|
| 789 |
+
len(restconf_ops), rc_fix_file)
|
| 790 |
+
for op in restconf_ops:
|
| 791 |
+
flag = " [NO BODY]" if op.get("body") is None else ""
|
| 792 |
+
log.info(" %-6s %s%s", op["method"], op["url"], flag)
|
| 793 |
+
|
| 794 |
# ββ Write to main output file ββββββββββββββββββββββββββββββββββββ
|
| 795 |
out_rec = {
|
| 796 |
+
"alert_index": idx,
|
| 797 |
+
"timestamp": rec.get("timestamp"),
|
| 798 |
+
"device": device,
|
| 799 |
+
"incident_type": incident,
|
| 800 |
+
"domain": domain,
|
| 801 |
+
"sub_id": sub_id,
|
| 802 |
+
"lora_used": lora_rel,
|
| 803 |
+
"alert_folder": str(folder),
|
| 804 |
+
"wazuh_alert": alert,
|
| 805 |
+
"show_commands_used": list(show_outputs.keys()),
|
| 806 |
+
"fix_commands": fix,
|
| 807 |
+
"restconf_fix_count": len(restconf_ops),
|
| 808 |
+
"restconf_fix_file": str(rc_fix_file),
|
| 809 |
}
|
| 810 |
fout.write(json.dumps(out_rec, ensure_ascii=False) + "\n")
|
| 811 |
fout.flush()
|
| 812 |
|
| 813 |
print("\n" + "=" * 65)
|
| 814 |
print(f" All alerts processed.")
|
| 815 |
+
print(f" Results -> {output_file.resolve()}")
|
| 816 |
+
print(f" Per-alert folder -> fix_commands.txt (CLI commands)")
|
| 817 |
+
print(f" Per-alert folder -> restconf_fix_commands.json (RESTCONF PATCH ops)")
|
| 818 |
print("=" * 65 + "\n")
|
| 819 |
|
| 820 |
|