Skip to content

Commit a917eb5

Browse files
committed
🎨 feat(signatures): wire SignatureManager and API into client
1 parent 8510db8 commit a917eb5

6 files changed

Lines changed: 89 additions & 27 deletions

File tree

‎pyoaev/apis/signature.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def send_signatures(
9292
if len(serialized) <= self._max_payload_size:
9393
self._send_with_retry(inject_id, payload)
9494
else:
95-
self._send_chunked(inject_id, payload["signatures"], phase=phase)
95+
self._send_chunked(inject_id, payload["expectation_signature"], phase=phase)
9696

9797
def _build_callback_payload(
9898
self,
@@ -119,7 +119,7 @@ def _build_callback_payload(
119119
try:
120120
envelope = SignatureCallbackPayload.model_validate(
121121
{
122-
"signatures": signatures,
122+
"expectation_signature": signatures,
123123
"phase": phase,
124124
"chunk_index": chunk_index,
125125
"total_chunks": total_chunks,
@@ -216,7 +216,7 @@ def _send_chunked(
216216
candidate = current_chunk + [target]
217217
size = len(
218218
json.dumps(
219-
{"signatures": {"targets": candidate}},
219+
{"expectation_signature": {"targets": candidate}},
220220
separators=(",", ":"),
221221
).encode()
222222
)
@@ -237,7 +237,7 @@ def _send_chunked(
237237
current_chunk = [target]
238238
solo_size = len(
239239
json.dumps(
240-
{"signatures": {"targets": [target]}},
240+
{"expectation_signature": {"targets": [target]}},
241241
separators=(",", ":"),
242242
).encode()
243243
)
@@ -280,7 +280,9 @@ def callback(
280280
result = self.openaev.http_post(path, post_data=data, **kwargs)
281281
return result
282282

283-
def _send_with_retry(self, inject_id: str, payload: dict[str, Any]) -> dict[str, Any]:
283+
def _send_with_retry(
284+
self, inject_id: str, payload: dict[str, Any]
285+
) -> dict[str, Any]:
284286
"""Retry callback() with exponential backoff on 5xx, immediate raise on 4xx.
285287
286288
Args:

‎pyoaev/client.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __init__(
7575
self.payload = apis.PayloadManager(self)
7676
self.security_platform = apis.SecurityPlatformManager(self)
7777
self.inject_expectation_trace = apis.InjectExpectationTraceManager(self)
78+
self.signature = apis.SignatureApiManager(self)
7879
self.tag = apis.TagManager(self)
7980

8081
@staticmethod
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pyoaev.signatures.models import (
2+
ExpectationSignatureGroup,
3+
SignatureCallbackPayload,
4+
SignaturePayload,
5+
SignatureTarget,
6+
SignatureValue,
7+
TargetSignatures,
8+
)
9+
from pyoaev.signatures.signature_manager import SignatureManager
10+
from pyoaev.signatures.types import MatchTypes, SignatureTypes
11+
12+
__all__ = [
13+
"ExpectationSignatureGroup",
14+
"MatchTypes",
15+
"SignatureCallbackPayload",
16+
"SignatureManager",
17+
"SignaturePayload",
18+
"SignatureTarget",
19+
"SignatureTypes",
20+
"SignatureValue",
21+
"TargetSignatures",
22+
]

‎pyoaev/signatures/models.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class SignatureCallbackPayload(BaseModel):
5555

5656
model_config = ConfigDict(populate_by_name=True, extra="forbid")
5757

58-
signatures: SignaturePayload
58+
expectation_signature: SignaturePayload
5959
phase: str | None = None
6060
chunk_index: int | None = None
6161
total_chunks: int | None = None

‎pyoaev/signatures/signature_manager.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@
1111

1212
from pyoaev.exceptions import OpenAEVError
1313
from pyoaev.signatures.models import (
14+
ExpectationSignatureGroup,
1415
PostExecutionSignature,
1516
PreExecutionSignature,
17+
SignaturePayload,
18+
SignatureTarget,
19+
SignatureValue,
20+
TargetSignatures,
1621
ToolOutput,
1722
)
1823

@@ -211,6 +216,49 @@ def _merge_post(
211216

212217
return merged
213218

219+
def build_payload(
220+
self,
221+
post_signatures: dict[str, Any] | list[dict[str, Any]],
222+
targets_meta: dict[str, str] | list[dict[str, str]],
223+
expectation_type: str = "DETECTION",
224+
) -> dict[str, Any]:
225+
"""Build the nested wire payload from flat post-execution signatures.
226+
227+
Bridges the gap between compile_post_execution_signatures output (flat dicts)
228+
and send_signatures input (nested wire format).
229+
230+
Args:
231+
post_signatures: A single post-execution dict or a list (multi-target).
232+
targets_meta: Target metadata dict(s) with keys like agent, asset, asset_group.
233+
expectation_type: The expectation type label (e.g. 'DETECTION', 'PREVENTION').
234+
235+
Returns:
236+
A payload dict ready for send_signatures.
237+
"""
238+
if isinstance(post_signatures, dict):
239+
post_signatures = [post_signatures]
240+
if isinstance(targets_meta, dict):
241+
targets_meta = [targets_meta] * len(post_signatures)
242+
243+
targets = []
244+
for sig, meta in zip(post_signatures, targets_meta):
245+
values = [
246+
SignatureValue(signature_type=k, signature_value=str(v))
247+
for k, v in sig.items()
248+
]
249+
targets.append(
250+
TargetSignatures(
251+
signature_target=SignatureTarget(**meta),
252+
signature_values=[
253+
ExpectationSignatureGroup(
254+
expectation_type=expectation_type, values=values
255+
)
256+
],
257+
)
258+
)
259+
260+
return SignaturePayload(targets=targets).model_dump()
261+
214262
def send_signatures(
215263
self,
216264
inject_id: str,

‎test/signatures/test_signature_manager_transmission.py‎

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,6 @@ def compiled_payload_single_target(
208208
"a compiled payload whose serialised size exceeds MAX_PAYLOAD_SIZE by at least a factor of 2"
209209
)
210210
def compiled_large_payload(context):
211-
# Each target serialises to ~450 bytes with the canonical signature_target
212-
# shape (`agent`/`asset`/`asset_group`) and the 140-char hostname below.
213-
# Budget chosen so a single target fits but two don't, producing 6 chunks
214-
# of one target each. Total payload (~2.7 KiB) exceeds max_payload_size
215-
# (700 B) by ~4x, satisfying the feature's "at least factor of 2" wording.
216211
context["signature_manager"] = SignatureManager(
217212
context["mock_client"],
218213
logger=context["logger"],
@@ -321,8 +316,6 @@ def compiled_payload_grouped_by_expectation(
321316
expectation_a,
322317
expectation_b,
323318
):
324-
# Feed FLAT form (mixed expectation_types in a single signature_values list).
325-
# SignatureManager must regroup it into the canonical wire schema.
326319
context["signatures"] = {
327320
"targets": [
328321
{
@@ -395,7 +388,7 @@ def assert_post_request_sent_to_callback(context, inject_id):
395388
@then("the POST request body contains signatures.targets as a list")
396389
def assert_targets_is_list(context):
397390
body = context["captured_calls"][-1]["post_data"]
398-
assert isinstance(body["signatures"]["targets"], list)
391+
assert isinstance(body["expectation_signature"]["targets"], list)
399392

400393

401394
@then(
@@ -405,7 +398,7 @@ def assert_targets_is_list(context):
405398
)
406399
def assert_expectation_type(context, expected_value):
407400
body = context["captured_calls"][-1]["post_data"]
408-
assert body["signatures"]["targets"][0]["signature_values"][0][
401+
assert body["expectation_signature"]["targets"][0]["signature_values"][0][
409402
"expectation_type"
410403
] == (expected_value)
411404

@@ -418,7 +411,7 @@ def assert_expectation_type(context, expected_value):
418411
def assert_signature_type(context, expected_value):
419412
body = context["captured_calls"][-1]["post_data"]
420413
assert (
421-
body["signatures"]["targets"][0]["signature_values"][0]["values"][0][
414+
body["expectation_signature"]["targets"][0]["signature_values"][0]["values"][0][
422415
"signature_type"
423416
]
424417
== expected_value
@@ -433,7 +426,7 @@ def assert_signature_type(context, expected_value):
433426
def assert_signature_value(context, expected_value):
434427
body = context["captured_calls"][-1]["post_data"]
435428
assert (
436-
body["signatures"]["targets"][0]["signature_values"][0]["values"][0][
429+
body["expectation_signature"]["targets"][0]["signature_values"][0]["values"][0][
437430
"signature_value"
438431
]
439432
== expected_value
@@ -443,7 +436,7 @@ def assert_signature_value(context, expected_value):
443436
@then("signatures.targets[0] contains a signature_target key")
444437
def assert_signature_target_key(context):
445438
body = context["captured_calls"][-1]["post_data"]
446-
assert "signature_target" in body["signatures"]["targets"][0]
439+
assert "signature_target" in body["expectation_signature"]["targets"][0]
447440

448441

449442
@then(
@@ -484,7 +477,7 @@ def assert_total_chunks_present(context):
484477
'each POST request body contains only "signatures", "chunk_index" and "total_chunks" at the top level'
485478
)
486479
def assert_chunked_envelope_is_strict(context):
487-
expected_keys = {"signatures", "chunk_index", "total_chunks", "phase"}
480+
expected_keys = {"expectation_signature", "chunk_index", "total_chunks", "phase"}
488481
for call_item in context["captured_calls"]:
489482
post_data = call_item["post_data"]
490483
assert set(post_data.keys()) == expected_keys, (
@@ -499,13 +492,12 @@ def assert_targets_union_matches_original(context):
499492
sent_targets = [
500493
target
501494
for call_item in context["captured_calls"]
502-
for target in call_item["post_data"]["signatures"]["targets"]
495+
for target in call_item["post_data"]["expectation_signature"]["targets"]
503496
]
504497
assert len(sent_targets) == len(original_targets), (
505498
f"Expected {len(original_targets)} targets across all chunks, "
506499
f"got {len(sent_targets)}"
507500
)
508-
# signature_target identifiers must match one-to-one (order-preserved).
509501
for original, sent in zip(original_targets, sent_targets):
510502
assert sent["signature_target"] == original["signature_target"]
511503

@@ -622,7 +614,7 @@ def assert_no_exception_from_resolve_container_ip(context):
622614
)
623615
def assert_signature_values_nested_by_expectation_type(context):
624616
body = context["captured_calls"][-1]["post_data"]
625-
entries = body["signatures"]["targets"][0]["signature_values"]
617+
entries = body["expectation_signature"]["targets"][0]["signature_values"]
626618
expectation_types = {entry["expectation_type"] for entry in entries}
627619
assert expectation_types == {"DETECTION", "PREVENTION"}
628620

@@ -632,14 +624,12 @@ def assert_signature_values_nested_by_expectation_type(context):
632624
)
633625
def assert_detection_values_grouped_correctly(context):
634626
body = context["captured_calls"][-1]["post_data"]
635-
entries = body["signatures"]["targets"][0]["signature_values"]
627+
entries = body["expectation_signature"]["targets"][0]["signature_values"]
636628
detection_entry = next(
637629
entry for entry in entries if entry["expectation_type"] == "DETECTION"
638630
)
639-
# Two DETECTION items were fed in flat form; both must be grouped here.
640631
detection_values = {value["signature_value"] for value in detection_entry["values"]}
641632
assert detection_values == {"203.0.113.5", "host-a.internal"}
642-
# No PREVENTION value should have leaked into the DETECTION group.
643633
assert "198.51.100.10" not in detection_values
644634

645635

@@ -648,14 +638,13 @@ def assert_detection_values_grouped_correctly(context):
648638
)
649639
def assert_prevention_values_grouped_correctly(context):
650640
body = context["captured_calls"][-1]["post_data"]
651-
entries = body["signatures"]["targets"][0]["signature_values"]
641+
entries = body["expectation_signature"]["targets"][0]["signature_values"]
652642
prevention_entry = next(
653643
entry for entry in entries if entry["expectation_type"] == "PREVENTION"
654644
)
655645
prevention_values = {
656646
value["signature_value"] for value in prevention_entry["values"]
657647
}
658648
assert prevention_values == {"198.51.100.10"}
659-
# No DETECTION value should have leaked into the PREVENTION group.
660649
assert "203.0.113.5" not in prevention_values
661650
assert "host-a.internal" not in prevention_values

0 commit comments

Comments
 (0)