File size: 12,192 Bytes
fbe9dad
ebd50f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbe9dad
ebd50f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Function-Call backend β€” the advisory/reasoning execution backend.

WHAT THIS IS
------------
This backend stands in for the "tool calling" that an agent framework normally
does with plain local functions. Where the Direct-API backend *changes things*
(it can create a ticket), this backend only **thinks out loud**: it looks things
up, scores risk, and drafts text for a human to read. It produces *analysis and
recommendations* β€” never an action against a real system.

    * LOOK-UPS / SCORING β€” cve_lookup, risk_score      (deterministic facts)
    * DRAFTS             β€” draft_remediation,           (generated text, for a
                           draft_incident                human to review)
    * RECOMMENDATIONS    β€” recommend_containment        (suggested next steps;
                                                         it suggests, never does)

THE ONE PROPERTY THAT DEFINES THIS BACKEND: NO SIDE EFFECTS
-----------------------------------------------------------
Every handler here is **pure**: given the same arguments it returns the same
result, and it changes *nothing* β€” not the mock data, not any external system,
not even an internal store (unlike Direct-API, this backend has no writable
store at all). That is what lets the policy allow these actions at the low
"recommend-only" autonomy tier (L1): a recommendation to contain a host is just
words until a human acts on it. "recommend_containment" returns a *plan*; it
does not contain anything.

WHERE IT SITS IN THE TRUST MODEL (same as every backend)
--------------------------------------------------------
This backend never decides *whether* an action is allowed β€” that already
happened upstream. By the time ``run`` is called, the gate has said ``ALLOW``
and the dispatcher has verified the gate's signed grant.

    gate decides ─► dispatcher verifies grant ─► THIS backend executes

It still keeps the same defense-in-depth guard as the other backends: an
**allow-list of handlers**. An action this backend doesn't know how to perform
(anything not in the table below) is refused, not guessed at.
"""

from __future__ import annotations

from typing import Any, Callable

import data
from control_plane.schema import ProposedAction


class FunctionBackendError(Exception):
    """Base error for anything this backend refuses or cannot do."""


class FunctionUnsupportedActionError(FunctionBackendError):
    """The requested action is not in this backend's allow-list of handlers."""


class FunctionRecordNotFoundError(FunctionBackendError):
    """A look-up referenced an ID that does not exist in the mock data."""


class FunctionMissingArgumentError(FunctionBackendError):
    """A required argument was absent from the action's ``arguments`` map."""


# How much an asset's business criticality amplifies a raw vulnerability score.
# Kept as a fixed table (not a formula) so risk scores are deterministic and easy
# to explain: the same CVE on a more critical asset scores higher, by a known amount.
_CRITICALITY_WEIGHT: dict[str, float] = {
    "low": 0.6,
    "medium": 0.8,
    "high": 1.0,
}


