From fe8b08ef984d8a7e91cd90db3abdaffc940481ff Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Fri, 9 Oct 2026 08:27:15 +0000 Subject: [PATCH] fix: [relationships] Correct directions and validate catalogue consistency This change comes from a MISP-LEA.org project request. Clarify source and target roles, remove download-origin inverse assertions, and complete existing parent/child and negative-target inverse pairs. Retain all legacy identifiers and add three corrected spellings in version 56. Replace the text-based opposite checker with structured JSON validation, pin the relationship schema to Draft 7, and run 22 regression tests in CI. Document standard-specific directions, legacy spellings and compatibility. --- README.md | 2 + relationships/README.md | 101 +++++++++++++++ relationships/definition.json | 139 ++++++++++++--------- schema_relationships.json | 27 ++-- tests/test_relationships.py | 211 ++++++++++++++++++++++++++++++++ tools/validate_opposites.sh | 17 +-- tools/validate_relationships.py | 85 +++++++++++++ validate_all.sh | 2 + 8 files changed, 502 insertions(+), 82 deletions(-) create mode 100644 relationships/README.md create mode 100644 tests/test_relationships.py create mode 100644 tools/validate_relationships.py diff --git a/README.md b/README.md index 3186fc61..fe15bdd9 100644 --- a/README.md +++ b/README.md @@ -516,6 +516,8 @@ The MISP object model is open and allows user to use their own relationships. MI - [relationships](relationships/definition.json) - list of predefined default relationships which can be used to link MISP objects together and explain the context of the relationship. +See the [relationship usage and compatibility guidance](relationships/README.md) for source/target directions, legacy spellings, and validation. + ## How to contribute MISP objects? Fork the project, create a new directory in the [objects directory](objects/) matching your object name. Objects must be composed diff --git a/relationships/README.md b/relationships/README.md new file mode 100644 index 00000000..9ae7a2fe --- /dev/null +++ b/relationships/README.md @@ -0,0 +1,101 @@ +# MISP object relationships + +`definition.json` is the catalogue of relationship names offered to MISP users. +An object reference has a source and a target; read its description in that order. +For example, `document --authored-by--> person` and +`person --is-author-of--> document` express the same authorship with reversed +endpoints. An `opposite` must point to the relationship that reverses those +endpoints, and the other entry must point back. + +Not every relationship needs an opposite. Do not infer that a relationship is +symmetric or transitive merely because it lacks inverse metadata. Friendship +or a reported attitude can be one-sided. + +## Download endpoints + +A download can involve three distinct objects: + +- `process --downloaded/downloads--> file`: downloader to downloaded artifact. +- `file --downloaded-from/downloads-from--> URL`: artifact to origin. + +These are not inverse edges: reversing the first would connect the file to the +downloader, not to the URL. Version 56 removes the incorrect inverse metadata +between these pairs while retaining all four identifiers and their endpoint +meanings. Consumers that previously relied on that metadata should review their +reverse-edge handling; the catalogue update does not rewrite stored references. + +## Legacy spellings + +Existing identifiers remain accepted. Use the following spellings for new +references; the catalogue does not automatically rename old references. +Here `␠` represents one trailing ASCII space. + +| Legacy identifier | Preferred identifier | +| --- | --- | +| `is-allied-with␠` | `is-allied-with` | +| `preceeds` | `precedes` | +| `ambivalient-of` | `ambivalent-of` | + +`precedes` places the earlier object at the source and the later object at the +target, like `followed-by`. It is not a same-direction alias of `preceded-by`. +`targeted-by` and `is-targeted-by` are both retained; prefer `targeted-by` for +new MISP references because it is the declared opposite of `targets`. + +The capitalized XFN identifiers are preserved. In particular, +`source --Child--> target` means the target is the source's child, whereas +`source --Parent--> target` means the target is the source's parent. +These have the directions of `parent-of` and `child-of`, respectively. +Do not lowercase imported labels or infer reciprocal friendship automatically. + +## Format tags and standard-specific directions + +`format` records the catalogue's vocabulary/format associations. Its tags alone +are not a specification of an exporter mapping or a guarantee that a term is a +native predicate in the named standard. MISP imports catalogue entries +regardless of whether they carry the `misp` tag. + +The [STIX 2.1 specification, section 3.7](https://docs.oasis-open.org/cti/stix/v2.1/os/stix-v2.1-os.html) +defines `derived-from` with the original object at the source and the derivative +at the target. The catalogue retains this published direction despite its +unintuitive name. `based-on`, `extracted-from` and `retrieved-from` place the +dependent item at the source; converting between them can require reversing +endpoints. + +STIX allows user-defined relationship types, but exporters must still enforce +its endpoint types and lowercase ASCII/digit/hyphen restriction. Capitalized XFN +labels and the padded legacy identifier require an explicit export mapping. +Similarly, a `foaf` tag does not establish a native FOAF predicate; consult the +[FOAF vocabulary](https://xmlns.com/foaf/spec/) before choosing a mapping. + +## Validation + +From the repository root, run: + +```bash +jsonschema -i relationships/definition.json schema_relationships.json +./tools/validate_opposites.sh +python3 -B -m unittest discover -s tests -v +``` + +The shell entry point delegates to a JSON-aware Python validator. It checks +unique names, nonempty descriptions/formats, name whitespace, existing inverse +targets and reciprocal inverse links. The exact historical `is-allied-with␠` +identifier is the sole exception to the name-whitespace rule. XFN capitalization +is valid. An optional path allows validation of another catalogue: + +```bash +./tools/validate_opposites.sh /path/to/definition.json +``` + +These checks also run in `validate_all.sh`. The full script normalizes JSON and +requires a clean Git tree. Normalize edited JSON before committing and bump the +catalogue version whenever its definitions change; retain the catalogue UUID. + +## Further vocabulary work + +Similar names are not automatically interchangeable. Clarifying imported +payment/award roles (`paid`, `awarded-to`), registrar versus registrant +(`registered`, `registered-to`), and injector/payload/destination roles needs +representative producer data before changing their established meanings. +Domain additions such as document holding or evidentiary relevance should +define their endpoints and explain how they differ from existing relationships. diff --git a/relationships/definition.json b/relationships/definition.json index 2e4589ab..e18beb3a 100644 --- a/relationships/definition.json +++ b/relationships/definition.json @@ -194,22 +194,20 @@ "name": "dropped-by" }, { - "description": "The referenced source has downloaded the target object.", + "description": "The source object downloaded the target object, for example a process downloading a file. The target is the downloaded artifact, not its origin.", "format": [ "misp", "stix-1.1" ], - "name": "downloaded", - "opposite": "downloaded-from" + "name": "downloaded" }, { - "description": "The referenced source has been downloaded from the target object.", + "description": "The source object was downloaded from the target location, for example a file downloaded from a URL. The target is the origin, not the downloader.", "format": [ "misp", "stix-1.1" ], - "name": "downloaded-from", - "opposite": "downloaded" + "name": "downloaded-from" }, { "description": "The referenced source is resolved to the target object.", @@ -304,7 +302,7 @@ "name": "retrieved-from" }, { - "description": "This relationship describes the author of a specific object.", + "description": "The source object was authored by the target object.", "format": [ "misp" ], @@ -312,7 +310,7 @@ "opposite": "is-author-of" }, { - "description": "This relationship describes an object being author by someone.", + "description": "The source object is the author of the target object.", "format": [ "misp" ], @@ -320,7 +318,7 @@ "opposite": "authored-by" }, { - "description": "This relationship describes the location (of any type) of a specific object.", + "description": "The source object is located at the location described by the target object.", "format": [ "misp" ], @@ -335,7 +333,7 @@ "opposite": "includes" }, { - "description": "This relationship describes an object that includes an other object.", + "description": "This relationship describes an object that includes another object.", "format": [ "misp" ], @@ -343,7 +341,7 @@ "opposite": "included-in" }, { - "description": "This relationship describes an object analysed by another object.", + "description": "The source object was analysed using the target tool or analysis object.", "format": [ "misp" ], @@ -437,7 +435,7 @@ "opposite": "intercepted-by" }, { - "description": "This relationship describes an object which calls another objects.", + "description": "This relationship describes an object which calls another object.", "format": [ "misp" ], @@ -526,7 +524,7 @@ "opposite": "hosted-by" }, { - "description": "This relatiobship describes an object hosted by another object.", + "description": "This relationship describes an object hosted by another object.", "format": [ "stix-2.1" ], @@ -541,7 +539,7 @@ "name": "consists" }, { - "description": "This relationship describes an object which delivers to one or more object(s).", + "description": "The source object delivers the target object, such as malware delivering another malware payload.", "format": [ "stix-2.1" ], @@ -757,7 +755,7 @@ "name": "released-source-code" }, { - "description": "This relationship describes an object which release another object.", + "description": "This relationship describes an object which releases another object.", "format": [ "cert-eu" ], @@ -778,7 +776,7 @@ "name": "signed-by" }, { - "description": "This relationship describes an object by another object (such as exploit kit, dropper).", + "description": "The source object is delivered by the target object, such as a payload delivered by an exploit kit or dropper.", "format": [ "misp" ], @@ -793,14 +791,14 @@ "opposite": "controlled-by" }, { - "description": "This relationships describes an object which annotates another object.", + "description": "This relationship describes an object which annotates another object.", "format": [ "misp" ], "name": "annotates" }, { - "description": "This relationships describes an object which references another object or attribute.", + "description": "This relationship describes an object which references another object or attribute.", "format": [ "misp" ], @@ -811,7 +809,8 @@ "format": [ "alfred" ], - "name": "child-of" + "name": "child-of", + "opposite": "parent-of" }, { "description": "A parent semantic link to a child.", @@ -819,7 +818,8 @@ "alfred", "misp" ], - "name": "parent-of" + "name": "parent-of", + "opposite": "child-of" }, { "description": "Represents the semantic link of having compromised something.", @@ -857,20 +857,18 @@ "name": "disclosed-to" }, { - "description": "Represents the semantic link of one thing downloading another.", + "description": "The source object downloads the target object, for example a process downloading a file. The target is the downloaded artifact, not its origin.", "format": [ "alfred" ], - "name": "downloads", - "opposite": "downloads-from" + "name": "downloads" }, { - "description": "Represents the semantic link of malware being downloaded from a location.", + "description": "The source object is downloaded from the target location, for example malware downloaded from a URL. The target is the origin, not the downloader.", "format": [ "alfred" ], - "name": "downloads-from", - "opposite": "downloads" + "name": "downloads-from" }, { "description": "Represents the semantic link of an alert generated from a signature.", @@ -896,7 +894,7 @@ "opposite": "initiated-by" }, { - "description": "The source object initiated the target object.", + "description": "The source object was initiated by the target object.", "format": [ "misp" ], @@ -933,7 +931,7 @@ "name": "not-relevant-to" }, { - "description": "Represents the semantic link that defines one thing to be part of another in a hierachial structure from the child to the parent.", + "description": "Represents the semantic link that defines one thing to be part of another in a hierarchical structure from the child to the parent.", "format": [ "alfred" ], @@ -1047,7 +1045,7 @@ "name": "sends-to" }, { - "description": "The represents the semantic link of having spoofed something.", + "description": "This represents the semantic link of having spoofed something.", "format": [ "alfred" ], @@ -1082,7 +1080,7 @@ "name": "uploads" }, { - "description": "The represents the semantic link of being the user of something.", + "description": "This represents the semantic link of being the user of something.", "format": [ "alfred" ], @@ -1161,7 +1159,7 @@ "name": "extends" }, { - "description": "Reprensents an object which writes towards another object or attribute", + "description": "Represents an object which writes towards another object or attribute", "format": [ "misp" ], @@ -1175,7 +1173,7 @@ "name": "ranked-with" }, { - "description": "owns", + "description": "The source object owns the target entity or asset.", "format": [ "followthemoney" ], @@ -1189,49 +1187,49 @@ "name": "awarded-to" }, { - "description": "directs", + "description": "The source object directs the target organisation or activity.", "format": [ "followthemoney" ], "name": "directs" }, { - "description": "involved-in", + "description": "The source object is involved in the target entity, activity or event.", "format": [ "followthemoney" ], "name": "involved-in" }, { - "description": "associated-with", + "description": "The source object is associated with the target object without specifying a more precise relationship.", "format": [ "followthemoney" ], "name": "associated-with" }, { - "description": "represents", + "description": "The source object represents the target object, for example a person acting on behalf of an organisation.", "format": [ "followthemoney" ], "name": "represents" }, { - "description": "owes", + "description": "The source object owes an obligation to the target object.", "format": [ "followthemoney" ], "name": "owes" }, { - "description": "preceeds", + "description": "The source object precedes the target object in a sequence or order. This is a legacy spelling; use precedes for new references.", "format": [ "followthemoney" ], "name": "preceeds" }, { - "description": "documents", + "description": "The source document records information about the target entity, activity or event.", "format": [ "followthemoney" ], @@ -1245,7 +1243,7 @@ "name": "paid" }, { - "description": "leaks", + "description": "The source object discloses the target information in a leak.", "format": [ "misp" ], @@ -1253,7 +1251,7 @@ "opposite": "leaked-by" }, { - "description": "leaked-by", + "description": "The source information was disclosed in a leak by the target object.", "format": [ "misp" ], @@ -1261,7 +1259,7 @@ "opposite": "leaks" }, { - "description": "doxed-by", + "description": "The source person or identity had their personal information disclosed in a doxing incident by the target object.", "format": [ "misp" ], @@ -1333,7 +1331,7 @@ "name": "found-in" }, { - "description": "The referenced source object drives the target described (often a vehicule).", + "description": "The referenced source object drives the target described (often a vehicle).", "format": [ "misp" ], @@ -1396,7 +1394,7 @@ "name": "antagonist-of" }, { - "description": "The referenced source object is an ambivalent of the target object.", + "description": "The source object has an ambivalent attitude toward the target object. This is a legacy spelling; use ambivalent-of for new references.", "format": [ "foaf" ], @@ -1437,7 +1435,8 @@ "format": [ "misp" ], - "name": "does-not-target" + "name": "does-not-target", + "opposite": "is-not-targeted-by" }, { "description": "This relationship describes that the source object is targeted by the target object.", @@ -1451,7 +1450,8 @@ "format": [ "misp" ], - "name": "is-not-targeted-by" + "name": "is-not-targeted-by", + "opposite": "does-not-target" }, { "description": "This relationship describes that the source object provides services described in the target object.", @@ -1468,21 +1468,21 @@ "name": "Friend" }, { - "description": "The source object considers the target object as a acquaintance. Is not necessarily symmetric.", + "description": "The source object considers the target object as an acquaintance. Is not necessarily symmetric.", "format": [ "XFN" ], "name": "Acquaintance" }, { - "description": "The source object have information to contact and/or get in touch with the target object.", + "description": "The source object has information to contact and/or get in touch with the target object.", "format": [ "XFN" ], "name": "Contact" }, { - "description": "The source object have physically met the target object.", + "description": "The source object has physically met the target object.", "format": [ "XFN" ], @@ -1521,17 +1521,19 @@ "format": [ "XFN" ], - "name": "Child" + "name": "Child", + "opposite": "Parent" }, { "description": "The target object is the parent of the source object.", "format": [ "XFN" ], - "name": "Parent" + "name": "Parent", + "opposite": "Child" }, { - "description": "The source object share a parent with the target object. Brothers, sisters, half-brothers, and half-sisters are all examples of siblings.", + "description": "The source object shares a parent with the target object. Brothers, sisters, half-brothers, and half-sisters are all examples of siblings.", "format": [ "XFN" ], @@ -1608,7 +1610,7 @@ "name": "known-as" }, { - "description": "The source object is led to the target object.", + "description": "The source object led to the target object.", "format": [ "stix-2.1" ], @@ -1631,7 +1633,7 @@ "opposite": "impacts" }, { - "description": "An object occured at a specific location.", + "description": "The source event occurred at the location described by the target object.", "format": [ "stix-2.1" ], @@ -1669,7 +1671,7 @@ "opposite": "observed-by" }, { - "description": "The source object was observed by the target object.", + "description": "The source object was observed as part of the target event.", "format": [ "misp" ], @@ -1776,7 +1778,7 @@ "name": "sample-of" }, { - "description": "This relationship describes an object which is allied with another object.", + "description": "The source object is allied with the target object. This is a legacy spelling; use is-allied-with for new references.", "format": [ "misp" ], @@ -1909,7 +1911,7 @@ "opposite": "summarizes" }, { - "description": "The source object is releasable to the target object.", + "description": "The source object is releasable to the target recipient. This records release eligibility; it does not grant access or enforce permissions.", "format": [ "misp" ], @@ -2434,7 +2436,28 @@ ], "name": "validated-by", "opposite": "validates" + }, + { + "description": "The source object is allied with the target object.", + "format": [ + "misp" + ], + "name": "is-allied-with" + }, + { + "description": "The source object precedes the target object in a sequence or order.", + "format": [ + "misp" + ], + "name": "precedes" + }, + { + "description": "The source object has an ambivalent attitude toward the target object.", + "format": [ + "misp" + ], + "name": "ambivalent-of" } ], - "version": 55 + "version": 56 } \ No newline at end of file diff --git a/schema_relationships.json b/schema_relationships.json index db5b6e6a..68895bfd 100644 --- a/schema_relationships.json +++ b/schema_relationships.json @@ -1,27 +1,36 @@ { - "$schema": "http://json-schema.org/schema#", + "$schema": "http://json-schema.org/draft-07/schema#", "title": "Validator for misp-objects", - "id": "https://www.github.com/MISP/misp-objects/schema.json", - "defs": { + "$id": "https://www.github.com/MISP/misp-objects/schema.json", + "definitions": { "value": { "type": "object", "additionalProperties": false, "properties": { "name": { - "type": "string" + "type": "string", + "minLength": 1, + "pattern": "\\S" }, "description": { - "type": "string" + "type": "string", + "minLength": 1, + "pattern": "\\S" }, "format": { "type": "array", "uniqueItems": true, "items": { - "type": "string" - } + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "minItems": 1 }, "opposite": { - "type": "string" + "type": "string", + "minLength": 1, + "pattern": "\\S" } }, "required": [ @@ -41,7 +50,7 @@ "type": "array", "uniqueItems": true, "items": { - "$ref": "#/defs/value" + "$ref": "#/definitions/value" } }, "description": { diff --git a/tests/test_relationships.py b/tests/test_relationships.py new file mode 100644 index 00000000..e70aaa8c --- /dev/null +++ b/tests/test_relationships.py @@ -0,0 +1,211 @@ +import copy +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +from jsonschema import Draft7Validator + +from tools.validate_relationships import validate_relationships + + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = json.loads((ROOT / "schema_relationships.json").read_text(encoding="utf-8")) +CATALOGUE = json.loads((ROOT / "relationships/definition.json").read_text(encoding="utf-8")) + + +def catalogue(): + return { + "name": "relationships", + "description": "Relationships for validation tests.", + "uuid": "b002c0d6-320f-450d-82c4-b3aa15bbbd6c", + "version": 1, + "values": [ + { + "name": "parent-of", + "description": "The source is a parent of the target.", + "format": ["misp"], + "opposite": "child-of", + }, + { + "name": "child-of", + "description": "The source is a child of the target.", + "format": ["misp"], + "opposite": "parent-of", + }, + ], + } + + +class RelationshipValidationTests(unittest.TestCase): + def test_current_catalogue(self): + Draft7Validator.check_schema(SCHEMA) + Draft7Validator(SCHEMA).validate(CATALOGUE) + self.assertEqual(validate_relationships(CATALOGUE), []) + + def test_duplicate_name_with_different_description(self): + data = catalogue() + duplicate = copy.deepcopy(data["values"][0]) + duplicate["description"] = "Another description of the same relation." + data["values"].append(duplicate) + # uniqueItems compares whole records, so a separate name check is needed. + self.assertTrue(Draft7Validator(SCHEMA).is_valid(data)) + self.assertTrue(any("Duplicate relationship name" in error for error in validate_relationships(data))) + + def test_unknown_opposite(self): + data = catalogue() + data["values"][0]["opposite"] = "missing" + self.assertTrue(any("unknown opposite" in error for error in validate_relationships(data))) + + def test_existing_but_nonreciprocal_opposite(self): + data = catalogue() + data["values"][1]["opposite"] = "child-of" + self.assertTrue(any("does not point back" in error for error in validate_relationships(data))) + + def test_missing_reverse_link(self): + data = catalogue() + del data["values"][1]["opposite"] + self.assertTrue(any("does not point back" in error for error in validate_relationships(data))) + + def test_unpaired_relationship_is_allowed(self): + data = catalogue() + for relationship in data["values"]: + del relationship["opposite"] + self.assertEqual(validate_relationships(data), []) + + def test_explicit_self_inverse_is_allowed(self): + data = catalogue() + data["values"] = [{ + "name": "same-as", + "description": "The source and target denote the same entity.", + "format": ["misp"], + "opposite": "same-as", + }] + self.assertEqual(validate_relationships(data), []) + + def test_new_padded_names_are_rejected(self): + for name in [" padded", "padded ", "\tpadded", "padded\n", "is-allied-with "]: + with self.subTest(name=name): + data = catalogue() + data["values"][0]["name"] = name + self.assertTrue(any("whitespace" in error for error in validate_relationships(data))) + + def test_exact_legacy_name_and_xfn_case_are_allowed(self): + data = catalogue() + data["values"] = [ + {"name": name, "description": "A historical relationship.", "format": ["misp"]} + for name in ["is-allied-with ", "is-allied-with", "Friend", "Child", "Parent"] + ] + self.assertEqual(validate_relationships(data), []) + + def test_literal_names_are_not_regular_expressions(self): + data = catalogue() + data["values"][0]["name"] = "literal[1].*" + data["values"][1]["opposite"] = "literal[1].*" + self.assertEqual(validate_relationships(data), []) + data["values"][1]["opposite"] = "literal1-extra" + self.assertTrue(any("unknown opposite" in error for error in validate_relationships(data))) + + def test_malformed_catalogue_structure(self): + for data in [None, [], {}, {"values": None}, {"values": "not an array"}, {"values": [None]}]: + with self.subTest(data=data): + self.assertTrue(validate_relationships(data)) + + def test_blank_or_wrong_type_fields(self): + cases = [ + ("name", ""), ("name", " \t"), ("name", None), + ("description", ""), ("description", "\n"), ("description", None), + ("format", []), ("format", None), ("format", [""]), ("format", [" "]), + ("format", ["misp "]), ("format", [123]), + ("opposite", ""), ("opposite", " \t"), ("opposite", None), + ] + for key, value in cases: + with self.subTest(key=key, value=value): + data = catalogue() + data["values"][0][key] = value + self.assertTrue(validate_relationships(data)) + + def test_schema_rejects_empty_definition_fields(self): + cases = [ + ("name", ""), ("name", " \t"), ("description", ""), ("description", "\n"), + ("format", []), ("format", [""]), ("format", ["\t"]), + ("opposite", ""), ("opposite", " "), + ] + validator = Draft7Validator(SCHEMA) + for key, value in cases: + with self.subTest(key=key, value=value): + data = catalogue() + data["values"][0][key] = value + self.assertFalse(validator.is_valid(data)) + + def test_schema_still_rejects_unknown_fields_and_duplicate_formats(self): + validator = Draft7Validator(SCHEMA) + data = catalogue() + data["values"][0]["alias"] = "another-name" + self.assertFalse(validator.is_valid(data)) + data = catalogue() + data["values"][0]["format"] = ["misp", "misp"] + self.assertFalse(validator.is_valid(data)) + + def run_wrapper(self, content, use_default_path=False): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "relationships/definition.json" + path.parent.mkdir() + path.write_text(content, encoding="utf-8") + command = ["bash", str(ROOT / "tools/validate_opposites.sh")] + if not use_default_path: + command.append(str(path)) + return subprocess.run(command, cwd=directory, capture_output=True, text=True) + + def test_wrapper_accepts_compact_json_in_another_directory(self): + result = self.run_wrapper(json.dumps(catalogue(), separators=(",", ":"))) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OK,", result.stdout) + + def test_wrapper_preserves_default_path(self): + result = self.run_wrapper(json.dumps(catalogue()), use_default_path=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_wrapper_fails_for_nonreciprocal_links(self): + data = catalogue() + del data["values"][1]["opposite"] + result = self.run_wrapper(json.dumps(data)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not point back", result.stderr) + self.assertNotIn("OK,", result.stdout) + + def test_wrapper_fails_for_invalid_json(self): + result = self.run_wrapper("{") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cannot read", result.stderr) + + def test_wrapper_fails_for_missing_file(self): + with tempfile.TemporaryDirectory() as directory: + result = subprocess.run( + ["bash", str(ROOT / "tools/validate_opposites.sh"), str(Path(directory) / "missing.json")], + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cannot read", result.stderr) + + def test_download_roles_do_not_claim_origin_is_downloader(self): + relationships = {relationship["name"]: relationship for relationship in CATALOGUE["values"]} + for name in ["downloaded", "downloaded-from", "downloads", "downloads-from"]: + with self.subTest(name=name): + self.assertNotIn("opposite", relationships[name]) + + def test_legacy_spellings_and_xfn_labels_are_retained(self): + names = {relationship["name"] for relationship in CATALOGUE["values"]} + self.assertTrue({ + "is-allied-with ", "is-allied-with", "preceeds", "precedes", + "ambivalient-of", "ambivalent-of", "Friend", "Child", "Parent", + }.issubset(names)) + + def test_derived_from_keeps_published_stix_direction(self): + relationship = next(item for item in CATALOGUE["values"] if item["name"] == "derived-from") + self.assertIn("target object is based on information from the source object", relationship["description"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate_opposites.sh b/tools/validate_opposites.sh index bd51fe14..67007b02 100755 --- a/tools/validate_opposites.sh +++ b/tools/validate_opposites.sh @@ -1,17 +1,4 @@ #!/bin/bash -opposites=$(cat relationships/definition.json | grep '"opposite"' | cut -d ':' -f 2 | tr -d ' ' | tr -d '"') - -for opposite in $opposites -do - cat relationships/definition.json | grep '"name": "'$opposite'"' >/dev/null 2>&1 - res=$? - if [ "$res" -eq 1 ] - then - echo "'$opposite' not found" - exit 1 - fi -done - -echo "OK, all opposites seem to point to existing relationships" -exit 0 +# Keep the existing entry point while parsing JSON instead of grepping text. +exec python3 "$(dirname "$0")/validate_relationships.py" "$@" diff --git a/tools/validate_relationships.py b/tools/validate_relationships.py new file mode 100644 index 00000000..48c8ef8a --- /dev/null +++ b/tools/validate_relationships.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Check catalogue constraints that cannot be expressed by the JSON Schema.""" + +import argparse +import json +import sys +from pathlib import Path + + +# Preserve this exact historical identifier until existing references migrate. +# New relationships must not introduce leading or trailing whitespace. +LEGACY_WHITESPACE_NAMES = frozenset({"is-allied-with "}) + + +def validate_relationships(catalogue: dict) -> list[str]: + errors = [] + if not isinstance(catalogue, dict) or not isinstance(catalogue.get("values"), list): + return ["The catalogue must contain a values array."] + + relationships = {} + for index, relationship in enumerate(catalogue["values"]): + if not isinstance(relationship, dict): + errors.append(f"Relationship at index {index} must be an object.") + continue + + name = relationship.get("name") + if not isinstance(name, str) or not name.strip(): + errors.append(f"Relationship at index {index} must have a nonempty name.") + continue + if name != name.strip() and name not in LEGACY_WHITESPACE_NAMES: + errors.append(f"Relationship {name!r} has leading or trailing whitespace.") + if name in relationships: + errors.append(f"Duplicate relationship name: {name!r}.") + else: + relationships[name] = relationship + + description = relationship.get("description") + if not isinstance(description, str) or not description.strip(): + errors.append(f"Relationship {name!r} must have a nonempty description.") + + formats = relationship.get("format") + if not isinstance(formats, list) or not formats: + errors.append(f"Relationship {name!r} must have a nonempty format array.") + else: + for value in formats: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + errors.append(f"Relationship {name!r} has an invalid format: {value!r}.") + + for name, relationship in relationships.items(): + if "opposite" not in relationship: + continue + opposite = relationship["opposite"] + if not isinstance(opposite, str) or not opposite.strip(): + errors.append(f"Relationship {name!r} must have a nonempty opposite name.") + elif opposite not in relationships: + errors.append(f"Relationship {name!r} has an unknown opposite: {opposite!r}.") + elif relationships[opposite].get("opposite") != name: + errors.append(f"Opposite {opposite!r} does not point back to {name!r}.") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("catalogue", nargs="?", type=Path, default=Path("relationships/definition.json")) + args = parser.parse_args() + try: + with args.catalogue.open(encoding="utf-8") as source: + catalogue = json.load(source) + except (OSError, ValueError) as error: + print(f"ERROR: Cannot read {args.catalogue}: {error}", file=sys.stderr) + return 1 + + errors = validate_relationships(catalogue) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print(f"OK, {len(catalogue['values'])} relationship names and declared opposites are valid") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/validate_all.sh b/validate_all.sh index a2bb5d3d..c82a1db9 100755 --- a/validate_all.sh +++ b/validate_all.sh @@ -33,6 +33,8 @@ done jsonschema -i relationships/definition.json schema_relationships.json ./tools/validate_opposites.sh +python3 -B -m unittest discover -s tests -v + ./unique_uuid.py echo "Success: All is fine, please go ahead.".