Skip to content

Commit f7b5e24

Browse files
committed
✨ feat(pre_execution): add InjectorConfigModel and builder for NetworkOne
1 parent 47259da commit f7b5e24

6 files changed

Lines changed: 377 additions & 259 deletions

File tree

pyoaev/signatures/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
from pyoaev.signatures.models import (
2+
CloudInjectorConfig,
23
ExpectationSignatureGroup,
4+
ExternalInjectorConfig,
5+
InjectorConfig,
6+
NetworkInjectorConfig,
37
SignatureCallbackPayload,
48
SignaturePayload,
59
SignatureTarget,
610
SignatureValue,
711
TargetSignatures,
12+
build_network_configs,
813
)
914
from pyoaev.signatures.signature_manager import SignatureManager
1015
from pyoaev.signatures.types import MatchTypes, SignatureTypes
1116

1217
__all__ = [
18+
"CloudInjectorConfig",
1319
"ExpectationSignatureGroup",
20+
"ExternalInjectorConfig",
21+
"InjectorConfig",
1422
"MatchTypes",
23+
"NetworkInjectorConfig",
1524
"SignatureCallbackPayload",
1625
"SignatureManager",
1726
"SignaturePayload",
1827
"SignatureTarget",
1928
"SignatureTypes",
2029
"SignatureValue",
2130
"TargetSignatures",
31+
"build_network_configs",
2232
]