class FunctionCallBackend:
    """Advisory backend satisfying the dispatcher's ``BackendAdapter`` contract.

    Construct it once and let the dispatcher call :meth:`run` per authorized
    action. It holds only **read-only** references to the mock data β€” there is
    deliberately no mutable state here, because none of its actions may change
    anything.
    """

    def __init__(self) -> None:
        # Read-only lookups by ID. Indexed once at construction for O(1) access.
        # Nothing in this class ever writes back to these.
        self._cves = data.index_by_id("cves")
        self._assets = data.index_by_id("assets")
        self._alerts = data.load_alerts()  # scanned by incident_id when drafting

        # The allow-list: action_name -> handler. Membership here is what makes an
        # action executable by this backend; the names match the policy exactly
        # (policies/agt_policy.yaml, function_call section). Anything not listed is
        # refused by :meth:`run`.
        self._handlers: dict[str, Callable[[dict[str, Any]], Any]] = {
            # deterministic facts
            "cve_lookup": self._cve_lookup,
            "risk_score": self._risk_score,
            # generated text for a human to review
            "draft_remediation": self._draft_remediation,
            "draft_incident": self._draft_incident,
            # suggested next steps (advisory only)
            "recommend_containment": self._recommend_containment,
        }

    # -- dispatcher entry point ------------------------------------------------

    def run(self, action: ProposedAction) -> Any:
        """Execute *action* and return its advisory result.

        Routes on ``action.action_name`` to a handler in the allow-list. An action
        this backend doesn't handle raises :class:`FunctionUnsupportedActionError` β€” it is
        never silently ignored or guessed at.
        """
        handler = self._handlers.get(action.action_name)
        if handler is None:
            raise FunctionUnsupportedActionError(
                f"function_call backend cannot perform {action.action_name!r} "
                f"(not in its allow-list of advisory actions)"
            )
        return handler(action.arguments)

    # -- look-ups / scoring (deterministic facts) ------------------------------

    def _cve_lookup(self, args: dict[str, Any]) -> dict[str, Any]:
        """Return the mock CVE record for ``cve_id`` (a pure read of the fixtures)."""
        cve_id = _require(args, "cve_id")
        return _found(self._cves.get(cve_id), "cve", cve_id)

    def _risk_score(self, args: dict[str, Any]) -> dict[str, Any]:
        """Score a CVE, optionally amplified by the criticality of a named asset.

        The score is deterministic: it combines the CVE's published CVSS score
        with a fixed per-asset weight (see :data:`_CRITICALITY_WEIGHT`). The same
        inputs always yield the same score and band, which is what makes this
        usable as an advisory signal a human can sanity-check.
        """
        cve = self._cve_lookup(args)  # reuses the look-up (and its not-found guard)
        cvss = float(cve.get("cvss_score", 0.0))

        # Asset is optional: if given, a more business-critical asset raises the
        # effective risk; if absent, we score the raw vulnerability on its own.
        asset_id = args.get("asset_id")
        if asset_id:
            asset = _found(self._assets.get(asset_id), "asset", asset_id)
            criticality = asset.get("criticality", "medium")
            weight = _CRITICALITY_WEIGHT.get(criticality, 0.8)
        else:
            criticality, weight = None, 1.0

        score = round(cvss * weight, 1)
        return {
            "cve_id": cve["cve_id"],
            "asset_id": asset_id,
            "cvss_score": cvss,
            "asset_criticality": criticality,
            "risk_score": score,
            "risk_band": _band(score),
            "rationale": (
                f"CVSS {cvss} weighted by asset criticality "
                f"{criticality or 'n/a'} (Γ—{weight}) β†’ {score}"
            ),
        }

    # -- drafts (generated text, no side effects) ------------------------------

    def _draft_remediation(self, args: dict[str, Any]) -> dict[str, Any]:
        """Draft a remediation write-up for a CVE β€” text only, nothing is applied.

        This is the "L1 draft" action. It assembles human-readable
        guidance from the mock CVE record; it does **not** patch, restart, or
        change anything. The ``is_draft`` flag makes that explicit to any consumer.
        """
        cve = self._cve_lookup(args)
        content = (
            f"Remediation draft for {cve['cve_id']} β€” {cve['title']}\n"
            f"Severity: {cve.get('severity', 'unknown')} "
            f"(CVSS {cve.get('cvss_score', 'n/a')})\n\n"
            f"Summary: {cve.get('summary', '')}\n\n"
            f"Recommended steps: {cve.get('remediation', 'No guidance on file.')}\n\n"
            f"NOTE: This is a draft for analyst review. No change has been applied."
        )
        return {
            "kind": "remediation_draft",
            "cve_id": cve["cve_id"],
            "is_draft": True,
            "content": content,
        }

    def _draft_incident(self, args: dict[str, Any]) -> dict[str, Any]:
        """Draft an incident summary from the alerts attached to an incident_id.

        Pulls every mock alert sharing the incident_id and folds them into a short
        narrative a human could paste into an incident record. Read-only: it never
        creates or edits the incident itself.
        """
        incident_id = _require(args, "incident_id")
        alerts = [a for a in self._alerts if a.get("incident_id") == incident_id]
        if not alerts:
            raise FunctionRecordNotFoundError(f"no alerts found for incident {incident_id!r}")

        lines = [f"Incident draft for {incident_id}", ""]
        for alert in alerts:
            lines.append(
                f"- [{alert.get('severity', '?')}] {alert['alert_id']}: "
                f"{alert.get('title', '')} (asset {alert.get('asset_id', 'n/a')})"
            )
        lines += ["", "NOTE: Draft for analyst review. The incident record is unchanged."]
        return {
            "kind": "incident_draft",
            "incident_id": incident_id,
            "alert_ids": [a["alert_id"] for a in alerts],
            "is_draft": True,
            "content": "\n".join(lines),
        }

    def _recommend_containment(self, args: dict[str, Any]) -> dict[str, Any]:
        """Recommend containment steps for an asset β€” a suggestion, not an action.

        Returns an ordered list of *suggested* steps (e.g. isolate the host, stop a
        suspicious container). Crucially this backend cannot perform any of them;
        executing a containment step is a high-tier action that would have to go
        back through the gate on a separate, approval-gated request.
        """
        asset_id = _require(args, "asset_id")
        asset = _found(self._assets.get(asset_id), "asset", asset_id)

        steps = [f"Isolate host {asset.get('hostname', asset_id)} from the network."]
        # If a specific container was named, recommend stopping just that one;
        # otherwise call out any container the mock data already flags suspicious.
        container_id = args.get("container_id")
        suspicious = [
            c for c in asset.get("containers", [])
            if c.get("suspicious") and (container_id is None or c["container_id"] == container_id)
        ]
        for c in suspicious:
            steps.append(f"Stop suspicious container {c['name']} ({c['container_id']}).")
        steps.append("Preserve volatile evidence (memory, logs) before remediation.")

        return {
            "kind": "containment_recommendation",
            "asset_id": asset_id,
            "is_recommendation": True,  # advisory only β€” nothing here is executed
            "steps": steps,
        }


# Module-level helpers keep the handlers above short and uniform. (They mirror the
# small guards in the Direct-API backend; each backend keeps its own copy so the
# backends stay independent of one another.)


def _require(args: dict[str, Any], key: str) -> Any:
    """Return ``args[key]`` or raise a clear error naming the missing argument."""
    if key not in args or args[key] in (None, ""):
        raise FunctionMissingArgumentError(f"missing required argument {key!r}")
    return args[key]


def _found(record: dict[str, Any] | None, kind: str, record_id: str) -> dict[str, Any]:
    """Return *record* if present, else raise a clear not-found error."""
    if record is None:
        raise FunctionRecordNotFoundError(f"no {kind} with id {record_id!r}")
    return record


def _band(score: float) -> str:
    """Map a 0–10 risk score to a human-readable band (fixed, deterministic cut-offs)."""
    if score >= 9.0:
        return "critical"
    if score >= 7.0:
        return "high"
    if score >= 4.0:
        return "medium"
    return "low"