Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions deploy/examples/hydrator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ signing: # optional; signs each
agents:
- agent_id: 7a4b2e91-83f6-4c12-9d58-bf1e6a3c9d07
ans_name: ans://v1.0.0.payments.example.com
sandbox_names: [payments] # container.name in the OCSF record
sandbox_uids: [] # container.uid, the durable association
sandbox_names: [payments] # device.container.name (container.name up to OpenShell v0.1.7)
sandbox_uids: [] # device.container.uid, the durable association
9 changes: 5 additions & 4 deletions internal/hydrator/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,13 +439,14 @@ func (s *activityStats) policyChanged(policies []PolicyContext) bool {
}

func activitySandbox(ev Event) string {
if ev.Container == nil {
sandbox := ev.Sandbox()
if sandbox == nil {
return ""
}
if ev.Container.UID != "" {
return ev.Container.UID
if sandbox.UID != "" {
return sandbox.UID
}
return ev.Container.Name
return sandbox.Name
}

func boundedActivityText(s string) string {
Expand Down
42 changes: 42 additions & 0 deletions internal/hydrator/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"testing"
Expand Down Expand Up @@ -228,6 +229,47 @@ func TestActivityPolicyBeforeWindow(t *testing.T) {
}
}

func TestActivityDeviceContainerShape(t *testing.T) {
t.Parallel()
window := Window{Start: time.UnixMilli(sampleBaseMs).UTC(), End: time.UnixMilli(sampleBaseMs + 60_000).UTC()}
policy := func(uid string, offsetMs int64) string {
return jsonLine(t, map[string]any{"class_uid": ClassDeviceConfigChange, "activity_id": 1, "time": sampleBaseMs + offsetMs, "container": map[string]any{"uid": sandboxAUID}, "metadata": map[string]any{"uid": uid}, "unmapped": map[string]any{"policy_hash": "hash-" + uid, "version": 1}})
}
original := strings.Join([]string{
policy("p1", -1000),
activityInput(t, "a1", "L7_REQUEST allow GET example.com:443/test reason=rule", 4002, 99, 1),
activityInput(t, "a2", "L7_REQUEST deny POST example.com:443/test reason=rule", 4002, 99, 2),
policy("p2", 1000),
}, "\n")
run := func(t *testing.T, input string) (Report, string) {
t.Helper()
opts := activityOptions(t)
opts.Window = &window
var evidence bytes.Buffer
opts.Evidence = &evidence
report, err := Run(t.Context(), strings.NewReader(input), &fakeSink{}, opts, nil)
if err != nil {
t.Fatal(err)
}
return report, evidence.String()
}
wantReport, wantEvidence := run(t, original)
if len(wantReport.Activity) != 1 || len(wantReport.Activity[0].Policies) != 2 || wantReport.OutOfWindow != 1 {
t.Fatalf("baseline activity=%+v outOfWindow=%d, want one agent with both policies", wantReport.Activity, wantReport.OutOfWindow)
}
input := moveSandboxToDevice(t, original, everyLine)
if got := strings.Count(input, `"device":{"container"`); got != 4 {
t.Fatalf("moved %d records, want 4", got)
}
report, evidence := run(t, input)
if !reflect.DeepEqual(report, wantReport) {
t.Fatalf("Report = %+v\nwant %+v", report, wantReport)
}
if evidence != wantEvidence {
t.Fatalf("evidence differs:\n%s\nwant\n%s", evidence, wantEvidence)
}
}

func TestActivityFailureStatusKeepsLegacyBehavior(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
Expand Down
15 changes: 8 additions & 7 deletions internal/hydrator/agentmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,20 @@ func NewAgentMap(mappings []AgentMapping) (AgentMap, error) {
return m, nil
}

// Lookup resolves the event's sandbox to an agent, by container.uid first and
// container.name second. Events without a container never resolve.
// Lookup resolves the event's sandbox (see Event.Sandbox) to an agent, by uid
// first and name second. Events without a sandbox never resolve.
func (m AgentMap) Lookup(ev Event) (AgentMapping, bool) {
if ev.Container == nil {
sandbox := ev.Sandbox()
if sandbox == nil {
return AgentMapping{}, false
}
if ev.Container.UID != "" {
if mapping, ok := m.byUID[ev.Container.UID]; ok {
if sandbox.UID != "" {
if mapping, ok := m.byUID[sandbox.UID]; ok {
return mapping, true
}
}
if ev.Container.Name != "" {
if mapping, ok := m.byName[ev.Container.Name]; ok {
if sandbox.Name != "" {
if mapping, ok := m.byName[sandbox.Name]; ok {
return mapping, true
}
}
Expand Down
8 changes: 7 additions & 1 deletion internal/hydrator/agentmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,14 @@ func TestAgentMapLookup(t *testing.T) {
name string
m AgentMap
container *Container
device *Device
wantID string
}{
{name: "by device container uid", m: m, device: &Device{Container: &Container{UID: "u-a"}}, wantID: "a"},
{name: "by device container name", m: m, device: &Device{Container: &Container{Name: "n-b"}}, wantID: "b"},
{name: "device container wins over top-level", m: m, device: &Device{Container: &Container{UID: "u-a"}}, container: &Container{Name: "n-b"}, wantID: "a"},
{name: "unknown device container", m: m, device: &Device{Container: &Container{UID: "x"}}, container: &Container{Name: "n-b"}},
{name: "device without container", m: m, device: &Device{}, container: &Container{Name: "n-b"}, wantID: "b"},
{name: "by uid", m: m, container: &Container{UID: "u-a", Name: "renamed"}, wantID: "a"},
{name: "by name when uid unknown", m: m, container: &Container{UID: "other", Name: "n-a"}, wantID: "a"},
{name: "uid wins over another agent's name", m: m, container: &Container{UID: "u-a", Name: "n-b"}, wantID: "a"},
Expand All @@ -87,7 +93,7 @@ func TestAgentMapLookup(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, ok := tt.m.Lookup(Event{Container: tt.container})
got, ok := tt.m.Lookup(Event{Device: tt.device, Container: tt.container})
if ok != (tt.wantID != "") {
t.Fatalf("Lookup() ok = %v, want %v", ok, tt.wantID != "")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/hydrator/aggregate.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func (a *attributor) add(ev Event) {
mapping, ok := a.agents.Lookup(ev)
if !ok {
a.unmapped++
if ev.Container == nil {
if ev.Sandbox() == nil {
a.noContainer++
}
return
Expand Down
9 changes: 5 additions & 4 deletions internal/hydrator/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
// MaxLineBytes, unknown fields are ignored, missing fields are tolerated and a
// malformed line is counted and skipped, never fatal.
//
// Events are attributed to agents through their sandbox (container.uid, then
// container.name). Records that OpenShell downgraded to OCSF 1.1 or 1.3 have
// the container stripped and therefore cannot be attributed; they are counted
// as unmapped.
// Events are attributed to agents through their sandbox (uid, then name),
// read from device.container and, for OpenShell v0.1.7 and earlier, from the
// top-level container. Records without either, such as those an earlier
// release downgraded to OCSF 1.1 or 1.3, cannot be attributed; they are
// counted as unmapped.
//
// Per agent and input window the package aggregates Network Activity (4001)
// and HTTP Activity (4002) events into two observations. An event counts as
Expand Down
23 changes: 22 additions & 1 deletion internal/hydrator/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type Event struct {
Message string `json:"message"`
Time int64 `json:"time"`
Metadata Metadata `json:"metadata"`
Device *Device `json:"device"`
Container *Container `json:"container"`
DstEndpoint *Endpoint `json:"dst_endpoint"`
Actor *Actor `json:"actor"`
Expand All @@ -73,8 +74,16 @@ type Product struct {
Version string `json:"version"`
}

// Device is the OCSF device object. Only its container is read: the device's
// own uid, name and hostname describe where the emitter runs, never which
// sandbox an event belongs to.
type Device struct {
Container *Container `json:"container"`
}

// Container is the sandbox the event belongs to. OpenShell omits it for
// events without a sandbox association and strips it on schema downgrade.
// events without a sandbox association; releases before v0.1.7 also stripped
// it on schema downgrade.
type Container struct {
UID string `json:"uid"`
Name string `json:"name"`
Expand Down Expand Up @@ -113,6 +122,18 @@ func (e Event) Timestamp() (time.Time, bool) {
return time.UnixMilli(e.Time).UTC(), true
}

// Sandbox returns the sandbox the event belongs to, or nil when it has none.
// OpenShell releases after v0.1.7 carry it in device.container and earlier
// releases in the top-level container. A device.container with neither uid
// nor name is treated as absent so it cannot hide a usable top-level one.
func (e Event) Sandbox() *Container {
Comment thread
chou-godaddy marked this conversation as resolved.
Resolved
if e.Device != nil && e.Device.Container != nil &&
(e.Device.Container.UID != "" || e.Device.Container.Name != "") {
return e.Device.Container
}
return e.Container
}

// Denied reports whether the event was denied, blocked or failed: any of
// action_id, disposition_id or status_id equals 2.
func (e Event) Denied() bool {
Expand Down
39 changes: 39 additions & 0 deletions internal/hydrator/event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,45 @@ func TestEventDenied(t *testing.T) {
}
}

func TestEventSandbox(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record string
wantUID string
wantName string
}{
{name: "no sandbox", record: `{"class_uid":4001}`},
{name: "top-level container", record: `{"container":{"uid":"top","name":"top-name"}}`, wantUID: "top", wantName: "top-name"},
{name: "device container", record: `{"device":{"hostname":"h","container":{"uid":"dev","name":"dev-name"}}}`, wantUID: "dev", wantName: "dev-name"},
{name: "device container name only", record: `{"device":{"container":{"name":"dev-name"}}}`, wantName: "dev-name"},
{name: "device container wins", record: `{"device":{"container":{"uid":"dev"}},"container":{"uid":"top"}}`, wantUID: "dev"},
{name: "device without container falls back", record: `{"device":{"hostname":"h","type_id":99},"container":{"uid":"top"}}`, wantUID: "top"},
{name: "empty device container falls back", record: `{"device":{"container":{}},"container":{"uid":"top"}}`, wantUID: "top"},
{name: "empty device container alone", record: `{"device":{"container":{}}}`},
{name: "device identity is not the sandbox", record: `{"device":{"uid":"gateway-a","name":"gateway-a","hostname":"h"}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ev, ok := decodeLine([]byte(tt.record))
if !ok {
t.Fatalf("decodeLine(%s) failed", tt.record)
}
got := ev.Sandbox()
if tt.wantUID == "" && tt.wantName == "" {
if got != nil {
t.Fatalf("Sandbox() = %+v, want nil", got)
}
return
}
if got == nil || got.UID != tt.wantUID || got.Name != tt.wantName {
t.Fatalf("Sandbox() = %+v, want uid %q name %q", got, tt.wantUID, tt.wantName)
}
})
}
}

func TestEventDestination(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
30 changes: 30 additions & 0 deletions internal/hydrator/fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,36 @@ func jsonLine(t *testing.T, fields map[string]any) string {
return string(b)
}

// moveSandboxToDevice rewrites the records on lines selected by move to carry
// their sandbox in device.container, the shape OpenShell emits after v0.1.7.
// Lines without a JSON object or without a container are left untouched.
func moveSandboxToDevice(t *testing.T, input string, move func(line string) bool) string {
t.Helper()
lines := strings.Split(input, "\n")
for i, line := range lines {
start := strings.IndexByte(line, '{')
if start < 0 || !move(line) {
continue
}
fields := exampleFields(t, line[start:])
container, ok := fields["container"]
if !ok {
continue
}
delete(fields, "container")
device, _ := fields["device"].(map[string]any)
if device == nil {
device = map[string]any{}
}
device["container"] = container
fields["device"] = device
lines[i] = line[:start] + jsonLine(t, fields)
}
return strings.Join(lines, "\n")
}

func everyLine(string) bool { return true }

func exampleFields(t *testing.T, example string) map[string]any {
t.Helper()
var fields map[string]any
Expand Down
2 changes: 1 addition & 1 deletion internal/hydrator/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ type Report struct {
// inside the window, whatever their class.
Attributed int
// Unmapped counts events that resolved to no agent; NoContainer is the
// subset that carried no container at all, as downgraded records do.
// subset that carried no sandbox at all (see Event.Sandbox).
Unmapped int
NoContainer int
// OutOfWindow counts events excluded by an explicit Options.Window.
Expand Down
Loading
Loading