pyoaev/signatures/models.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Pydantic schemas pinning every shape SignatureManager touches."""
22

3+
import ipaddress
34
from typing import Any
45

56
from pydantic import BaseModel, ConfigDict
@@ -122,6 +123,90 @@ class ToolOutput(BaseModel):
122123
extra_signatures: dict[str, Any] | None = None
123124

124125

126+
class NetworkInjectorConfig(BaseModel):
127+
"""A single network target. Exactly one of ``target_ipv4``, ``target_ipv6``, or ``target_hostname``."""
128+
129+
model_config = ConfigDict(extra="forbid")
130+
131+
target_ipv4: str | None = None
132+
target_ipv6: str | None = None
133+
target_hostname: str | None = None
134+
135+
136+
class CloudInjectorConfig(BaseModel):
137+
"""A single cloud target row. One config per region; fan out by passing a list."""
138+
139+
model_config = ConfigDict(extra="forbid")
140+
141+
cloud_provider: str
142+
cloud_account_id: str
143+
cloud_region: str
144+
target_service: str | None = None
145+
146+
147+
class ExternalInjectorConfig(BaseModel):
148+
"""A single external scan target (e.g. Shodan): a query against an asset."""
149+
150+
model_config = ConfigDict(extra="forbid")
151+
152+
query: str
153+
target_ipv4: str | None = None
154+
target_hostname: str | None = None
155+
156+
157+
InjectorConfig = NetworkInjectorConfig | CloudInjectorConfig | ExternalInjectorConfig
158+
159+
160+
# ---------------------------------------------------------------------------
161+
# Builders. Cheap helpers to turn raw injector input into typed configs.
162+
# ---------------------------------------------------------------------------
163+
164+
165+
def _classify_network_target(value: str) -> NetworkInjectorConfig:
166+
"""Decide whether ``value`` is an IPv4, IPv6, or hostname and wrap it."""
167+
try:
168+
addr = ipaddress.ip_address(value)
169+
except ValueError:
170+
return NetworkInjectorConfig(target_hostname=value)
171+
if isinstance(addr, ipaddress.IPv4Address):
172+
return NetworkInjectorConfig(target_ipv4=value)
173+
return NetworkInjectorConfig(target_ipv6=value)
174+
175+
176+
def build_network_configs(
177+
targets: list[str | dict[str, Any] | NetworkInjectorConfig],
178+
) -> list[NetworkInjectorConfig]:
179+
"""Forge a list of `NetworkInjectorConfig` from a heterogeneous target list.
180+
181+
Each item is one distinct asset. Accepted shapes:
182+
183+
- `NetworkInjectorConfig`: passed through unchanged.
184+
- `dict`: validated against :class:`NetworkInjectorConfig`.
185+
- `str`: auto-classified into IPv4 / IPv6 / hostname.
186+
187+
Args:
188+
targets: Raw target list straight out of the injector.
189+
190+
Returns:
191+
One `NetworkInjectorConfig` per input target, order preserved.
192+
193+
Raises:
194+
TypeError: An item is not one of the accepted shapes.
195+
ValidationError: A dict item fails the one-identity invariant.
196+
"""
197+
configs: list[NetworkInjectorConfig] = []
198+
for target in targets:
199+
if isinstance(target, NetworkInjectorConfig):
200+
configs.append(target)
201+
elif isinstance(target, dict):
202+
configs.append(NetworkInjectorConfig(**target))
203+
elif isinstance(target, str):
204+
configs.append(_classify_network_target(target))
205+
else:
206+
raise TypeError(f"unsupported network target type: {type(target).__name__}")
207+
return configs
208+
209+
125210
__all__ = [
126211
"SignatureValue",
127212
"ExpectationSignatureGroup",
@@ -134,4 +219,9 @@ class ToolOutput(BaseModel):
134219
"ToolErrorInfo",
135220
"ToolTimeoutInfo",
136221
"ToolOutput",
222+
"NetworkInjectorConfig",
223+
"CloudInjectorConfig",
224+
"ExternalInjectorConfig",
225+
"InjectorConfig",
226+
"build_network_configs",
137227
]

pyoaev/signatures/signature_manager.py

Lines changed: 53 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
import socket
66
import subprocess
77
from datetime import datetime, timezone
8-
from typing import TYPE_CHECKING, Any, Literal
8+
from typing import TYPE_CHECKING, Any
99

1010
from pydantic import ValidationError
1111

1212
from pyoaev.exceptions import OpenAEVError
1313
from pyoaev.signatures.models import (
14+
CloudInjectorConfig,
1415
ExpectationSignatureGroup,
16+
ExternalInjectorConfig,
17+
InjectorConfig,
18+
NetworkInjectorConfig,
1519
PostExecutionSignature,
1620
PreExecutionSignature,
1721
SignaturePayload,
@@ -48,120 +52,72 @@ def _utcnow(self) -> datetime:
4852

4953
def compile_pre_execution_signatures(
5054
self,
51-
inject_config: dict[str, Any],
52-
category: Literal["network", "cloud", "external"],
55+
config: InjectorConfig | list[InjectorConfig],
5356
) -> dict[str, Any] | list[dict[str, Any]]:
54-
"""Build pre-execution signature dicts off the inject config and category.
57+
"""Build pre-execution signature dicts from one or more typed injector configs.
58+
59+
The category is carried by the config type itself
60+
(:class:`NetworkInjectorConfig`, :class:`CloudInjectorConfig`,
61+
:class:`ExternalInjectorConfig`), so no separate ``category`` flag is needed.
5562
5663
Args:
57-
inject_config: The inject payload dict.
58-
category: One of 'network', 'cloud', 'external'.
64+
config: A single injector config or a homogeneous list of them.
65+
Multi-target injects must be expressed as a list.
5966
6067
Returns:
61-
One dict for single-target, list of dicts for multi-target configs.
68+
One dict when a single config is given, otherwise a list of dicts in
69+
input order.
6270
6371
Raises:
64-
ValueError: Unknown category or required fields missing.
72+
ValueError: Empty list, or mixed config types in a single call.
73+
TypeError: Unknown injector config type.
6574
"""
66-
now = self._utcnow()
67-
start_time = now.strftime("%Y-%m-%dT%H:%M:%SZ")
68-
69-
if category == "network":
70-
return self._compile_network_pre(inject_config, start_time)
71-
elif category == "cloud":
72-
return self._compile_cloud_pre(inject_config, start_time)
73-
elif category == "external":
74-
return self._compile_external_pre(inject_config, start_time)
75-
else:
76-
raise ValueError(f"Unknown category: {category!r}")
77-
78-
def _compile_network_pre(
79-
self, config: dict[str, Any], start_time: str
80-
) -> dict[str, Any] | list[dict[str, Any]]:
81-
ipv4 = self.resolve_container_ip()
82-
ipv6 = self._cached_ipv6
83-
84-
assets = config.get("target_assets") or config.get("assets") or []
85-
if not assets:
86-
asset = config.get("asset")
87-
if asset:
88-
assets = [asset]
89-
90-
if not assets:
75+
configs = list(config) if isinstance(config, list) else [config]
76+
if not configs:
9177
raise ValueError(
92-
"inject_config must contain 'target_assets', 'assets', or 'asset'"
78+
"compile_pre_execution_signatures requires at least one config"
9379
)
9480

