add value-shape field classification and auto-apply

Field roles are now inferred from observed values instead of names,
with src/dst disambiguation, value-pattern detector hints, an Ollama
advisor scoped to ambiguous fields only, confidence-gated auto-apply
with manual-field protection, and drift flagging for review
This commit is contained in:
larssand
2026-09-07 21:58:25 +02:00
parent 7b94512abc
commit e35445da4f
22 changed files with 1133 additions and 93 deletions

View File

@@ -276,6 +276,39 @@ class ProfileSuggestionTests(unittest.TestCase):
self.assertEqual(advised["profile_advisor"]["field_roles"], {"username": "identity"})
self.assertEqual(advised["profile_advisor"]["correlation_roles"], {"identity": ["username"]})
def test_scoped_field_roles_patch_only_the_named_fields(self):
suggestion = suggest_stream_profiles([
parse_log_line("fgai_stream_id=windows fgai_stream=Windows username=alice hostname=host01 eventid=4625 action=failure")
])[0]
advised = apply_profile_advice([suggestion], [{
"stream_id": "windows",
"field_roles": {"hostname": "entity", "action": "reject"},
"reason": "scoped advisor pass",
}])[0]
self.assertEqual(advised["profile_advisor"]["status"], "ok")
self.assertEqual(advised["profile_advisor"]["fields_reclassified"], 2)
self.assertIn("username", advised["profile"]["entity_fields"])
self.assertIn("hostname", advised["profile"]["entity_fields"])
self.assertNotIn("action", advised["profile"]["categorical_fields"])
def test_scoped_advisor_never_rejects_the_only_entity_field(self):
events = [
parse_log_line(f"fgai_stream_id=custom_vendor fgai_stream=CustomVendor x7_field_a=203.0.113.{index}")
for index in range(1, 25)
]
suggestion = suggest_stream_profiles(events)[0]
self.assertEqual(suggestion["profile"]["entity_fields"], ["x7_field_a"])
advised = apply_profile_advice([suggestion], [{
"stream_id": "custom_vendor",
"field_roles": {"x7_field_a": "reject"},
"reason": "scoped advisor pass",
}])[0]
self.assertIn("x7_field_a", advised["profile"]["entity_fields"])
def test_missing_llm_advice_keeps_heuristic_profile(self):
suggestion = suggest_stream_profiles([
parse_log_line("fgai_stream_id=app fgai_stream=App actor_id=user1 action=login")
@@ -286,6 +319,64 @@ class ProfileSuggestionTests(unittest.TestCase):
self.assertEqual(advised["profile_advisor"]["status"], "heuristic")
self.assertEqual(advised["profile"], suggestion["profile"])
def test_unfamiliar_field_name_selected_as_entity_from_value_shape_alone(self):
events = [
parse_log_line(
f"fgai_stream_id=custom_vendor fgai_stream=CustomVendor x7_field_a=203.0.113.{index} "
f"x7_field_b=some-{index % 5} timestamp=2026-06-29T10:00:{index:02d}Z"
)
for index in range(1, 25)
]
suggestion = suggest_stream_profiles(events)[0]
self.assertIn("x7_field_a", suggestion["profile"]["entity_fields"])
self.assertEqual(suggestion["field_shapes"]["x7_field_a"], "ip")
self.assertGreaterEqual(suggestion["field_confidence"]["x7_field_a"], 60)
def test_ambiguous_ip_fields_disambiguated_by_private_ratio(self):
events = [
parse_log_line(
f"fgai_stream_id=custom_vendor fgai_stream=CustomVendor field_alpha=10.0.0.{index} "
f"field_beta=8.8.8.{index} timestamp=2026-06-29T10:00:{index:02d}Z"
)
for index in range(1, 25)
]
suggestion = suggest_stream_profiles(events)[0]
relationships = suggestion["profile"]["relationship_fields"]
pairs = {(item["left"], item["right"]) for item in relationships}
self.assertIn(("field_alpha", "field_beta"), pairs)
def test_deny_action_detector_suggested_from_value_pattern_alone(self):
events = [
parse_log_line(
f"fgai_stream_id=custom_vendor fgai_stream=CustomVendor whatever_field="
f"{'Denied' if index % 3 else 'Blocked'} src=10.0.0.{index} timestamp=2026-06-29T10:00:{index:02d}Z"
)
for index in range(1, 25)
]
suggestion = suggest_stream_profiles(events)[0]
detectors = suggestion["profile"]["detectors"]
self.assertIn("deny_action", detectors)
self.assertEqual(detectors["deny_action"].get("source"), "value_shape")
def test_every_suggestion_carries_field_confidence_and_shapes(self):
events = [
parse_log_line(f"fgai_stream_id=firewall fgai_stream=Firewall srcip=10.0.0.{index} dstip=8.8.8.8 dstport=443 action=deny timestamp=2026-06-29T10:00:{index:02d}Z hitcount={index}")
for index in range(1, 25)
]
suggestion = suggest_stream_profiles(events)[0]
self.assertIn("field_confidence", suggestion)
self.assertIn("field_shapes", suggestion)
self.assertIn("srcip", suggestion["field_shapes"])
self.assertEqual(suggestion["field_shapes"]["srcip"], "ip")
if __name__ == "__main__":
unittest.main()