JoeiBanana commited on
Commit
036a93f
Β·
verified Β·
1 Parent(s): dbbc211

Upload batch 8/8

Browse files
Files changed (2) hide show
  1. README.md +217 -89
  2. 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 pipeline for Cisco routers and switches.
24
- Takes Wazuh SIEM alerts, classifies them, queries live device state via RESTCONF,
25
- and generates CLI fix commands using fine-tuned LoRA adapters.
 
 
 
 
 
 
 
26
 
27
  ## Architecture
28
 
 
 
29
  ```
30
- Wazuh Alert (JSON)
31
- β”‚
32
- β–Ό
33
- [prefilter.py] ← detect SSH brute-force, drop auth noise
34
- β”‚
35
- β–Ό
36
- [pipeline.py]
37
- Stage 1 – Wazuh LLM (Ollama) β†’ incident_type, severity, IOCs
38
- Stage 2 – Show command lookup β†’ deterministic table (incident β†’ show cmds)
39
- Stage 3 – RESTCONF mapping β†’ deterministic table (show cmd β†’ YANG path)
40
- Stage 4 – Domain routing β†’ selects correct LoRA adapter
41
- β”‚
42
- β–Ό creates show_outputs/alert_NNN_DEVICE_INCIDENT/
43
- restconf_commands.json ← execute these GETs against routers
44
- alert_info.json
45
- β”‚
46
- (operator drops RESTCONF response JSONs into the folder)
47
- β”‚
48
- β–Ό
49
- [run_fix.py] ← polls folders, runs domain LoRA, writes fix_commands.txt
 
 
 
 
 
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 | Adapter | Incidents covered |
57
- |--------|---------|-------------------|
58
- | OSPF | `ospf_llm/lora_llm_ospf1` | Neighbor down, adjacency loss |
59
- | OSPF | `ospf_llm/lora_llm_ospf2` | ExStart/Exchange stuck, MTU/area mismatch |
60
- | OSPF | `ospf_llm/lora_llm_ospf3` | Auth, hello/dead timer, network-type mismatch |
61
- | OSPF | `ospf_llm/lora_llm_ospf4` | LSA flood, LSDB inconsistency, redistribution |
62
- | BGP | `bgp_llm/lora_llm_bgp1` | Session flap, hold-timer expiry, reset |
63
- | BGP | `bgp_llm/lora_llm_bgp2` | Prefix limit, route leak, path selection |
64
- | BGP | `bgp_llm/lora_llm_bgp3` | Missing RIB routes, next-hop-self, AFI-SAFI |
65
- | Sec | `sec_llm/lora_llm_sec1` | ACL blocking legitimate traffic |
66
- | Sec | `sec_llm/lora_llm_sec2` | ACL misconfiguration |
67
- | Sec | `sec_llm/lora_llm_sec3` | Excessive deny entries, brute force, port scan |
68
- | Switch | `switch_llm/lora_llm_switch1` | MAC flapping, STP topology change, err-disable |
69
- | Switch | `switch_llm/lora_llm_switch2` | VLAN mismatch, trunk negotiation |
70
- | Switch | `switch_llm/lora_llm_switch3` | LLDP/CDP inconsistency, storm control |
71
- | Service| `service_llm/lora_llm_service1` | DHCP conflict, starvation, IP conflict |
72
- | Service| `service_llm/lora_llm_service2` | DNS issues, NTP unsync, ARP spoofing |
73
- | Sys | `sys_llm/lora_llm_sys1` | High CPU, memory exhaustion, process crash |
74
- | Sys | `sys_llm/lora_llm_sys2` | Interface flap, duplex mismatch |
75
- | Sys | `sys_llm/lora_llm_sys3` | Reload, environment, general system |
 
 
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
- ## Quick Start
 
 
90
 
91
  ```bash
92
- # Step 1 β€” filter alerts (brute-force detection + auth noise removal)
93
- python prefilter.py --input alerts.json --output filtered_enriched.jsonl
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- # Step 2 β€” Phase 1: classify, map RESTCONF commands, create per-alert folders
96
- python pipeline.py --input filtered_enriched.jsonl
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
- # Step 3 β€” execute the RESTCONF GETs listed in each alert folder's
99
- # restconf_commands.json and save responses into the same folder
100
- # (using the 'filename' field as the save name)
101
 
102
- # Step 4 β€” Phase 2: run domain LoRA, generate fix commands
103
- python run_fix.py
 
 
 
 
 
104
  ```
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  ## Device Configuration
107
 
108
- Edit the maps at the top of `pipeline.py` to match your lab:
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", # RESTCONF management IP
118
  "R2": "2.2.2.2",
119
- ...
120
  }
121
  ```
122
 
123
- Router credentials: `ROUTER_USER` / `ROUTER_PASS` constants in `pipeline.py`.
 
 
124
 
125
  ## RESTCONF
126
 
127
- The pipeline uses RESTCONF over HTTPS (port 443) with Cisco IOS-XE YANG models.
128
- All show-command β†’ YANG-path mappings are in the `_RESTCONF_RULES` table inside
129
- `pipeline.py` and also standalone in `show_to_restconf.py`.
130
-
131
- Supported YANG modules:
132
- - `Cisco-IOS-XE-ospf-oper`
133
- - `Cisco-IOS-XE-bgp-oper`
134
- - `Cisco-IOS-XE-interfaces-oper`
135
- - `Cisco-IOS-XE-acl-oper`
136
- - `Cisco-IOS-XE-spanning-tree-oper`
137
- - `Cisco-IOS-XE-dhcp-oper`
138
- - `Cisco-IOS-XE-arp-oper`
139
- - `Cisco-IOS-XE-nat-oper`
140
- - `Cisco-IOS-XE-ntp-oper`
141
- - `Cisco-IOS-XE-process-cpu-oper`
142
- - `Cisco-IOS-XE-process-memory-oper`
143
- - and more …
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  ## Training
146
 
147
  Training scripts and datasets are in `training/` and `datasets/`.
148
- Each adapter was trained for ~900–1500 examples with LoRA (r=8, alpha=32, dropout=0.1)
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 + 2-switch Cisco lab:
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
- ## Files
 
 
 
 
 
 
166
 
167
- | File | Description |
 
 
168
  |------|-------------|
169
- | `pipeline.py` | Main Phase 1 pipeline (self-contained, no external imports) |
170
- | `prefilter.py` | Brute-force detection + auth alert filtering |
171
- | `run_fix.py` | Phase 2 β€” polls folders, runs domain LoRA, writes fix commands |
172
- | `show_to_restconf.py` | Standalone show→RESTCONF lookup tool |
173
- | `incident_to_showcommands.py` | Standalone incident→show-commands lookup tool |
174
- | `Modelfile` | Ollama model definition for the Wazuh classifier |
 
 
 
 
 
 
 
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", "fix_commands.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", "fix_commands.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": idx,
354
- "timestamp": rec.get("timestamp"),
355
- "device": device,
356
- "incident_type": incident,
357
- "domain": domain,
358
- "sub_id": sub_id,
359
- "lora_used": lora_rel,
360
- "alert_folder": str(folder),
361
- "wazuh_alert": alert,
362
- "show_commands_used": list(show_outputs.keys()),
363
- "fix_commands": fix,
 
 
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 -> {output_file.resolve()}")
371
- print(f" Fix files saved inside each alert folder as fix_commands.txt")
 
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