diff --git a/cmd/api/src/services/graphify/convertors.go b/cmd/api/src/services/graphify/convertors.go index 1acab6af23a..9ceb3a406e4 100644 --- a/cmd/api/src/services/graphify/convertors.go +++ b/cmd/api/src/services/graphify/convertors.go @@ -65,14 +65,16 @@ func ConvertGenericNode(entity ein.GenericNode, converted *ConvertedData) error func ConvertGenericEdge(entity ein.GenericEdge, converted *ConvertedData) error { ingestibleRel := ein.NewIngestibleRelationship( ein.IngestibleEndpoint{ - Value: strings.ToUpper(entity.Start.Value), - MatchBy: ein.IngestMatchStrategy(entity.Start.MatchBy), - Kind: graph.StringKind(entity.Start.Kind), + Property: entity.Start.Property, + Value: strings.ToUpper(entity.Start.Value), + MatchBy: ein.IngestMatchStrategy(entity.Start.MatchBy), + Kind: graph.StringKind(entity.Start.Kind), }, ein.IngestibleEndpoint{ - Value: strings.ToUpper(entity.End.Value), - MatchBy: ein.IngestMatchStrategy(entity.End.MatchBy), - Kind: graph.StringKind(entity.End.Kind), + Property: entity.End.Property, + Value: strings.ToUpper(entity.End.Value), + MatchBy: ein.IngestMatchStrategy(entity.End.MatchBy), + Kind: graph.StringKind(entity.End.Kind), }, ein.IngestibleRel{ RelProps: entity.Properties, diff --git a/cmd/api/src/services/graphify/ingestrelationships.go b/cmd/api/src/services/graphify/ingestrelationships.go index 8b04e338603..a89e2818785 100644 --- a/cmd/api/src/services/graphify/ingestrelationships.go +++ b/cmd/api/src/services/graphify/ingestrelationships.go @@ -27,7 +27,6 @@ import ( "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/bloodhound/packages/go/graphschema/common" "github.com/specterops/dawgs/graph" - "github.com/specterops/dawgs/query" "github.com/specterops/dawgs/util" ) @@ -42,7 +41,7 @@ func IngestRelationships(ingestCtx *IngestContext, sourceKind graph.Kind, relati errs = util.NewErrorCollector() ) - updates, err := resolveRelationships(ingestCtx, relationships, sourceKind) + updates, err := buildIngestionUpdateBatch(ingestCtx, relationships, sourceKind) if err != nil { errs.Add(err) } @@ -192,202 +191,45 @@ func IngestSessions(batch *IngestContext, sessions []ein.IngestibleSession) erro return errs.Combined() } -type endpointKey struct { - Name string - Kind string -} - -func addKey(endpoint ein.IngestibleEndpoint, cache map[endpointKey]struct{}) { - if endpoint.MatchBy != ein.MatchByName { - return - } - key := endpointKey{ - Name: strings.ToUpper(endpoint.Value), - } - if endpoint.Kind != nil { - key.Kind = endpoint.Kind.String() - } - cache[key] = struct{}{} -} - -// resolveAllEndpointsByName attempts to resolve all unique source and target -// endpoints from a list of ingestible relationships into their corresponding object IDs. -// -// Each endpoint is identified by a Name, (optional) Kind pair. A single batch query is -// used to resolve all endpoints in one round trip. -// -// If multiple nodes match a given Name, Kind pair with conflicting object IDs, -// the match is considered ambiguous and excluded from the result. This can happen because there are no -// uniqueness guarantees on a node's `Name` property. -// -// Returns a map of resolved object IDs. If no matches are found or the input is empty, an empty map is returned. -func resolveAllEndpointsByName(batch BatchUpdater, rels []ein.IngestibleRelationship) (map[endpointKey]string, error) { - // seen deduplicates Name:Kind pairs from the input batch to ensure that each Name:Kind pairs is resolved once. - seen := map[endpointKey]struct{}{} - - if len(rels) == 0 { - return map[endpointKey]string{}, nil - } - - for _, rel := range rels { - addKey(rel.Source, seen) - addKey(rel.Target, seen) - } - // if nothing to filter, return early - if len(seen) == 0 { - return map[endpointKey]string{}, nil - } - +func buildIngestionUpdateBatch(batch *IngestContext, rels []ein.IngestibleRelationship, sourceKind graph.Kind) ([]graph.RelationshipUpdate, error) { var ( - filters = make([]graph.Criteria, 0, len(seen)) - buildFilter = func(key endpointKey) graph.Criteria { - var criteria []graph.Criteria - - criteria = append(criteria, query.Equals(query.NodeProperty(common.Name.String()), key.Name)) - if key.Kind != "" { - criteria = append(criteria, query.Kind(query.Node(), graph.StringKind(key.Kind))) - } - return query.And(criteria...) - } + updates []graph.RelationshipUpdate + errs = errorlist.NewBuilder() ) - // aggregate all Name:Kind pairs in 1 DAWGs query for 1 round trip - for key := range seen { - filters = append(filters, buildFilter(key)) - } - - var ( - resolved = map[endpointKey]string{} - ambiguous = map[endpointKey]bool{} - ) - - if err := batch.Nodes().Filter(query.Or(filters...)).Fetch( - func(cursor graph.Cursor[*graph.Node]) error { - - for node := range cursor.Chan() { - nameVal, _ := node.Properties.Get(common.Name.String()).String() - objectID, err := node.Properties.Get(string(common.ObjectID)).String() - if err != nil || objectID == "" { - slog.Warn("Matched node missing objectid", - slog.String("name", nameVal), - slog.Any("kinds", node.Kinds)) - continue - } - - // edge case: resolve an empty key to match endpoints that provide no Kind filter - node.Kinds = append(node.Kinds, graph.EmptyKind) - - // resolve all names found to objectids, - // record ambiguous matches (when more than one match is found, we cannot disambiguate the requested node and must skip the update) - for _, kind := range node.Kinds { - key := endpointKey{Name: strings.ToUpper(nameVal), Kind: kind.String()} - if existingID, exists := resolved[key]; exists && existingID != objectID { - ambiguous[key] = true - } else { - resolved[key] = objectID - } - } - } - - return nil - }, - ); err != nil { - return nil, err - } - - // remove ambiguous matches - for key := range ambiguous { - delete(resolved, key) - } - - return resolved, nil -} + for _, rel := range rels { + rel.RelProps[common.LastSeen.String()] = batch.IngestTime -// resolveRelationships transforms a list of ingestible relationships into a -// slice of graph.RelationshipUpdate objects, suitable for ingestion into the -// graph database. -// -// The function resolves all source and target endpoints to their corresponding -// object IDs if MatchByName is set on an endpoint. Relationships with unresolved -// or ambiguous endpoints are skipped and logged with a warning. -// -// The identityKind parameter determines the identity kind used for both start -// and end nodes if provided. eg. ad.Base and az.Base are used for *hound collections, and generic ingest has no base kind. -// -// Each resolved relationship is stamped with the current UTC timestamp as the "last seen" property. -// -// Returns a slice of valid relationship updates or an error if resolution fails. -func resolveRelationships(batch *IngestContext, rels []ein.IngestibleRelationship, sourceKind graph.Kind) ([]graph.RelationshipUpdate, error) { - if cache, err := resolveAllEndpointsByName(batch.Batch, rels); err != nil { - return nil, err - } else { var ( - updates []graph.RelationshipUpdate - errs = errorlist.NewBuilder() + startIdentityProperty = rel.Source.IdentityProperty() + startKinds = MergeNodeKinds(sourceKind, rel.Source.Kind) + startProperties = graph.AsProperties(map[string]any{ + startIdentityProperty: rel.Source.Value, + common.LastSeen.String(): batch.IngestTime, + }) + + endIdentityProperty = rel.Target.IdentityProperty() + endKinds = MergeNodeKinds(sourceKind, rel.Target.Kind) + endProperties = graph.AsProperties(map[string]any{ + endIdentityProperty: rel.Target.Value, + common.LastSeen.String(): batch.IngestTime, + }) ) - for _, rel := range rels { - srcID, srcOK := resolveEndpointID(rel.Source, cache) - targetID, targetOK := resolveEndpointID(rel.Target, cache) - - if !srcOK || !targetOK { - slog.Warn("Skipping unresolved relationship", - slog.String("source", rel.Source.Value), - slog.String("target", rel.Target.Value), - slog.Bool("resolved_source", srcOK), - slog.Bool("resolved_target", targetOK), - slog.String("type", rel.RelType.String())) - errs.Add( - IngestUserDataError{ - Msg: fmt.Sprintf("skipping invalid relationship. unable to resolve endpoints. source: %s, target: %s", rel.Source.Value, rel.Target.Value), - }, - ) - continue - } - - rel.RelProps[common.LastSeen.String()] = batch.IngestTime - - startKinds := MergeNodeKinds(sourceKind, rel.Source.Kind) - endKinds := MergeNodeKinds(sourceKind, rel.Target.Kind) - - update := graph.RelationshipUpdate{ - Start: graph.PrepareNode(graph.AsProperties(graph.PropertyMap{ - common.ObjectID: srcID, - common.LastSeen: batch.IngestTime, - }), startKinds...), - StartIdentityProperties: []string{common.ObjectID.String()}, - StartIdentityKind: sourceKind, - End: graph.PrepareNode(graph.AsProperties(graph.PropertyMap{ - common.ObjectID: targetID, - common.LastSeen: batch.IngestTime, - }), endKinds...), - EndIdentityKind: sourceKind, - EndIdentityProperties: []string{common.ObjectID.String()}, - Relationship: graph.PrepareRelationship(graph.AsProperties(rel.RelProps), rel.RelType), - } - - updates = append(updates, update) + update := graph.RelationshipUpdate{ + Start: graph.PrepareNode(startProperties, startKinds...), + StartIdentityKind: sourceKind, + StartIdentityProperties: []string{startIdentityProperty}, + End: graph.PrepareNode(endProperties, endKinds...), + EndIdentityKind: sourceKind, + EndIdentityProperties: []string{endIdentityProperty}, + Relationship: graph.PrepareRelationship(graph.AsProperties(rel.RelProps), rel.RelType), } - return updates, errs.Build() - } -} - -func resolveEndpointID(endpoint ein.IngestibleEndpoint, cache map[endpointKey]string) (string, bool) { - if endpoint.MatchBy == ein.MatchByName { - key := endpointKey{ - Name: strings.ToUpper(endpoint.Value), - Kind: "", - } - if endpoint.Kind != nil { - key.Kind = endpoint.Kind.String() - } - id, ok := cache[key] - return id, ok + updates = append(updates, update) } - // Fallback to raw value if matching by ID - return endpoint.Value, endpoint.Value != "" + return updates, errs.Build() } // MergeNodeKinds combines a source kind with any additional kinds, diff --git a/cmd/api/src/services/graphify/ingestrelationships_integration_test.go b/cmd/api/src/services/graphify/ingestrelationships_integration_test.go index b1cd806dcab..67ccaa61879 100644 --- a/cmd/api/src/services/graphify/ingestrelationships_integration_test.go +++ b/cmd/api/src/services/graphify/ingestrelationships_integration_test.go @@ -377,7 +377,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.Nil(t, err) require.Len(t, updates, 1) @@ -417,7 +417,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.ErrorContains(t, err, "skipping invalid relationship") require.Empty(t, updates) @@ -447,7 +447,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.ErrorContains(t, err, "skipping invalid relationship") require.Empty(t, updates) @@ -477,7 +477,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.ErrorContains(t, err, "skipping invalid relationship") require.Empty(t, updates) @@ -507,7 +507,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.ErrorContains(t, err, "skipping invalid relationship") require.Empty(t, updates) @@ -537,7 +537,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.ErrorContains(t, err, "skipping invalid relationship") require.Empty(t, updates) @@ -567,7 +567,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.Nil(t, err) require.Len(t, updates, 1) @@ -607,7 +607,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.Nil(t, err) require.Len(t, updates, 1) @@ -646,7 +646,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.Nil(t, err) require.Len(t, updates, 1) @@ -684,7 +684,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.Nil(t, err) require.Len(t, updates, 1) @@ -722,7 +722,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.ErrorContains(t, err, "skipping invalid relationship") require.Empty(t, updates) @@ -752,7 +752,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.ErrorContains(t, err, "skipping invalid relationship") require.Empty(t, updates) @@ -782,7 +782,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, []ein.IngestibleRelationship{ingestibleRel}, graph.EmptyKind) require.Nil(t, err) require.Len(t, updates, 1) @@ -849,7 +849,7 @@ func Test_ResolveRelationships(t *testing.T) { err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { ingestContext := NewIngestContext(testContext.Context(), WithBatchUpdater(batch)) - updates, err := resolveRelationships(ingestContext, rels, graph.EmptyKind) + updates, err := buildIngestionUpdateBatch(ingestContext, rels, graph.EmptyKind) require.Nil(t, err) require.Len(t, updates, 2) @@ -879,152 +879,3 @@ func Test_ResolveRelationships(t *testing.T) { }) }) } - -func Test_ResolveAllEndpointsByName(t *testing.T) { - generateKey := func(name, kind string) endpointKey { - return endpointKey{ - Name: name, - Kind: kind, - } - } - t.Run("Single match. One node with name and kind found, and valid objectid returned.", func(t *testing.T) { - testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) - testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { - harness.ResolveEndpointsByName.Setup(testContext) - return nil - }, func(harness integration.HarnessDetails, db graph.Database) { - - err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { - rel := ein.NewIngestibleRelationship( - ein.IngestibleEndpoint{Value: "alice", Kind: ad.User, MatchBy: ein.MatchByName}, - ein.IngestibleEndpoint{}, - ein.IngestibleRel{RelType: graph.EmptyKind}, - ) - - rels := []ein.IngestibleRelationship{rel} // simulate a "batch" - - cache, err := resolveAllEndpointsByName(batch, rels) - require.Nil(t, err) - require.Len(t, cache, 3) // cache has keys for 'User' and 'Base' and "" - - key := generateKey("ALICE", "User") - require.Contains(t, cache, key) - require.NotEmpty(t, cache[key]) - - return nil - }) - - require.Nil(t, err) - }) - }) - - t.Run("No match. Lookup requests name/kind that do not exist in DB. Empty result map returned.", func(t *testing.T) { - testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) - testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { - harness.ResolveEndpointsByName.Setup(testContext) - return nil - }, func(harness integration.HarnessDetails, db graph.Database) { - - err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { - rel := ein.NewIngestibleRelationship( - ein.IngestibleEndpoint{Value: "not alice", Kind: ad.User, MatchBy: ein.MatchByName}, - ein.IngestibleEndpoint{}, - ein.IngestibleRel{RelType: graph.EmptyKind}, - ) - - rels := []ein.IngestibleRelationship{rel} // simulate a "batch" - - cache, err := resolveAllEndpointsByName(batch, rels) - require.Nil(t, err) - require.Len(t, cache, 0) - - return nil - }) - - require.Nil(t, err) - }) - }) - - t.Run("Ambiguous match. Two nodes with same name + kind. Skipped from result map.", func(t *testing.T) { - testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) - testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { - harness.ResolveEndpointsByName.Setup(testContext) - return nil - }, func(harness integration.HarnessDetails, db graph.Database) { - - err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { - rel := ein.NewIngestibleRelationship( - ein.IngestibleEndpoint{Value: "SAME NAME", Kind: ad.Computer, MatchBy: ein.MatchByName}, - ein.IngestibleEndpoint{}, - ein.IngestibleRel{RelType: graph.EmptyKind}, - ) - - rels := []ein.IngestibleRelationship{rel} // simulate a "batch" - - cache, err := resolveAllEndpointsByName(batch, rels) - require.Nil(t, err) - require.Len(t, cache, 0) - - return nil - }) - - require.Nil(t, err) - }) - }) - - t.Run("Multiple distinct matches for Alice n Bob. Both returned", func(t *testing.T) { - testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) - testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { - harness.ResolveEndpointsByName.Setup(testContext) - return nil - }, func(harness integration.HarnessDetails, db graph.Database) { - - err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { - rel := ein.NewIngestibleRelationship( - ein.IngestibleEndpoint{Value: "alice", Kind: ad.User, MatchBy: ein.MatchByName}, - ein.IngestibleEndpoint{Value: "bob", Kind: graph.StringKind("GenericDevice"), MatchBy: ein.MatchByName}, - ein.IngestibleRel{RelType: graph.EmptyKind}, - ) - - rels := []ein.IngestibleRelationship{rel} // simulate a "batch" - - cache, err := resolveAllEndpointsByName(batch, rels) - require.Nil(t, err) - require.Len(t, cache, 5) // Alice node has keys for 'User' and 'Base' and "". Bob just has GenericBase and "" - - aliceKey := generateKey("ALICE", "User") - require.Contains(t, cache, aliceKey) - require.NotEmpty(t, cache[aliceKey]) - - bobKey := generateKey("BOB", "GenericDevice") - require.Contains(t, cache, bobKey) - require.NotEmpty(t, cache[bobKey]) - - return nil - }) - - require.Nil(t, err) - }) - }) - - t.Run("Empty input. Empty result map returned.", func(t *testing.T) { - testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) - testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { - harness.ResolveEndpointsByName.Setup(testContext) - return nil - }, func(harness integration.HarnessDetails, db graph.Database) { - - err := db.BatchOperation(testContext.Context(), func(batch graph.Batch) error { - rels := []ein.IngestibleRelationship{} // simulate a "batch" - - cache, err := resolveAllEndpointsByName(batch, rels) - require.Nil(t, err) - require.Len(t, cache, 0) - - return nil - }) - - require.Nil(t, err) - }) - }) -} diff --git a/packages/go/chow/ingestvalidator/jsonschema/edge.json b/packages/go/chow/ingestvalidator/jsonschema/edge.json index c8d36d85c4b..1528a2d2a07 100644 --- a/packages/go/chow/ingestvalidator/jsonschema/edge.json +++ b/packages/go/chow/ingestvalidator/jsonschema/edge.json @@ -8,12 +8,16 @@ "properties": { "match_by": { "type": "string", - "enum": ["id", "name"], + "enum": ["id", "name", "property"], "default": "id", - "description": "Whether to match the start node by its unique object ID or by its name property." + "description": "Matching method to use. Addressing a node by its 'objectid' or 'name' property which are indexed. All other lookups may use the 'property' match_by option and specify a property name to target." }, - "value": { + "property": { "type": "string", + "description": "Optional. The property name to match against when using the 'property' match_by method." + }, + "value": { + "type": ["number", "string", "boolean", "array"], "description": "The value used for matching — either an object ID or a name, depending on match_by." }, "kind": { @@ -28,12 +32,16 @@ "properties": { "match_by": { "type": "string", - "enum": ["id", "name"], + "enum": ["id", "name", "property"], "default": "id", - "description": "Whether to match the end node by its unique object ID or by its name property." + "description": "Matching method to use. Addressing a node by its 'objectid' or 'name' property which are indexed. All other lookups may use the 'property' match_by option and specify a property name to target." }, - "value": { + "property": { "type": "string", + "description": "Optional. The property name to match against when using the 'property' match_by method." + }, + "value": { + "type": ["number", "string", "boolean", "array"], "description": "The value used for matching — either an object ID or a name, depending on match_by." }, "kind": { @@ -76,6 +84,22 @@ "duration_minutes": 45 } }, + { + "start": { + "match_by": "property", + "property": "my_id_number", + "value": 12 + }, + "end": { + "match_by": "id", + "value": "server-5678" + }, + "kind": "has_session", + "properties": { + "timestamp": "2025-04-16T12:00:00Z", + "duration_minutes": 45 + } + }, { "start": { "match_by": "name", diff --git a/packages/go/ein/incoming_models.go b/packages/go/ein/incoming_models.go index 28e7d133c7b..a7f03c32404 100644 --- a/packages/go/ein/incoming_models.go +++ b/packages/go/ein/incoming_models.go @@ -382,7 +382,8 @@ type GenericEdge struct { } type EdgeEndpoint struct { - Value string - Kind string - MatchBy string `json:"match_by"` + Property string + Value string + Kind string + MatchBy string `json:"match_by"` } diff --git a/packages/go/ein/models.go b/packages/go/ein/models.go index 5a1d2520235..987e27743db 100644 --- a/packages/go/ein/models.go +++ b/packages/go/ein/models.go @@ -16,7 +16,10 @@ package ein -import "github.com/specterops/dawgs/graph" +import ( + "github.com/specterops/bloodhound/packages/go/graphschema/common" + "github.com/specterops/dawgs/graph" +) // Initialize IngestibleRelationship to ensure the RelProps map can't be nil func NewIngestibleRelationship(source IngestibleEndpoint, target IngestibleEndpoint, rel IngestibleRel) IngestibleRelationship { @@ -37,15 +40,30 @@ func NewIngestibleRelationship(source IngestibleEndpoint, target IngestibleEndpo type IngestMatchStrategy string const ( - MatchByID IngestMatchStrategy = "id" - MatchByName IngestMatchStrategy = "name" + MatchByID IngestMatchStrategy = "id" + MatchByName IngestMatchStrategy = "name" + MatchByGenericProperty IngestMatchStrategy = "property" ) // IngestibleEndpoint represents a node reference in a relationship to be ingested. type IngestibleEndpoint struct { - Value string // The actual lookup value (either objectid or name) - MatchBy IngestMatchStrategy // Strategy used to resolve the node - Kind graph.Kind // Optional kind filter to help disambiguate nodes + Property string // Generic matching + Value string // The actual lookup value (either objectid or name) + MatchBy IngestMatchStrategy // Strategy used to resolve the node + Kind graph.Kind // Optional kind filter to help disambiguate nodes +} + +func (s IngestibleEndpoint) IdentityProperty() string { + switch s.MatchBy { + case MatchByID: + return common.ObjectID.String() + case MatchByName: + return common.Name.String() + case MatchByGenericProperty: + return s.Property + } + + return "" } type IngestibleRel struct {