File size: 13,526 Bytes
8c25076
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
"""
DynamicInputPipeline β€” Three-Source Dynamic Scenario Input
===========================================================
Provides a unified interface for three sources of dynamic input that drive
scenario variation in the SOC Simulator:

Source A β€” Live Threat Intel Feed
    Fetches malicious IPs from URLhaus and Feodo Tracker (both free, no auth).
    Results are cached for 1 hour.  Falls back to hardcoded IPs on failure.

Source B β€” Performance-Based Difficulty Adaptation
    Tracks blue agent's rolling average score over the last 5 episodes and
    returns difficulty adjustment parameters accordingly.

Source C β€” Runtime Custom Parameters
    Accepts per-request overrides (attacker_ip, target_user, attack_intensity,
    enable_red_agent, use_live_threat_intel) that merge on top of scenario
    defaults.

Usage
-----
pipeline = DynamicInputPipeline()

# Source A
threat_ips = pipeline.get_live_threat_ips()

# Source B
params = pipeline.adapt_difficulty([0.3, 0.5, 0.6, 0.45, 0.55])

# Source C β€” typically called by the server layer
merged = pipeline.merge_custom_params(base_scenario_params, custom_params_dict)
"""

from __future__ import annotations

import json
import logging
import time
from typing import Any, Dict, List, Optional

import requests

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

URLHAUS_API_URL = "https://urlhaus-api.abuse.ch/v1/urls/recent/"
FEODO_BLOCKLIST_URL = (
    "https://feodotracker.abuse.ch/downloads/ipblocklist.json"
)

CACHE_TTL_SECONDS = 3600  # 1 hour

# Hardcoded fallback β€” realistic-looking attacker IPs from documentation ranges
FALLBACK_THREAT_IPS: List[str] = [
    "198.51.100.10",
    "198.51.100.35",
    "198.51.100.77",
    "203.0.113.5",
    "203.0.113.42",
    "203.0.113.99",
    "198.51.100.120",
    "203.0.113.150",
]

# Difficulty adaptation thresholds
LOW_SCORE_THRESHOLD = 0.40
HIGH_SCORE_THRESHOLD = 0.75


# ---------------------------------------------------------------------------
# DynamicInputPipeline
# ---------------------------------------------------------------------------


