Skip to content

Commit 30d6b06

Browse files
committed
Merge OpenCRE direct map analysis behavior into issue OWASP#471 specialized review
1 parent 58e1d87 commit 30d6b06

2 files changed

Lines changed: 311 additions & 1 deletion

File tree

application/tests/web_main_test.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import unittest
99
import tempfile
10+
from types import SimpleNamespace
1011
from unittest.mock import patch
1112

1213
import redis
@@ -902,6 +903,195 @@ def test_gap_analysis_returns_direct_cre_overlap_when_backends_fail(
902903
self.assertIn(compare.id, payload["result"][base.id]["paths"])
903904
db_mock.return_value.add_gap_analysis_result.assert_called_once()
904905

906+
@patch.object(web_main.gap_analysis, "schedule")
907+
@patch.object(db, "Node_collection")
908+
def test_gap_analysis_supports_opencre_as_standard(
909+
self, db_mock, schedule_mock
910+
) -> None:
911+
compare = defs.Standard(
912+
name="OWASP Web Security Testing Guide (WSTG)",
913+
section="WSTG-CRYP-04",
914+
)
915+
opencre = defs.CRE(id="170-772", name="Cryptography", description="")
916+
opencre.add_link(
917+
defs.Link(ltype=defs.LinkTypes.LinkedTo, document=compare.shallow_copy())
918+
)
919+
920+
db_mock.return_value.get_gap_analysis_result.return_value = None
921+
db_mock.return_value.gap_analysis_exists.return_value = False
922+
db_mock.return_value.get_nodes.side_effect = lambda name=None, **kwargs: (
923+
[compare] if name == "OWASP Web Security Testing Guide (WSTG)" else []
924+
)
925+
db_mock.return_value.session.query.return_value.all.return_value = [
926+
SimpleNamespace(id="cre-internal-1")
927+
]
928+
db_mock.return_value.get_CREs.return_value = [opencre]
929+
930+
with self.app.test_client() as client:
931+
response = client.get(
932+
"/rest/v1/map_analysis?standard=OpenCRE&standard=OWASP%20Web%20Security%20Testing%20Guide%20(WSTG)",
933+
headers={"Content-Type": "application/json"},
934+
)
935+
936+
payload = json.loads(response.data)
937+
self.assertEqual(200, response.status_code)
938+
self.assertIn("result", payload)
939+
self.assertIn(opencre.id, payload["result"])
940+
self.assertEqual(1, len(payload["result"][opencre.id]["paths"]))
941+
path = next(iter(payload["result"][opencre.id]["paths"].values()))
942+
self.assertEqual(compare.id, path["end"]["id"])
943+
schedule_mock.assert_not_called()
944+
945+
@patch.object(web_main.gap_analysis, "schedule")
946+
@patch.object(db, "Node_collection")
947+
def test_gap_analysis_returns_only_direct_opencre_mappings(
948+
self, db_mock, schedule_mock
949+
) -> None:
950+
compare = defs.Standard(
951+
name="CWE",
952+
sectionID="1004",
953+
section="Sensitive Cookie Without 'HttpOnly' Flag",
954+
)
955+
direct_cre = defs.CRE(
956+
id="804-220",
957+
name="Set httponly attribute for cookie-based session tokens",
958+
description="",
959+
)
960+
direct_cre.add_link(
961+
defs.Link(ltype=defs.LinkTypes.LinkedTo, document=compare.shallow_copy())
962+
)
963+
auto_linked_cres = []
964+
for i, cre_id in enumerate(
965+
[
966+
"117-371",
967+
"166-151",
968+
"284-521",
969+
"368-633",
970+
"612-252",
971+
"664-080",
972+
"801-310",
973+
],
974+
start=1,
975+
):
976+
cre = defs.CRE(
977+
id=cre_id,
978+
name=f"Automatically mapped CRE {i}",
979+
description="",
980+
)
981+
cre.add_link(
982+
defs.Link(
983+
ltype=defs.LinkTypes.AutomaticallyLinkedTo,
984+
document=compare.shallow_copy(),
985+
)
986+
)
987+
auto_linked_cres.append(cre)
988+
989+
opencre_documents = [direct_cre] + auto_linked_cres
990+
internal_ids = [
991+
SimpleNamespace(id=f"cre-internal-{i}")
992+
for i in range(len(opencre_documents))
993+
]
994+
995+
db_mock.return_value.get_gap_analysis_result.return_value = None
996+
db_mock.return_value.gap_analysis_exists.return_value = False
997+
db_mock.return_value.get_nodes.side_effect = lambda name=None, **kwargs: (
998+
[compare] if name == "CWE" else []
999+
)
1000+
db_mock.return_value.session.query.return_value.all.return_value = internal_ids
1001+
db_mock.return_value.get_CREs.side_effect = lambda internal_id=None, **kwargs: [
1002+
next(
1003+
cre
1004+
for index, cre in enumerate(opencre_documents)
1005+
if internal_id == f"cre-internal-{index}"
1006+
)
1007+
]
1008+
1009+
with self.app.test_client() as client:
1010+
response = client.get(
1011+
"/rest/v1/map_analysis?standard=CWE&standard=OpenCRE",
1012+
headers={"Content-Type": "application/json"},
1013+
)
1014+
1015+
payload = json.loads(response.data)
1016+
self.assertEqual(200, response.status_code)
1017+
self.assertIn("result", payload)
1018+
self.assertEqual([compare.id], list(payload["result"].keys()))
1019+
self.assertEqual(1, len(payload["result"][compare.id]["paths"]))
1020+
path = next(iter(payload["result"][compare.id]["paths"].values()))
1021+
self.assertEqual(compare.id, payload["result"][compare.id]["start"]["id"])
1022+
self.assertEqual(direct_cre.id, path["end"]["id"])
1023+
self.assertEqual(direct_cre.name, path["end"]["name"])
1024+
self.assertEqual("", path["path"][0]["start"]["id"])
1025+
self.assertEqual(direct_cre.id, path["path"][0]["end"]["id"])
1026+
schedule_mock.assert_not_called()
1027+
1028+
@patch.object(web_main.gap_analysis, "schedule")
1029+
@patch.object(db, "Node_collection")
1030+
def test_gap_analysis_returns_only_direct_opencre_mappings_when_opencre_is_left(
1031+
self, db_mock, schedule_mock
1032+
) -> None:
1033+
compare = defs.Standard(
1034+
name="CWE",
1035+
sectionID="1004",
1036+
section="Sensitive Cookie Without 'HttpOnly' Flag",
1037+
)
1038+
direct_cre = defs.CRE(
1039+
id="804-220",
1040+
name="Set httponly attribute for cookie-based session tokens",
1041+
description="",
1042+
)
1043+
direct_cre.add_link(
1044+
defs.Link(ltype=defs.LinkTypes.LinkedTo, document=compare.shallow_copy())
1045+
)
1046+
indirect_cre = defs.CRE(
1047+
id="117-371",
1048+
name="Use a centralized access control mechanism",
1049+
description="",
1050+
)
1051+
indirect_cre.add_link(
1052+
defs.Link(
1053+
ltype=defs.LinkTypes.AutomaticallyLinkedTo,
1054+
document=compare.shallow_copy(),
1055+
)
1056+
)
1057+
1058+
opencre_documents = [direct_cre, indirect_cre]
1059+
internal_ids = [
1060+
SimpleNamespace(id=f"cre-internal-{i}")
1061+
for i in range(len(opencre_documents))
1062+
]
1063+
1064+
db_mock.return_value.get_gap_analysis_result.return_value = None
1065+
db_mock.return_value.gap_analysis_exists.return_value = False
1066+
db_mock.return_value.get_nodes.side_effect = lambda name=None, **kwargs: (
1067+
[compare] if name == "CWE" else []
1068+
)
1069+
db_mock.return_value.session.query.return_value.all.return_value = internal_ids
1070+
db_mock.return_value.get_CREs.side_effect = lambda internal_id=None, **kwargs: [
1071+
next(
1072+
cre
1073+
for index, cre in enumerate(opencre_documents)
1074+
if internal_id == f"cre-internal-{index}"
1075+
)
1076+
]
1077+
1078+
with self.app.test_client() as client:
1079+
response = client.get(
1080+
"/rest/v1/map_analysis?standard=OpenCRE&standard=CWE",
1081+
headers={"Content-Type": "application/json"},
1082+
)
1083+
1084+
payload = json.loads(response.data)
1085+
self.assertEqual(200, response.status_code)
1086+
self.assertEqual([direct_cre.id], list(payload["result"].keys()))
1087+
self.assertEqual(1, len(payload["result"][direct_cre.id]["paths"]))
1088+
path = next(iter(payload["result"][direct_cre.id]["paths"].values()))
1089+
self.assertEqual(direct_cre.id, payload["result"][direct_cre.id]["start"]["id"])
1090+
self.assertEqual(compare.id, path["end"]["id"])
1091+
self.assertEqual(direct_cre.id, path["path"][0]["start"]["id"])
1092+
self.assertEqual(compare.id, path["path"][0]["end"]["id"])
1093+
schedule_mock.assert_not_called()
1094+
9051095
@patch.object(web_main.cre_main, "fetch_upstream_json")
9061096
@patch.object(web_main.gap_analysis, "schedule")
9071097
@patch.object(db, "Node_collection")

application/web/web_main.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949

5050
ITEMS_PER_PAGE = 20
51+
OPENCRE_STANDARD_NAME = "OpenCRE"
5152
OWASP_TOP10_2025_DATA_FILE = (
5253
pathlib.Path(__file__).resolve().parent.parent
5354
/ "utils"
@@ -314,6 +315,116 @@ def _fetch_upstream_map_analysis(
314315
return result
315316

316317

318+
def _get_opencre_documents(collection: db.Node_collection) -> list[defs.CRE]:
319+
return [
320+
collection.get_CREs(internal_id=cre.id)[0]
321+
for cre in collection.session.query(db.CRE).all()
322+
]
323+
324+
325+
def _get_opencre_map_analysis_documents(
326+
standard: str, collection: db.Node_collection
327+
) -> list[defs.Document]:
328+
if standard == OPENCRE_STANDARD_NAME:
329+
return _get_opencre_documents(collection)
330+
return collection.get_nodes(name=standard)
331+
332+
333+
def _build_opencre_direct_link_path(
334+
start_document: defs.Document, end_document: defs.Document
335+
) -> dict[str, Any]:
336+
segment_start = start_document.shallow_copy()
337+
# The current gap-analysis popup mutates non-CRE row ids during display
338+
# before it resolves the one-step direct path. Keep the OpenCRE direct-link
339+
# response compatible with that renderer.
340+
if segment_start.doctype != defs.Credoctypes.CRE.value:
341+
segment_start.id = ""
342+
return {
343+
"end": end_document.shallow_copy(),
344+
"path": [
345+
{
346+
"start": segment_start,
347+
"end": end_document.shallow_copy(),
348+
"relationship": "LINKED_TO",
349+
"score": 0,
350+
}
351+
],
352+
"score": 0,
353+
}
354+
355+
356+
def _make_opencre_direct_link_path_key(end_document: defs.Document) -> str:
357+
return end_document.id
358+
359+
360+
def _add_opencre_direct_link_result(
361+
grouped_paths: dict[str, dict[str, Any]],
362+
start_document: defs.Document,
363+
end_document: defs.Document,
364+
) -> None:
365+
shared_paths = grouped_paths.setdefault(
366+
start_document.id,
367+
{
368+
"start": start_document.shallow_copy(),
369+
"paths": {},
370+
"extra": 0,
371+
},
372+
)["paths"]
373+
shared_paths.setdefault(
374+
_make_opencre_direct_link_path_key(end_document),
375+
_build_opencre_direct_link_path(start_document, end_document),
376+
)
377+
378+
379+
def _build_opencre_direct_map_analysis(
380+
standards: list[str],
381+
standards_hash: str,
382+
collection: db.Node_collection,
383+
) -> dict[str, Any] | None:
384+
if len(standards) < 2:
385+
return None
386+
387+
base_standard = standards[0]
388+
compare_standard = standards[1]
389+
base_nodes = _get_opencre_map_analysis_documents(base_standard, collection)
390+
compare_nodes = _get_opencre_map_analysis_documents(compare_standard, collection)
391+
if not base_nodes or not compare_nodes:
392+
return None
393+
394+
base_is_opencre = base_standard == OPENCRE_STANDARD_NAME
395+
opencre_nodes = base_nodes if base_is_opencre else compare_nodes
396+
standard_nodes = compare_nodes if base_is_opencre else base_nodes
397+
398+
standard_nodes_by_id = {
399+
standard_node.id: standard_node for standard_node in standard_nodes
400+
}
401+
direct_pairs: list[tuple[defs.CRE, defs.Document]] = []
402+
for opencre_node in opencre_nodes:
403+
for link in opencre_node.links:
404+
if link.ltype != defs.LinkTypes.LinkedTo:
405+
continue
406+
standard_node = standard_nodes_by_id.get(link.document.id)
407+
if not standard_node:
408+
continue
409+
direct_pairs.append((opencre_node, standard_node))
410+
411+
grouped_paths: dict[str, dict[str, Any]] = {}
412+
for opencre_node, standard_node in direct_pairs:
413+
if base_is_opencre:
414+
_add_opencre_direct_link_result(grouped_paths, opencre_node, standard_node)
415+
else:
416+
_add_opencre_direct_link_result(grouped_paths, standard_node, opencre_node)
417+
418+
if not grouped_paths:
419+
return None
420+
421+
result = {"result": grouped_paths}
422+
collection.add_gap_analysis_result(
423+
cache_key=standards_hash, ga_object=flask_json.dumps(result)
424+
)
425+
return result
426+
427+
317428
def _build_direct_cre_overlap_map_analysis(
318429
standards: list[str],
319430
standards_hash: str,
@@ -662,6 +773,13 @@ def map_analysis() -> Any:
662773

663774
database = db.Node_collection()
664775
standards_hash = gap_analysis.make_resources_key(standards)
776+
if OPENCRE_STANDARD_NAME in standards:
777+
direct_gap_analysis = _build_opencre_direct_map_analysis(
778+
standards, standards_hash, database
779+
)
780+
if direct_gap_analysis:
781+
return jsonify(direct_gap_analysis)
782+
abort(404, "No direct overlap found for requested standards")
665783
owasp_top10_comparison = _build_owasp_top10_comparison(standards, database)
666784

667785
# First, check if we have cached results in the database
@@ -918,7 +1036,9 @@ def standards() -> Any:
9181036
posthog.capture(f"standards", "")
9191037

9201038
database = db.Node_collection()
921-
standards = database.standards()
1039+
standards = list(database.standards())
1040+
if OPENCRE_STANDARD_NAME not in standards:
1041+
standards.append(OPENCRE_STANDARD_NAME)
9221042
return standards
9231043

9241044

0 commit comments

Comments
 (0)