95-
results: list[dict[str, Any]] = []
96-
for asset in assets:
97-
sig = PreExecutionSignature(
98-
start_time=start_time,
99-
source_ipv4=ipv4,
100-
source_ipv6=ipv6,
101-
target_ipv4=asset["target_ipv4"],
102-
target_ipv6=asset.get("target_ipv6"),
103-
target_hostname=asset.get("target_hostname"),
81+
first_type = type(configs[0])
82+
if any(type(c) is not first_type for c in configs):
83+
raise ValueError(
84+
"compile_pre_execution_signatures does not mix injector config types; "
85+
f"got {sorted({type(c).__name__ for c in configs})}"
10486
)
105-
results.append(sig.model_dump(mode="json", exclude_none=True))
10687

88+
start_time = self._utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
89+
results = [self._compile_one(cfg, start_time) for cfg in configs]
10790
return results[0] if len(results) == 1 else results
10891

109-
def _compile_cloud_pre(
110-
self, config: dict[str, Any], start_time: str
111-
) -> dict[str, Any] | list[dict[str, Any]]:
112-
cloud_provider = config["cloud_provider"]
113-
cloud_account_id = config["cloud_account_id"]
114-
target_service = config.get("target_service")
115-
116-
regions = config.get("regions") or []
117-
if not regions:
118-
region = config.get("cloud_region")
119-
if region:
120-
regions = [region]
121-
122-
if not regions:
123-
raise ValueError("inject_config must contain 'regions' or 'cloud_region'")
124-
125-
results: list[dict[str, Any]] = []
126-
for region in regions:
127-
sig = PreExecutionSignature(
128-
start_time=start_time,
129-
cloud_provider=cloud_provider,
130-
cloud_account_id=cloud_account_id,
131-
cloud_region=region,
132-
target_service=target_service,
133-
)
134-
results.append(sig.model_dump(mode="json", exclude_none=True))
92+
def _compile_one(self, config: InjectorConfig, start_time: str) -> dict[str, Any]:
93+
"""Project a single injector config into a flat pre-execution signature dict.
13594
136-
return results[0] if len(results) == 1 else results
137-
138-
def _compile_external_pre(
139-
self, config: dict[str, Any], start_time: str
140-
) -> dict[str, Any] | list[dict[str, Any]]:
141-
targets = config.get("targets") or []
142-
if not targets:
143-
query = config.get("query")
144-
if query is None:
145-
raise ValueError("inject_config must contain 'query'")
146-
targets = [
147-
{
148-
"target_ipv4": config["target_ipv4"],
149-
"target_hostname": config.get("target_hostname"),
150-
"query": query,
151-
}
152-
]
95+
Common pipeline for every category:
96+
1. Seed the base dict with ``start_time`` and category-specific context
97+
(network gets resolved source IPs; cloud/external add nothing).
98+
2. Layer the config's own fields on top.
99+
3. Run it through :class:`PreExecutionSignature` for validation
100+
and emit JSON-ready output stripped of ``None``\\ s.
101+
"""
102+
base: dict[str, Any] = {"start_time": start_time}
103+
base.update(self._source_context(config))
104+
base.update(config.model_dump(exclude_none=True))
105+
return PreExecutionSignature(**base).model_dump(mode="json", exclude_none=True)
153106

154-
results: list[dict[str, Any]] = []
155-
for target in targets:
156-
sig = PreExecutionSignature(
157-
start_time=start_time,
158-
target_ipv4=target["target_ipv4"],
159-
target_hostname=target.get("target_hostname"),
160-
query=target.get("query"),
161-
)
162-
results.append(sig.model_dump(mode="json", exclude_none=True))
107+
def _source_context(self, config: InjectorConfig) -> dict[str, Any]:
108+
"""Return the source identity bits injected for the config's category.
163109
164-
return results[0] if len(results) == 1 else results
110+
Only network signatures need the running container's source IPs;
111+
cloud and external rows have no source identity to carry.
112+
"""
113+
if isinstance(config, NetworkInjectorConfig):
114+
return {
115+
"source_ipv4": self.resolve_container_ip(),
116+
"source_ipv6": self._cached_ipv6,
117+
}
118+
if isinstance(config, (CloudInjectorConfig, ExternalInjectorConfig)):
119+
return {}
120+
raise TypeError(f"unsupported injector config type: {type(config).__name__}")
165121

166122
def compile_post_execution_signatures(
167123
self,

test/signatures/constraints/signature_manager_pre_execution_constraints.feature

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ Feature: SignatureManager pre-execution constraints
33
I want pre-execution compilation to handle timing edge cases correctly
44
So that signatures always reflect the actual moment of execution
55

6-
Background:
7-
Given a SignatureManager initialised with constructor SignatureManager(client, logger)
6+
Background:
7+
Given a SignatureManager initialised with constructor SignatureManager(client, logger)
88

9-
Scenario: start_time is generated at method-call time not at class instantiation time
10-
Given a SignatureManager that was instantiated at timestamp T0
11-
And 5 seconds elapse after instantiation
12-
And an inject_config with a single network target
13-
When I call compile_pre_execution_signatures with category="network" at timestamp T1
14-
Then the start_time in the returned dict equals T1 within 1 second tolerance
15-
And start_time does not equal T0
9+
Scenario: start_time is generated at method-call time not at class instantiation time
10+
Given a SignatureManager that was instantiated at timestamp T0
11+
And 5 seconds elapse after instantiation
12+
And a NetworkInjectorConfig with target_ipv4="192.168.1.10"
13+
When I call compile_pre_execution_signatures with the config at timestamp T1
14+
Then the start_time in the returned dict equals T1 within 1 second tolerance
15+
And start_time does not equal T0

0 commit comments

Comments
 (0)