class DynamicInputPipeline:
    """
    Unified pipeline for three sources of dynamic scenario input.

    Thread Safety
    -------------
    The class keeps a simple in-memory cache.  For single-threaded or
    asyncio-based usage (FastAPI) this is safe.  For multi-process deployment
    consider an external cache (Redis).
    """

    def __init__(
        self,
        cache_ttl: int = CACHE_TTL_SECONDS,
        http_timeout: int = 10,
    ) -> None:
        """
        Parameters
        ----------
        cache_ttl : int
            Number of seconds to cache threat-intel results (default 3600).
        http_timeout : int
            HTTP request timeout in seconds (default 10).
        """
        self._cache_ttl = cache_ttl
        self._http_timeout = http_timeout

        # Source A β€” threat intel cache
        self._threat_ip_cache: List[str] = []
        self._cache_fetched_at: float = 0.0  # epoch seconds

        # Source B β€” episode score history (stored internally)
        self._score_history: List[float] = []

    # =========================================================================
    # SOURCE A β€” Live Threat Intel
    # =========================================================================

    def get_live_threat_ips(self) -> List[str]:
        """
        Return a deduplicated list of malicious IPs from live threat feeds.

        Sources:
        - URLhaus API (recent malware URLs β†’ extract host IPs)
        - Feodo Tracker IP blocklist (JSON)

        Results are cached for ``cache_ttl`` seconds.  On any network /
        parsing failure the method falls back to ``FALLBACK_THREAT_IPS``.

        Returns
        -------
        List[str]
            Deduplicated list of malicious IP strings.
        """
        now = time.monotonic()
        if (
            self._threat_ip_cache
            and (now - self._cache_fetched_at) < self._cache_ttl
        ):
            logger.debug("Returning cached threat IPs (%d entries).", len(self._threat_ip_cache))
            return list(self._threat_ip_cache)

        ips: List[str] = []

        # ── URLhaus ──────────────────────────────────────────────────────────
        try:
            resp = requests.post(
                URLHAUS_API_URL,
                data={"limit": 100},
                timeout=self._http_timeout,
            )
            resp.raise_for_status()
            payload = resp.json()
            for url_entry in payload.get("urls", []):
                host = url_entry.get("host", "")
                # Only keep plain IP addresses (not domain names)
                if host and self._is_ipv4(host):
                    ips.append(host)
            logger.info("URLhaus: fetched %d malicious IPs.", len(ips))
        except Exception as exc:  # noqa: BLE001
            logger.debug("URLhaus fetch failed (typically requires API key now) β€” %s", exc)

        # ── Feodo Tracker ────────────────────────────────────────────────────
        try:
            resp = requests.get(
                FEODO_BLOCKLIST_URL,
                timeout=self._http_timeout,
            )
            resp.raise_for_status()
            entries = resp.json()
            feodo_ips = [
                e.get("ip_address", "")
                for e in entries
                if isinstance(e, dict) and e.get("ip_address")
            ]
            ips.extend(feodo_ips)
            logger.info("FeodoTracker: fetched %d malicious IPs.", len(feodo_ips))
        except Exception as exc:  # noqa: BLE001
            logger.warning("FeodoTracker fetch failed β€” %s", exc)

        # Deduplicate + validate
        unique_ips = list(dict.fromkeys(ip for ip in ips if self._is_ipv4(ip)))

        if unique_ips:
            self._threat_ip_cache = unique_ips
            self._cache_fetched_at = now
            return list(unique_ips)

        # All fetches failed β€” return fallback
        logger.warning(
            "All threat intel sources failed.  Using fallback IP list (%d entries).",
            len(FALLBACK_THREAT_IPS),
        )
        return list(FALLBACK_THREAT_IPS)

    def get_threat_intel_summary(self) -> Dict[str, Any]:
        """
        Return metadata about the current threat intel cache state.

        Returns
        -------
        dict with keys: cached_ip_count, cache_age_seconds, cache_valid,
                        sample_ips (first 5)
        """
        now = time.monotonic()
        age = now - self._cache_fetched_at if self._cache_fetched_at > 0 else None
        return {
            "cached_ip_count": len(self._threat_ip_cache),
            "cache_age_seconds": round(age, 1) if age is not None else None,
            "cache_valid": bool(
                self._threat_ip_cache and age is not None and age < self._cache_ttl
            ),
            "sample_ips": self._threat_ip_cache[:5],
        }

    # =========================================================================
    # SOURCE B β€” Performance-Based Difficulty Adaptation
    # =========================================================================

    def adapt_difficulty(
        self, score_history: Optional[List[float]] = None
    ) -> Dict[str, Any]:
        """
        Compute difficulty adjustment parameters based on rolling avg score.

        Parameters
        ----------
        score_history : List[float], optional
            Episode scores to use.  If None, uses the internally tracked
            history (updated via ``record_episode_score``).  Uses the last 5
            scores from the provided list.

        Returns
        -------
        dict
            Keys:
            - ``difficulty_level`` : "easy" | "medium" | "hard"
            - ``rolling_avg_score`` : float
            - ``num_attack_events`` : int  (suggested event count)
            - ``inject_decoy_alert`` : bool
            - ``add_lateral_movement`` : bool
            - ``concurrent_attack_streams`` : int
            - ``use_obvious_malicious_ips`` : bool
            - ``rationale`` : str
        """
        history = score_history if score_history is not None else self._score_history
        recent = history[-5:] if len(history) >= 5 else history

        avg = sum(recent) / len(recent) if recent else 0.0

        params: Dict[str, Any] = {
            "rolling_avg_score": round(avg, 4),
            "episodes_considered": len(recent),
        }

        if avg < LOW_SCORE_THRESHOLD:
            # Blue agent struggling β€” make it easier
            params.update(
                {
                    "difficulty_level": "easy",
                    "num_attack_events": 3,
                    "inject_decoy_alert": False,
                    "add_lateral_movement": False,
                    "concurrent_attack_streams": 1,
                    "use_obvious_malicious_ips": True,
                    "rationale": (
                        f"Rolling avg score {avg:.2f} < {LOW_SCORE_THRESHOLD}. "
                        "Reduced event count and obvious attacker IPs to lower difficulty."
                    ),
                }
            )

        elif LOW_SCORE_THRESHOLD <= avg <= HIGH_SCORE_THRESHOLD:
            # Blue agent performing adequately β€” maintain current difficulty
            params.update(
                {
                    "difficulty_level": "medium",
                    "num_attack_events": 6,
                    "inject_decoy_alert": False,
                    "add_lateral_movement": False,
                    "concurrent_attack_streams": 1,
                    "use_obvious_malicious_ips": False,
                    "rationale": (
                        f"Rolling avg score {avg:.2f} in [{LOW_SCORE_THRESHOLD}, "
                        f"{HIGH_SCORE_THRESHOLD}]. Maintaining current difficulty."
                    ),
                }
            )

        else:
            # Blue agent excelling β€” escalate challenge
            params.update(
                {
                    "difficulty_level": "hard",
                    "num_attack_events": 10,
                    "inject_decoy_alert": True,       # A benign IP that looks suspicious
                    "add_lateral_movement": True,     # Add a new lateral movement hop
                    "concurrent_attack_streams": 3,   # Multiple simultaneous attacker IPs
                    "use_obvious_malicious_ips": False,
                    "rationale": (
                        f"Rolling avg score {avg:.2f} > {HIGH_SCORE_THRESHOLD}. "
                        "Injecting decoy alert, adding lateral movement step, "
                        "and increasing concurrent attack streams."
                    ),
                }
            )

        return params

    def record_episode_score(self, score: float) -> None:
        """
        Append an episode's final score to the internal history for
        subsequent ``adapt_difficulty()`` calls.

        Parameters
        ----------
        score : float
            Final grader score in [0, 1].
        """
        self._score_history.append(score)
        # Keep only last 20 to avoid unbounded growth
        if len(self._score_history) > 20:
            self._score_history = self._score_history[-20:]

    @property
    def score_history(self) -> List[float]:
        """Read-only copy of the internal score history."""
        return list(self._score_history)

    # =========================================================================
    # SOURCE C β€” Runtime Custom Parameter Merging
    # =========================================================================

    def merge_custom_params(
        self,
        base_params: Dict[str, Any],
        custom_params: Optional[Dict[str, Any]],
    ) -> Dict[str, Any]:
        """
        Merge runtime custom parameters on top of base scenario defaults.

        Only keys present in ``custom_params`` with non-None values will
        override the base.  All other base keys are preserved.

        Parameters
        ----------
        base_params : dict
            The scenario's default parameters.
        custom_params : dict or None
            Overrides provided at runtime (from the POST /reset body).

        Returns
        -------
        dict
            Merged parameter dict with custom overrides applied.
        """
        merged = dict(base_params)
        if not custom_params:
            return merged

        for key, value in custom_params.items():
            if value is not None:
                merged[key] = value

        return merged

    # =========================================================================
    # Helpers
    # =========================================================================

    @staticmethod
    def _is_ipv4(s: str) -> bool:
        """Return True if ``s`` looks like a valid IPv4 address."""
        parts = s.split(".")
        if len(parts) != 4:
            return False
        try:
            return all(0 <= int(p) <= 255 for p in parts)
        except ValueError:
            return False