diff --git a/docs/assets/opengraph/opengraph-edge.json b/docs/assets/opengraph/opengraph-edge.json deleted file mode 100644 index 0253fa1c..00000000 --- a/docs/assets/opengraph/opengraph-edge.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "title": "Generic Ingest Edge", - "description": "Defines an edge between two nodes in a generic graph ingestion system. Each edge specifies a start and end node using one of three matching strategies: by unique identifier (match_by: id), by name (match_by: name, deprecated), or by one or more property matchers (match_by: property). A kind is required to indicate the relationship type. Optional properties may include custom attributes. You may optionally constrain the start or end node to a specific kind using the kind field inside each reference.", - "type": "object", - "$defs": { - "property_map": { - "type": ["object", "null"], - "description": "A key-value map of edge attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", - "additionalProperties": { - "anyOf": [ - { "type": "string" }, - { "type": "number" }, - { "type": "boolean" }, - { - "type": "array", - "anyOf": [ - { "items": { "type": "string" } }, - { "items": { "type": "number" } }, - { "items": { "type": "boolean" } } - ] - } - ] - } - }, - "endpoint": { - "type": "object", - "properties": { - "match_by": { - "type": "string", - "enum": ["id", "name", "property"], - "default": "id", - "description": "Whether to match the start node by its unique object ID or by a series of property matches. Note that the name value here is deprecated and will be removed in future versions. Users are advised to use the multi-property match strategy moving forward." - }, - "property_matchers": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "operator": { - "type": "string", - "enum": ["equals"] - }, - "value": { - "type": ["string", "number", "boolean"] - } - }, - "required": ["key", "operator", "value"] - } - }, - "value": { - "type": "string", - "description": "The value used for matching — either an object ID or a name, depending on match_by." - }, - "kind": { - "type": "string", - "description": "Optional kind filter; the referenced node must have this kind." - } - }, - "if": { - "allOf": [ - { - "properties": { - "match_by": { - "type": "string", - "const": "property" - } - } - }, - { - "not": { - "properties": { - "match_by": { - "type": "null" - } - } - } - } - ] - }, - "then": { - "required": ["property_matchers"], - "not": { - "required": ["value"] - } - }, - "else": { - "required": ["value"], - "not": { - "required": ["property_matchers"] - } - } - } - }, - "properties": { - "start": { - "$ref": "#/$defs/endpoint" - }, - "end": { - "$ref": "#/$defs/endpoint" - }, - "kind": { - "type": "string", - "description": "Edge kind name must contain only alphanumeric characters and underscores.", - "pattern": "^[A-Za-z0-9_]+$" - }, - "properties": { - "$ref": "#/$defs/property_map" - } - }, - "required": ["start", "end", "kind"], - "examples": [ - { - "start": { - "match_by": "id", - "value": "user-1234" - }, - "end": { - "match_by": "id", - "value": "server-5678" - }, - "kind": "has_session", - "properties": { - "timestamp": "2025-04-16T12:00:00Z", - "duration_minutes": 45 - } - }, - { - "start": { - "match_by": "property", - "property_matchers": [ - { - "key": "prop_1", - "operator": "equals", - "value": "value" - } - ] - }, - "end": { - "match_by": "id", - "value": "server-5678" - }, - "kind": "has_session", - "properties": { - "timestamp": "2025-04-16T12:00:00Z", - "duration_minutes": 45 - } - }, - { - "start": { - "match_by": "name", - "value": "alice", - "kind": "User" - }, - "end": { - "match_by": "name", - "value": "file-server-1", - "kind": "Server" - }, - "kind": "accessed_resource", - "properties": { - "via": "SMB", - "sensitive": true - } - }, - { - "start": { - "value": "admin-1" - }, - "end": { - "value": "domain-controller-9" - }, - "kind": "admin_to", - "properties": { - "reason": "elevated_permissions", - "confirmed": false - } - }, - { - "start": { - "match_by": "name", - "value": "Printer-007" - }, - "end": { - "match_by": "id", - "value": "network-42" - }, - "kind": "connected_to", - "properties": null - } - ] -} diff --git a/docs/assets/opengraph/opengraph-node.json b/docs/assets/opengraph/opengraph-node.json deleted file mode 100644 index 5b2a6381..00000000 --- a/docs/assets/opengraph/opengraph-node.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "title": "Generic Ingest Node", - "description": "A node used in a generic graph ingestion system. Each node must have a unique identifier (`id`) and at least one kind describing its role or type. Nodes may also include a `properties` object containing custom attributes.", - "type": "object", - "$defs": { - "property_map": { - "type": ["object", "null"], - "description": "A key-value map of entity attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", - "additionalProperties": { - "anyOf": [ - { "type": "string" }, - { "type": "number" }, - { "type": "boolean" }, - { - "type": "array", - "anyOf": [ - { "items": { "type": "string" } }, - { "items": { "type": "number" } }, - { "items": { "type": "boolean" } } - ] - } - ] - }, - - "not": { - "required": ["objectid"] - } - } - }, - "properties": { - "id": { - "type": "string" - }, - "properties": { - "$ref": "#/$defs/property_map" - }, - "kinds": { - "type": ["array"], - "items": { "type": "string" }, - "minItems": 0, - "maxItems": 3, - "description": "An array of kind labels for the node. The first element is treated as the node's primary kind and is used to determine which icon to display in the graph UI. This primary kind is only used for visual representation and has no semantic significance for data processing." - } - }, - "required": ["id", "kinds"], - "examples": [ - { - "id": "user-1234", - "kinds": ["Person"] - }, - { - "id": "device-5678", - "properties": { - "manufacturer": "Brandon Corp", - "model": "4000x", - "is_active": true, - "rating": 43.5 - }, - "kinds": ["Device", "Asset"] - } - ] -} diff --git a/docs/assets/opengraph/payload-schema.json b/docs/assets/opengraph/payload-schema.json new file mode 100644 index 00000000..72a7d710 --- /dev/null +++ b/docs/assets/opengraph/payload-schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/opengraph.json" +} diff --git a/docs/opengraph/developer/schema.mdx b/docs/opengraph/developer/schema.mdx index af49bff7..875e2f30 100644 --- a/docs/opengraph/developer/schema.mdx +++ b/docs/opengraph/developer/schema.mdx @@ -4,6 +4,8 @@ sidebarTitle: Schema description: "Description of the OpenGraph JSON Schema" --- +import { GitHubJsonCodeblock } from "/snippets/opengraph/github-json-codeblock.jsx"; + You can find the latest node and edge schemas in the BloodHound [source code](https://github.com/SpecterOps/BloodHound/tree/main/cmd/api/src/services/upload/jsonschema) on GitHub. - ## Ingesting OpenGraph Data ### File Requirements @@ -54,6 +54,27 @@ When ingest completes, you can [search](/analyze-data/explore/search) OpenGraph **Entity Panels**: Clicking on an OpenGraph node or edge will currently display the full list of the entity's properties and their values. Future enhancements will enable OpenGraph extension authors to define structured and organized entity panel representations. +### Data Payload Validation + +Before structuring your payload, you can optionally set up JSON schema validation in your editor to catch errors before ingestion. Add the following to the top level of your JSON document: + +```json +{ + "$schema": "https://bloodhound.specterops.io/assets/opengraph/payload-schema.json" +} +``` + +Most editors will ask you to allow-list external domains for JSON schemas. You'll need to add the following URIs to your editor's configuration: + +``` +https://bloodhound.specterops.io/assets/opengraph/payload-schema.json +https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/ +``` + + + This enables real-time validation as you author your OpenGraph data payload. + + ## Nodes ### Property Rules @@ -78,72 +99,11 @@ Upon ingestion, this `id` value is automatically mapped and stored internally as ### Node JSON -The following is the JSON schema that all nodes must conform to. +The following is the JSON schema that all nodes must conform to. -```json -{ - "title": "Generic Ingest Node", - "description": "A node used in a generic graph ingestion system. Each node must have a unique identifier (`id`) and at least one kind describing its role or type. Nodes may also include a `properties` object containing custom attributes.", - "type": "object", - "$defs": { - "property_map": { - "type": ["object", "null"], - "description": "A key-value map of entity attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", - "additionalProperties": { - "anyOf": [ - { "type": "string" }, - { "type": "number" }, - { "type": "boolean" }, - { - "type": "array", - "anyOf": [ - { "items": { "type": "string" } }, - { "items": { "type": "number" } }, - { "items": { "type": "boolean" } } - ] - } - ] - }, - - "not": { - "required": ["objectid"] - } - } - }, - "properties": { - "id": { - "type": "string" - }, - "properties": { - "$ref": "#/$defs/property_map" - }, - "kinds": { - "type": ["array"], - "items": { "type": "string" }, - "minItems": 0, - "maxItems": 3, - "description": "An array of kind labels for the node. The first element is treated as the node's primary kind and is used to determine which icon to display in the graph UI. This primary kind is only used for visual representation and has no semantic significance for data processing." - } - }, - "required": ["id", "kinds"], - "examples": [ - { - "id": "user-1234", - "kinds": ["Person"] - }, - { - "id": "device-5678", - "properties": { - "manufacturer": "Brandon Corp", - "model": "4000x", - "is_active": true, - "rating": 43.5 - }, - "kinds": ["Device", "Asset"] - } - ] -} -``` + ## Edges @@ -184,7 +144,8 @@ BloodHound enforces this pattern for edge kinds: Edges in OpenGraph data define relationships between nodes using a `start` endpoint and an `end` endpoint. You can control how BloodHound resolves each endpoint using one of two matching strategies in the `match_by` field. This flexibility allows you to link nodes based on their unique database identifiers or by dynamically finding them based on specific attribute values. - Use identifier matching when possible. Property matching is more flexible, but it is slower and should be used only when you cannot match by node ID. + Use identifier matching when possible. Property matching is more flexible, but + it is slower and should be used only when you cannot match by node ID. #### Match by Identifier @@ -194,7 +155,9 @@ This is the default and most common method for defining edges. It resolves the e To use this strategy, set the `match_by` property to either `"id"` or `"name"`. - `"name"` is deprecated and will be removed in future versions; using `"property"` with a single equality matcher is the recommended approach for name-based lookups. + `"name"` is deprecated and will be removed in future versions; using + `"property"` with a single equality matcher is the recommended approach for + name-based lookups. - **`match_by`:** Set to `"id"` to match the node's unique object identifier, or `"name"` to match the node's name string. @@ -269,207 +232,13 @@ Linking a user to a server by matching the user's `username` property and the se ### Edge JSON -The following is the JSON schema that all edges must conform to. +The following is the JSON schema that all edges must conform to. If an edge kind does not meet the allowed pattern, BloodHound returns a schema validation error and rejects the upload. -```json -{ - "title": "Generic Ingest Edge", - "description": "Defines an edge between two nodes in a generic graph ingestion system. Each edge specifies a start and end node using one of three matching strategies: by unique identifier (match_by: id), by name (match_by: name, deprecated), or by one or more property matchers (match_by: property). A kind is required to indicate the relationship type. Optional properties may include custom attributes. You may optionally constrain the start or end node to a specific kind using the kind field inside each reference.", - "type": "object", - "$defs": { - "property_map": { - "type": ["object", "null"], - "description": "A key-value map of edge attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", - "additionalProperties": { - "anyOf": [ - { "type": "string" }, - { "type": "number" }, - { "type": "boolean" }, - { - "type": "array", - "anyOf": [ - { "items": { "type": "string" } }, - { "items": { "type": "number" } }, - { "items": { "type": "boolean" } } - ] - } - ] - } - }, - "endpoint": { - "type": "object", - "properties": { - "match_by": { - "type": "string", - "enum": ["id", "name", "property"], - "default": "id", - "description": "Whether to match the start node by its unique object ID or by a series of property matches. Note that the name value here is deprecated and will be removed in future versions. Users are advised to use the multi-property match strategy moving forward." - }, - "property_matchers": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "operator": { - "type": "string", - "enum": ["equals"] - }, - "value": { - "type": ["string", "number", "boolean"] - } - }, - "required": ["key", "operator", "value"] - } - }, - "value": { - "type": "string", - "description": "The value used for matching — either an object ID or a name, depending on match_by." - }, - "kind": { - "type": "string", - "description": "Optional kind filter; the referenced node must have this kind." - } - }, - "if": { - "allOf": [ - { - "properties": { - "match_by": { - "type": "string", - "const": "property" - } - } - }, - { - "not": { - "properties": { - "match_by": { - "type": "null" - } - } - } - } - ] - }, - "then": { - "required": ["property_matchers"], - "not": { - "required": ["value"] - } - }, - "else": { - "required": ["value"], - "not": { - "required": ["property_matchers"] - } - } - } - }, - "properties": { - "start": { - "$ref": "#/$defs/endpoint" - }, - "end": { - "$ref": "#/$defs/endpoint" - }, - "kind": { - "type": "string", - "description": "Edge kind name must contain only alphanumeric characters and underscores.", - "pattern": "^[A-Za-z0-9_]+$" - }, - "properties": { - "$ref": "#/$defs/property_map" - } - }, - "required": ["start", "end", "kind"], - "examples": [ - { - "start": { - "match_by": "id", - "value": "user-1234" - }, - "end": { - "match_by": "id", - "value": "server-5678" - }, - "kind": "has_session", - "properties": { - "timestamp": "2025-04-16T12:00:00Z", - "duration_minutes": 45 - } - }, - { - "start": { - "match_by": "property", - "property_matchers": [ - { - "key": "prop_1", - "operator": "equals", - "value": "value" - } - ] - }, - "end": { - "match_by": "id", - "value": "server-5678" - }, - "kind": "has_session", - "properties": { - "timestamp": "2025-04-16T12:00:00Z", - "duration_minutes": 45 - } - }, - { - "start": { - "match_by": "name", - "value": "alice", - "kind": "User" - }, - "end": { - "match_by": "name", - "value": "file-server-1", - "kind": "Server" - }, - "kind": "accessed_resource", - "properties": { - "via": "SMB", - "sensitive": true - } - }, - { - "start": { - "value": "admin-1" - }, - "end": { - "value": "domain-controller-9" - }, - "kind": "admin_to", - "properties": { - "reason": "elevated_permissions", - "confirmed": false - } - }, - { - "start": { - "match_by": "name", - "value": "Printer-007" - }, - "end": { - "match_by": "id", - "value": "network-42" - }, - "kind": "connected_to", - "properties": null - } - ] -} -``` + #### Post-processing @@ -568,19 +337,9 @@ You can optionally include a metadata object at the top level of your data paylo - `source_kind`: a string that applies to all nodes in the file, used to attribute a source to ingested nodes (e.g. Github, Snowflake, MSSQL). This is useful for tracking where a node originated. We internally use this concept already for AD/Azure, using the labels “Base” and “AZBase” respectively. -Example: - -```json -{ - "metadata": { - "source_kind": "GitHub" - }, - "graph": { - "nodes": [], - "edges": [] - } -} -``` + If present, the `source_kind` will be added to the `kinds` list of all nodes in the file during ingest. This feature is optional. @@ -589,51 +348,12 @@ If present, the `source_kind` will be added to the `kinds` list of all nodes in The following is a minimal example payload that conforms to the node and edge schemas above. You can use this as a starting point to build your own OpenGraph. Copy and paste the following example into a new `.json` file or download this example file. - When working with JSON files, use a plain text editor and UTF-8 encoding. Some - text editors may introduce unexpected, non-standard characters that can cause - parsing errors. It's always a good idea to validate your JSON with a - [linter](https://jsonlint.com/) before uploading it to BloodHound. + When working with JSON files, use a plain text editor and UTF-8 encoding to avoid unexpected, non-standard characters that can cause parsing errors. For proactive validation, set up the JSON schema validation described in [Data Payload Validation](#data-payload-validation). As a final check before uploading, you can also validate your JSON with a [linter](https://jsonlint.com/). -```json -{ - "graph": { - "nodes": [ - { - "id": "123", - "kinds": ["Person"], - "properties": { - "displayname": "bob", - "property": "a", - "name": "BOB" - } - }, - { - "id": "234", - "kinds": ["Person"], - "properties": { - "displayname": "alice", - "property": "b", - "name": "ALICE" - } - } - ], - "edges": [ - { - "kind": "Knows", - "start": { - "value": "123", - "match_by": "id" - }, - "end": { - "value": "234", - "match_by": "id" - } - } - ] - } -} -``` + To test the ingestion in your BloodHound instance, navigate to **Explore** → **Cypher**. Enter the following query and hit `Run`: diff --git a/docs/snippets/opengraph/github-json-codeblock.jsx b/docs/snippets/opengraph/github-json-codeblock.jsx new file mode 100644 index 00000000..a278cf18 --- /dev/null +++ b/docs/snippets/opengraph/github-json-codeblock.jsx @@ -0,0 +1,94 @@ +// Must use arrow function syntax in Mintlify snippets +export const GitHubJsonCodeblock = ({ + sourceUrl, + title = 'JSON', + showSourceLink = false, +}) => { + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [codeText, setCodeText] = useState(''); + + useEffect(() => { + const abortController = new AbortController(); + + const fetchSchema = async () => { + setIsLoading(true); + setErrorMessage(''); + setCodeText(''); + + try { + const response = await fetch(sourceUrl, { + signal: abortController.signal, + headers: { + Accept: 'application/json, text/plain;q=0.9,*/*;q=0.8', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch JSON (${response.status})`); + } + + const payload = await response.text(); + const parsedPayload = JSON.parse(payload); + const prettyPrintedJson = JSON.stringify(parsedPayload, null, 2); + setCodeText(prettyPrintedJson); + } catch (error) { + if (error.name === 'AbortError') { + return; + } + + setErrorMessage(error.message || 'Failed to load JSON content.'); + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }; + + fetchSchema(); + + return () => { + abortController.abort(); + }; + }, [sourceUrl]); + + const fileName = useMemo(() => { + if (!sourceUrl) { + return title; + } + + const sourcePathSegments = sourceUrl.split('/').filter(Boolean); + const maybeFileName = sourcePathSegments[sourcePathSegments.length - 1]; + return maybeFileName || title; + }, [sourceUrl, title]); + + const renderCode = (language, filename, text) => { + return ( + + {text} + + ); + }; + + if (isLoading) { + return renderCode('text', title, 'Loading JSON from GitHub...'); + } + + if (errorMessage) { + const message = `Unable to render JSON from ${sourceUrl}: ${errorMessage}`; + return renderCode('text', title, message); + } + + return ( + <> + {showSourceLink && sourceUrl && ( +

+ Source: {fileName} +

+ )} + {renderCode('json', fileName, codeText)} + + ); +}; + +export default GitHubJsonCodeblock;