From 203b952b7412651cc5cbef8e622d84e9eb7f478d Mon Sep 17 00:00:00 2001 From: akshat4703 Date: Thu, 26 Feb 2026 11:39:33 +0530 Subject: [PATCH 1/7] add CycloneDX attestation export endpoint --- application/tests/web_main_test.py | 25 +++++++++++ application/web/web_main.py | 70 ++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 12 ++--- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 3f78a5e2f..28d5024a3 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -164,6 +164,20 @@ def test_find_by_id(self) -> None: ) self.assertEqual(re.sub("\s", "", md_response.data.decode()), md_expected) + cdx_response = client.get( + f"/rest/v1/id/{cres['cd'].id}?format=cyclonedx", + headers={"Content-Type": "application/json"}, + ) + cdx_payload = json.loads(cdx_response.data.decode()) + self.assertEqual(200, cdx_response.status_code) + self.assertEqual("CycloneDX", cdx_payload["bomFormat"]) + self.assertTrue(len(cdx_payload["components"]) == 1) + self.assertEqual("CD", cdx_payload["components"][0]["name"]) + self.assertEqual( + "attestation", + cdx_payload["components"][0]["externalReferences"][0]["type"], + ) + def test_find_by_name(self) -> None: collection = db.Node_collection().with_graph() cres = { @@ -384,6 +398,17 @@ def test_find_node_by_name(self) -> None: ] self.assertCountEqual(expected, actual) + cdx_response = client.get( + f"/rest/v1/standard/{nodes['sa'].name}?format=cyclonedx" + ) + cdx_payload = json.loads(cdx_response.data.decode()) + self.assertEqual(200, cdx_response.status_code) + self.assertEqual("CycloneDX", cdx_payload["bomFormat"]) + self.assertEqual(5, len(cdx_payload["components"])) + self.assertEqual( + "Standard", cdx_payload["components"][0]["properties"][0]["value"] + ) + def test_find_document_by_tag(self) -> None: collection = db.Node_collection() cres = { diff --git a/application/web/web_main.py b/application/web/web_main.py index 77eee36e7..0ccda973c 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -2,6 +2,7 @@ # silence mypy for the routes file import csv +from datetime import datetime, timezone from functools import wraps import json import logging @@ -9,6 +10,7 @@ import io import pathlib import urllib.parse +import uuid from alive_progress import alive_bar from typing import Any from application.utils import oscal_utils, redis @@ -66,6 +68,64 @@ class SupportedFormats(Enum): JSON = "json" YAML = "yaml" OSCAL = "oscal" + CycloneDX = "cyclonedx" + + +def _document_attestation_url(document: defs.Document) -> str: + if document.doctype == defs.Credoctypes.CRE and document.id: + return f"https://www.opencre.org/cre/{document.id}" + if getattr(document, "hyperlink", ""): + return document.hyperlink + return f"https://www.opencre.org/node/{document.doctype.value.lower()}/{urllib.parse.quote(document.name)}" + + +def _document_to_cyclonedx_component(document: defs.Document) -> dict[str, Any]: + component = { + "type": "data", + "name": document.name, + "bom-ref": f"{document.doctype.value}:{document.id or document.name}", + "properties": [ + {"name": "opencre:doctype", "value": document.doctype.value}, + {"name": "opencre:id", "value": str(document.id or "")}, + ], + "externalReferences": [ + { + "type": "attestation", + "url": _document_attestation_url(document), + "comment": "OpenCRE source attestation", + } + ], + } + if document.description: + component["description"] = document.description + if document.tags: + component["properties"].append( + {"name": "opencre:tags", "value": ",".join(document.tags)} + ) + return component + + +def _documents_to_cyclonedx(documents: list[defs.Document]) -> dict[str, Any]: + return { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{uuid.uuid4()}", + "version": 1, + "metadata": { + "timestamp": datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z"), + "component": { + "type": "application", + "name": "OpenCRE", + "externalReferences": [ + {"type": "website", "url": "https://www.opencre.org/"} + ], + }, + }, + "components": [_document_to_cyclonedx_component(doc) for doc in documents], + } def extend_cre_with_tag_links( @@ -137,6 +197,8 @@ def find_cre(creid: str = None, crename: str = None) -> Any: # refer elif opt_format == SupportedFormats.OSCAL.value: result = {"data": json.loads(oscal_utils.document_to_oscal(cre))} + elif opt_format == SupportedFormats.CycloneDX.value: + result = _documents_to_cyclonedx([cre]) return jsonify(result) abort(404, "CRE does not exist") @@ -227,6 +289,8 @@ def find_node_by_name( elif opt_format == SupportedFormats.OSCAL.value: return jsonify(json.loads(oscal_utils.list_to_oscal(nodes))) + elif opt_format == SupportedFormats.CycloneDX.value: + return jsonify(_documents_to_cyclonedx(nodes)) # if opt_osib: # result["osib"] = odefs.cre2osib(nodes).todict() @@ -263,6 +327,8 @@ def find_document_by_tag() -> Any: return write_csv(docs=docs).getvalue().encode("utf-8") elif opt_format == SupportedFormats.OSCAL.value: return jsonify(json.loads(oscal_utils.list_to_oscal(documents))) + elif opt_format == SupportedFormats.CycloneDX.value: + return jsonify(_documents_to_cyclonedx(documents)) return jsonify(result) abort(404, "Tag does not exist") @@ -423,6 +489,8 @@ def text_search() -> Any: return write_csv(docs=docs).getvalue().encode("utf-8") elif opt_format == SupportedFormats.OSCAL.value: return jsonify(json.loads(oscal_utils.list_to_oscal(documents))) + elif opt_format == SupportedFormats.CycloneDX.value: + return jsonify(_documents_to_cyclonedx(documents)) res = [doc.todict() for doc in documents] return jsonify(res) @@ -457,6 +525,8 @@ def find_root_cres() -> Any: return write_csv(docs=docs).getvalue().encode("utf-8") elif opt_format == SupportedFormats.OSCAL.value: return jsonify(json.loads(oscal_utils.list_to_oscal(documents))) + elif opt_format == SupportedFormats.CycloneDX.value: + return jsonify(_documents_to_cyclonedx(documents)) return jsonify(result) abort(404, "No root CREs") diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 73ff48c83..0ef44e209 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26,7 +26,7 @@ paths: description: Optional response format schema: type: string - enum: [json, md, csv, oscal] + enum: [json, md, csv, oscal, cyclonedx] - name: include_only in: query required: false @@ -64,7 +64,7 @@ paths: required: false schema: type: string - enum: [json, md, csv, oscal] + enum: [json, md, csv, oscal, cyclonedx] responses: '200': description: CRE found @@ -124,7 +124,7 @@ paths: required: false schema: type: string - enum: [json, md, csv, oscal] + enum: [json, md, csv, oscal, cyclonedx] responses: '200': description: Nodes retrieved @@ -153,7 +153,7 @@ paths: required: false schema: type: string - enum: [json, md, csv, oscal] + enum: [json, md, csv, oscal, cyclonedx] responses: '200': description: Documents retrieved @@ -174,7 +174,7 @@ paths: required: false schema: type: string - enum: [json, md, csv, oscal] + enum: [json, md, csv, oscal, cyclonedx] responses: '200': description: Root CREs retrieved @@ -215,7 +215,7 @@ paths: required: false schema: type: string - enum: [json, md, csv, oscal] + enum: [json, md, csv, oscal, cyclonedx] responses: '200': description: Search results From 9cd59868036ce493e525209854288d6cb0f92c32 Mon Sep 17 00:00:00 2001 From: Akshat Pal <118915075+akshat4703@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:01:43 +0530 Subject: [PATCH 2/7] Update application/web/web_main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Akshat Pal <118915075+akshat4703@users.noreply.github.com> --- application/web/web_main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/application/web/web_main.py b/application/web/web_main.py index 0ccda973c..df7510c71 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -100,7 +100,10 @@ def _document_to_cyclonedx_component(document: defs.Document) -> dict[str, Any]: component["description"] = document.description if document.tags: component["properties"].append( - {"name": "opencre:tags", "value": ",".join(document.tags)} + { + "name": "opencre:tags", + "value": ",".join(tag for tag in document.tags if tag), + } ) return component From 1dc11ef5c57565f334fe461b62dbca4db8a10c5d Mon Sep 17 00:00:00 2001 From: Akshat Pal <118915075+akshat4703@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:02:30 +0530 Subject: [PATCH 3/7] Update application/tests/web_main_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Akshat Pal <118915075+akshat4703@users.noreply.github.com> --- application/tests/web_main_test.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 28d5024a3..076b81c28 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -405,8 +405,15 @@ def test_find_node_by_name(self) -> None: self.assertEqual(200, cdx_response.status_code) self.assertEqual("CycloneDX", cdx_payload["bomFormat"]) self.assertEqual(5, len(cdx_payload["components"])) - self.assertEqual( - "Standard", cdx_payload["components"][0]["properties"][0]["value"] + self.assertTrue( + any( + any( + prop.get("name") == "opencre:doctype" + and prop.get("value") == "Standard" + for prop in component.get("properties", []) + ) + for component in cdx_payload["components"] + ) ) def test_find_document_by_tag(self) -> None: From df0e59aab8294e59f3edb1e4636b4399e81ac3f0 Mon Sep 17 00:00:00 2001 From: Akshat Pal <118915075+akshat4703@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:02:45 +0530 Subject: [PATCH 4/7] Update application/tests/web_main_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Akshat Pal <118915075+akshat4703@users.noreply.github.com> --- application/tests/web_main_test.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 076b81c28..0944089bc 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -172,10 +172,15 @@ def test_find_by_id(self) -> None: self.assertEqual(200, cdx_response.status_code) self.assertEqual("CycloneDX", cdx_payload["bomFormat"]) self.assertTrue(len(cdx_payload["components"]) == 1) - self.assertEqual("CD", cdx_payload["components"][0]["name"]) - self.assertEqual( - "attestation", - cdx_payload["components"][0]["externalReferences"][0]["type"], + components = cdx_payload["components"] + component_cd = next( + (c for c in components if c.get("name") == "CD"), + None, + ) + self.assertIsNotNone(component_cd) + external_references = component_cd.get("externalReferences", []) + self.assertTrue( + any(ref.get("type") == "attestation" for ref in external_references) ) def test_find_by_name(self) -> None: From 9bfc941c8cf7a21c9833d1f8917f033321be7322 Mon Sep 17 00:00:00 2001 From: Akshat Pal <118915075+akshat4703@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:03:27 +0530 Subject: [PATCH 5/7] Update application/web/web_main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Akshat Pal <118915075+akshat4703@users.noreply.github.com> --- application/web/web_main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/web/web_main.py b/application/web/web_main.py index df7510c71..ebefc43f3 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -76,7 +76,8 @@ def _document_attestation_url(document: defs.Document) -> str: return f"https://www.opencre.org/cre/{document.id}" if getattr(document, "hyperlink", ""): return document.hyperlink - return f"https://www.opencre.org/node/{document.doctype.value.lower()}/{urllib.parse.quote(document.name)}" + identifier = str(document.id) if getattr(document, "id", None) else document.name + return f"https://www.opencre.org/node/{document.doctype.value.lower()}/{urllib.parse.quote(identifier)}" def _document_to_cyclonedx_component(document: defs.Document) -> dict[str, Any]: From 0f27de662de30d04afeacadaeda081694ad9ec92 Mon Sep 17 00:00:00 2001 From: Akshat Pal <118915075+akshat4703@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:03:35 +0530 Subject: [PATCH 6/7] Update application/web/web_main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Akshat Pal <118915075+akshat4703@users.noreply.github.com> --- application/web/web_main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/web/web_main.py b/application/web/web_main.py index ebefc43f3..760532a5e 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -203,6 +203,8 @@ def find_cre(creid: str = None, crename: str = None) -> Any: # refer result = {"data": json.loads(oscal_utils.document_to_oscal(cre))} elif opt_format == SupportedFormats.CycloneDX.value: result = _documents_to_cyclonedx([cre]) + elif opt_format is not None: + abort(400, "Unsupported format") return jsonify(result) abort(404, "CRE does not exist") From d485b676c4cf4f5b033e1b48f1c26693a79dd113 Mon Sep 17 00:00:00 2001 From: akshat4703 Date: Thu, 26 Feb 2026 12:11:02 +0530 Subject: [PATCH 7/7] copilot-review-changes --- .github/workflows/e2e.yml | 60 ++++++++-------- application/tests/web_main_test.py | 18 +++++ docs/api/openapi.yaml | 106 ++++++++++++++++++++++++++--- 3 files changed, 143 insertions(+), 41 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9fdf99146..688a95ede 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,30 +1,30 @@ -# name: Test-e2e -# on: [push, pull_request] -# jobs: -# build: -# name: Test-e2e -# runs-on: ubuntu-latest -# timeout-minutes: 10 -# steps: -# - name: Check out code -# uses: actions/checkout@v4 -# - uses: actions/setup-python@v4 -# with: -# python-version: '3.11.4' -# cache: 'pip' -# - uses: actions/setup-node@v3 -# with: -# cache: 'yarn' -# node-version: 'v20.12.1' -# - name: Install dependencies -# run: | -# sudo apt-get update -# sudo apt-get install -y python3-setuptools python3-pip python3-virtualenv chromium-browser libgbm1 -# make install -# - name: DB setup -# run: | -# make migrate-upgrade -# python cre.py --upstream_sync -# # - name: Run app and e2e tests - # run: | - # make e2e +name: Test-e2e +on: [push, pull_request] +jobs: + build: + name: Test-e2e + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out code + uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: '3.11.4' + cache: 'pip' + - uses: actions/setup-node@v3 + with: + cache: 'yarn' + node-version: 'v20.12.1' + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y python3-setuptools python3-pip python3-virtualenv chromium-browser libgbm1 + make install + - name: DB setup + run: | + make migrate-upgrade + python cre.py --upstream_sync + # - name: Run app and e2e tests + run: | + make e2e diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 0944089bc..0b9d74526 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -443,6 +443,12 @@ def test_find_document_by_tag(self) -> None: self.assertEqual(200, response.status_code) self.assertCountEqual(json.loads(response.data.decode()), expected) + cdx_response = client.get("/rest/v1/tags?tag=ta&format=cyclonedx") + cdx_payload = json.loads(cdx_response.data.decode()) + self.assertEqual(200, cdx_response.status_code) + self.assertEqual("CycloneDX", cdx_payload["bomFormat"]) + self.assertEqual(2, len(cdx_payload["components"])) + def test_test_search(self) -> None: collection = db.Node_collection() docs = { @@ -479,6 +485,12 @@ def test_test_search(self) -> None: self.assertEqual(200, resp.status_code) self.assertDictEqual(resp.json[0], expected[0]) + cdx_response = client.get("/rest/v1/text_search?text=SB&format=cyclonedx") + cdx_payload = json.loads(cdx_response.data.decode()) + self.assertEqual(200, cdx_response.status_code) + self.assertEqual("CycloneDX", cdx_payload["bomFormat"]) + self.assertEqual(1, len(cdx_payload["components"])) + def test_find_root_cres(self) -> None: self.maxDiff = None collection = db.Node_collection().with_graph() @@ -522,6 +534,12 @@ def test_find_root_cres(self) -> None: self.assertEqual(json.loads(response.data.decode()), expected) self.assertEqual(200, response.status_code) + cdx_response = client.get("/rest/v1/root_cres?format=cyclonedx") + cdx_payload = json.loads(cdx_response.data.decode()) + self.assertEqual(200, cdx_response.status_code) + self.assertEqual("CycloneDX", cdx_payload["bomFormat"]) + self.assertEqual(2, len(cdx_payload["components"])) + def test_smartlink(self) -> None: self.maxDiff = None collection = db.Node_collection().with_graph() diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0ef44e209..2b9fa886b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41,10 +41,24 @@ paths: content: application/json: schema: - type: object - properties: - data: - type: object + oneOf: + - type: object + properties: + data: + type: object + - type: object + properties: + bomFormat: + type: string + enum: [CycloneDX] + specVersion: + type: string + version: + type: integer + components: + type: array + items: + type: object '404': description: CRE not found @@ -71,7 +85,21 @@ paths: content: application/json: schema: - type: object + oneOf: + - type: object + - type: object + properties: + bomFormat: + type: string + enum: [CycloneDX] + specVersion: + type: string + version: + type: integer + components: + type: array + items: + type: object '404': description: CRE not found @@ -131,7 +159,21 @@ paths: content: application/json: schema: - type: object + oneOf: + - type: object + - type: object + properties: + bomFormat: + type: string + enum: [CycloneDX] + specVersion: + type: string + version: + type: integer + components: + type: array + items: + type: object '404': description: Node not found @@ -160,7 +202,21 @@ paths: content: application/json: schema: - type: object + oneOf: + - type: object + - type: object + properties: + bomFormat: + type: string + enum: [CycloneDX] + specVersion: + type: string + version: + type: integer + components: + type: array + items: + type: object '404': description: Tag not found @@ -181,7 +237,21 @@ paths: content: application/json: schema: - type: object + oneOf: + - type: object + - type: object + properties: + bomFormat: + type: string + enum: [CycloneDX] + specVersion: + type: string + version: + type: integer + components: + type: array + items: + type: object '404': description: No root CREs found @@ -222,8 +292,22 @@ paths: content: application/json: schema: - type: array - items: - type: object + oneOf: + - type: array + items: + type: object + - type: object + properties: + bomFormat: + type: string + enum: [CycloneDX] + specVersion: + type: string + version: + type: integer + components: + type: array + items: + type: object '404': description: No results found