From e2baf3a03a496717e7cbc6393e63ffee8f0cb56c Mon Sep 17 00:00:00 2001 From: PRAteek-singHWY Date: Fri, 6 Mar 2026 11:46:53 +0530 Subject: [PATCH 1/2] feat(supported-documents): add discoverable supported standards list --- application/database/db.py | 16 ++++ application/frontend/src/const.ts | 1 + .../SupportedDocuments.scss | 46 ++++++++++ .../SupportedDocuments/SupportedDocuments.tsx | 87 +++++++++++++++++++ application/frontend/src/routes.tsx | 7 ++ .../src/scaffolding/Header/Header.tsx | 13 +++ application/tests/db_test.py | 17 ++++ application/tests/web_main_test.py | 14 +++ application/web/web_main.py | 9 ++ docs/api/openapi.yaml | 18 ++++ 10 files changed, 228 insertions(+) create mode 100644 application/frontend/src/pages/SupportedDocuments/SupportedDocuments.scss create mode 100644 application/frontend/src/pages/SupportedDocuments/SupportedDocuments.tsx diff --git a/application/database/db.py b/application/database/db.py index 6c1613277..9900f1652 100644 --- a/application/database/db.py +++ b/application/database/db.py @@ -1798,6 +1798,22 @@ def standards(self) -> List[str]: ) return list(set([s[0] for s in standards])) + def supported_documents(self) -> Dict[str, List[str]]: + documents_by_type: Dict[str, set[str]] = {} + for ntype, name in ( + self.session.query(Node.ntype, Node.name) + .filter(Node.ntype.isnot(None), Node.name.isnot(None)) + .distinct() + ): + if not ntype or not name: + continue + documents_by_type.setdefault(ntype, set()).add(name) + + return { + ntype: sorted(list(names), key=lambda value: value.lower()) + for ntype, names in sorted(documents_by_type.items(), key=lambda item: item[0].lower()) + } + def text_search(self, text: str) -> List[Optional[cre_defs.Document]]: """Given a piece of text, tries to find the best match for the text in the database. diff --git a/application/frontend/src/const.ts b/application/frontend/src/const.ts index b15b3ddca..7e91224de 100644 --- a/application/frontend/src/const.ts +++ b/application/frontend/src/const.ts @@ -37,5 +37,6 @@ export const GRAPH = '/graph'; export const BROWSEROOT = '/root_cres'; export const GAP_ANALYSIS = '/map_analysis'; export const EXPLORER = '/explorer'; +export const SUPPORTED_DOCUMENTS = '/supported_documents'; export const GA_STRONG_UPPER_LIMIT = 2; // remember to change this in the Python code too diff --git a/application/frontend/src/pages/SupportedDocuments/SupportedDocuments.scss b/application/frontend/src/pages/SupportedDocuments/SupportedDocuments.scss new file mode 100644 index 000000000..bd4771322 --- /dev/null +++ b/application/frontend/src/pages/SupportedDocuments/SupportedDocuments.scss @@ -0,0 +1,46 @@ +main#supported-documents { + padding: 30px; + margin: var(--header-height) 0; + color: #111111; + + .supported-documents__summary { + max-width: 800px; + margin-bottom: 1.5rem; + } + + .supported-documents__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + } + + .supported-documents__card { + border: 1px solid #d4d4d4; + border-radius: 8px; + background: #fafafa; + padding: 1rem; + + h2 { + margin: 0 0 0.75rem 0; + font-size: 1.1rem; + } + + ul { + margin: 0; + padding-left: 1.1rem; + } + + a { + color: #0b5cab; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + li + li { + margin-top: 0.4rem; + } + } +} diff --git a/application/frontend/src/pages/SupportedDocuments/SupportedDocuments.tsx b/application/frontend/src/pages/SupportedDocuments/SupportedDocuments.tsx new file mode 100644 index 000000000..538c3a3f6 --- /dev/null +++ b/application/frontend/src/pages/SupportedDocuments/SupportedDocuments.tsx @@ -0,0 +1,87 @@ +import './SupportedDocuments.scss'; + +import axios from 'axios'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; +import { useEnvironment } from '../../hooks'; + +type SupportedDocumentsResponse = Record; + +export const SupportedDocuments = () => { + const { apiUrl } = useEnvironment(); + const [documentsByType, setDocumentsByType] = useState({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + setLoading(true); + window.scrollTo(0, 0); + + axios + .get(`${apiUrl}/supported_documents`) + .then((response) => { + if (!isMounted) return; + setError(null); + setDocumentsByType(response.data ?? {}); + }) + .catch((axiosError) => { + if (!isMounted) return; + setError(axiosError?.response?.data?.message ?? axiosError.message); + }) + .finally(() => { + if (isMounted) { + setLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [apiUrl]); + + const entries = useMemo( + () => Object.entries(documentsByType).sort(([left], [right]) => left.localeCompare(right)), + [documentsByType] + ); + + const total = useMemo( + () => entries.reduce((sum, [, names]) => sum + names.length, 0), + [entries] + ); + + const getDocumentPath = (doctype: string, name: string) => + `/node/${doctype.toLowerCase()}/${encodeURIComponent(name)}`; + + return ( +
+

Supported Standards and Documents

+

+ OpenCRE currently supports {total} unique document sources across {entries.length} document types. +

+ + + + {!loading && !error && ( +
+ {entries.map(([doctype, names]) => ( +
+

+ {doctype} ({names.length}) +

+
    + {names.map((name) => ( +
  • + {name} +
  • + ))} +
+
+ ))} +
+ )} +
+ ); +}; diff --git a/application/frontend/src/routes.tsx b/application/frontend/src/routes.tsx index 05edecbbd..418724635 100644 --- a/application/frontend/src/routes.tsx +++ b/application/frontend/src/routes.tsx @@ -12,6 +12,7 @@ import { SECTION_ID, STANDARD, SUBSECTION, + SUPPORTED_DOCUMENTS, } from './const'; import { CommonRequirementEnumeration, Graph, SearchPage, Standard } from './pages'; import { BrowseRootCres } from './pages/BrowseRootCres/browseRootCres'; @@ -24,6 +25,7 @@ import { MembershipRequired } from './pages/MembershipRequired/MembershipRequire import { MyOpenCRE } from './pages/MyOpenCRE/MyOpenCRE'; import { SearchName } from './pages/Search/SearchName'; import { StandardSection } from './pages/Standard/StandardSection'; +import { SupportedDocuments } from './pages/SupportedDocuments/SupportedDocuments'; export interface IRoute { path: string; @@ -125,4 +127,9 @@ export const ROUTES = (capabilities: Capabilities): IRoute[] => [ component: Explorer, showFilter: false, }, + { + path: `${SUPPORTED_DOCUMENTS}`, + component: SupportedDocuments, + showFilter: false, + }, ]; diff --git a/application/frontend/src/scaffolding/Header/Header.tsx b/application/frontend/src/scaffolding/Header/Header.tsx index 9f2cd117b..a4d74a85c 100644 --- a/application/frontend/src/scaffolding/Header/Header.tsx +++ b/application/frontend/src/scaffolding/Header/Header.tsx @@ -72,6 +72,10 @@ export const Header = ({ capabilities }: HeaderProps) => { Map Analysis + + Sources + + Explorer @@ -190,6 +194,15 @@ export const Header = ({ capabilities }: HeaderProps) => { Map Analysis + + Sources + + None: expected = [("Standard", "BarStand"), ("Standard", "Unlinked")] self.assertEqual(expected, result) + def test_supported_documents(self) -> None: + self.collection.session.add_all( + [ + db.Node(ntype=defs.Credoctypes.Tool.value, name="OWASP ZAP"), + db.Node(ntype=defs.Credoctypes.Tool.value, name="OWASP ZAP"), + db.Node(ntype=defs.Credoctypes.Code.value, name="NodeGoat"), + ] + ) + self.collection.session.commit() + + expected = { + defs.Credoctypes.Code.value: ["NodeGoat"], + defs.Credoctypes.Standard.value: ["BarStand", "Unlinked"], + defs.Credoctypes.Tool.value: ["OWASP ZAP"], + } + self.assertEqual(expected, self.collection.supported_documents()) + def test_get_max_internal_connections(self) -> None: self.assertEqual(self.collection.get_max_internal_connections(), 1) diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 9e219b4ce..b36795c1d 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -690,6 +690,20 @@ def test_standards_from_db(self, node_mock, redis_conn_mock) -> None: self.assertEqual(200, response.status_code) self.assertEqual(expected, json.loads(response.data)) + @patch.object(redis, "from_url") + @patch.object(db, "Node_collection") + def test_supported_documents_from_db(self, node_mock, redis_conn_mock) -> None: + expected = {"Standard": ["A"], "Tool": ["B"], "Code": ["C"]} + redis_conn_mock.return_value.get.return_value = None + node_mock.return_value.supported_documents.return_value = expected + with self.app.test_client() as client: + response = client.get( + "/rest/v1/supported_documents", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(200, response.status_code) + self.assertEqual(expected, json.loads(response.data)) + def test_gap_analysis_weak_links_no_cache(self) -> None: with self.app.test_client() as client: response = client.get( diff --git a/application/web/web_main.py b/application/web/web_main.py index 29567470a..5888e9ccc 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -442,6 +442,15 @@ def standards() -> Any: return standards +@app.route("/rest/v1/supported_documents", methods=["GET"]) +def supported_documents() -> Any: + if posthog: + posthog.capture(f"supported_documents", "") + + database = db.Node_collection() + return database.supported_documents() + + @app.route("/rest/v1/openapi.yaml", methods=["GET"]) def openapi_spec() -> Any: """ diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 87a5d5e52..02b68ebf0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -211,6 +211,24 @@ paths: items: type: string + /rest/v1/supported_documents: + get: + summary: List supported standards and documents + description: > + Retrieve unique node names grouped by document type (for example + Standard, Tool, or Code). + responses: + '200': + description: Supported document names grouped by type + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + type: string + /rest/v1/text_search: get: summary: Text search From e11619a54febfc120d60d40f640b4eb43b30b613 Mon Sep 17 00:00:00 2001 From: PRAteek-singHWY Date: Fri, 6 Mar 2026 11:50:11 +0530 Subject: [PATCH 2/2] style(python): apply black formatting for linter --- application/database/db.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/database/db.py b/application/database/db.py index 9900f1652..27a8a97fc 100644 --- a/application/database/db.py +++ b/application/database/db.py @@ -1811,7 +1811,9 @@ def supported_documents(self) -> Dict[str, List[str]]: return { ntype: sorted(list(names), key=lambda value: value.lower()) - for ntype, names in sorted(documents_by_type.items(), key=lambda item: item[0].lower()) + for ntype, names in sorted( + documents_by_type.items(), key=lambda item: item[0].lower() + ) } def text_search(self, text: str) -> List[Optional[cre_defs.Document]]: