Skip to content

Commit fdd8471

Browse files
committed
Merge OpenCRE direct map analysis behavior into issue OWASP#471 core review
1 parent 3cf7c68 commit fdd8471

2 files changed

Lines changed: 309 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
@@ -876,6 +877,195 @@ def test_gap_analysis_returns_direct_cre_overlap_when_backends_fail(
876877
self.assertIn(compare.id, payload["result"][base.id]["paths"])
877878
db_mock.return_value.add_gap_analysis_result.assert_called_once()
878879

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

application/web/web_main.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
],
8787
}
8888
ROOT_CRES_FEATURED_STANDARD_LIMIT = 2
89+
OPENCRE_STANDARD_NAME = "OpenCRE"
8990
LLM_TOP10_STANDARD_NAME = "OWASP Top 10 for LLM and Gen AI Apps 2025"
9091
AISVS_STANDARD_NAME = "OWASP AI Security Verification Standard (AISVS)"
9192
API_TOP10_STANDARD_NAME = "OWASP API Security Top 10 2023"
@@ -271,6 +272,114 @@ def _fetch_upstream_map_analysis(
271272
return result
272273

273274

275+
def _get_opencre_documents(collection: db.Node_collection) -> list[defs.CRE]:
276+
return [
277+
collection.get_CREs(internal_id=cre.id)[0]
278+
for cre in collection.session.query(db.CRE).all()
279+
]
280+
281+
282+
def _get_opencre_map_analysis_documents(
283+
standard: str, collection: db.Node_collection
284+
) -> list[defs.Document]:
285+
if standard == OPENCRE_STANDARD_NAME:
286+
return _get_opencre_documents(collection)
287+
return collection.get_nodes(name=standard)
288+
289+
290+
def _build_opencre_direct_link_path(
291+
start_document: defs.Document, end_document: defs.Document
292+
) -> dict[str, Any]:
293+
segment_start = start_document.shallow_copy()
294+
# Keep the direct-link response compatible with the current popup renderer.
295+
if segment_start.doctype != defs.Credoctypes.CRE.value:
296+
segment_start.id = ""
297+
return {
298+
"end": end_document.shallow_copy(),
299+
"path": [
300+
{
301+
"start": segment_start,
302+
"end": end_document.shallow_copy(),
303+
"relationship": "LINKED_TO",
304+
"score": 0,
305+
}
306+
],
307+
"score": 0,
308+
}
309+
310+
311+
def _make_opencre_direct_link_path_key(end_document: defs.Document) -> str:
312+
return end_document.id
313+
314+
315+
def _add_opencre_direct_link_result(
316+
grouped_paths: dict[str, dict[str, Any]],
317+
start_document: defs.Document,
318+
end_document: defs.Document,
319+
) -> None:
320+
shared_paths = grouped_paths.setdefault(
321+
start_document.id,
322+
{
323+
"start": start_document.shallow_copy(),
324+
"paths": {},
325+
"extra": 0,
326+
},
327+
)["paths"]
328+
shared_paths.setdefault(
329+
_make_opencre_direct_link_path_key(end_document),
330+
_build_opencre_direct_link_path(start_document, end_document),
331+
)
332+
333+
334+
def _build_opencre_direct_map_analysis(
335+
standards: list[str],
336+
standards_hash: str,
337+
collection: db.Node_collection,
338+
) -> dict[str, Any] | None:
339+
if len(standards) < 2:
340+
return None
341+
342+
base_standard = standards[0]
343+
compare_standard = standards[1]
344+
base_nodes = _get_opencre_map_analysis_documents(base_standard, collection)
345+
compare_nodes = _get_opencre_map_analysis_documents(compare_standard, collection)
346+
if not base_nodes or not compare_nodes:
347+
return None
348+
349+
base_is_opencre = base_standard == OPENCRE_STANDARD_NAME
350+
opencre_nodes = base_nodes if base_is_opencre else compare_nodes
351+
standard_nodes = compare_nodes if base_is_opencre else base_nodes
352+
353+
standard_nodes_by_id = {
354+
standard_node.id: standard_node for standard_node in standard_nodes
355+
}
356+
direct_pairs: list[tuple[defs.CRE, defs.Document]] = []
357+
for opencre_node in opencre_nodes:
358+
for link in opencre_node.links:
359+
if link.ltype != defs.LinkTypes.LinkedTo:
360+
continue
361+
standard_node = standard_nodes_by_id.get(link.document.id)
362+
if not standard_node:
363+
continue
364+
direct_pairs.append((opencre_node, standard_node))
365+
366+
grouped_paths: dict[str, dict[str, Any]] = {}
367+
for opencre_node, standard_node in direct_pairs:
368+
if base_is_opencre:
369+
_add_opencre_direct_link_result(grouped_paths, opencre_node, standard_node)
370+
else:
371+
_add_opencre_direct_link_result(grouped_paths, standard_node, opencre_node)
372+
373+
if not grouped_paths:
374+
return None
375+
376+
result = {"result": grouped_paths}
377+
collection.add_gap_analysis_result(
378+
cache_key=standards_hash, ga_object=flask_json.dumps(result)
379+
)
380+
return result
381+
382+
274383
def _build_direct_cre_overlap_map_analysis(
275384
standards: list[str],
276385
standards_hash: str,
@@ -555,6 +664,13 @@ def map_analysis() -> Any:
555664

556665
database = db.Node_collection()
557666
standards_hash = gap_analysis.make_resources_key(standards)
667+
if OPENCRE_STANDARD_NAME in standards:
668+
direct_gap_analysis = _build_opencre_direct_map_analysis(
669+
standards, standards_hash, database
670+
)
671+
if direct_gap_analysis:
672+
return jsonify(direct_gap_analysis)
673+
abort(404, "No direct overlap found for requested standards")
558674
owasp_top10_comparison = _build_owasp_top10_comparison(standards, database)
559675

560676
# First, check if we have cached results in the database
@@ -748,7 +864,9 @@ def standards() -> Any:
748864
posthog.capture(f"standards", "")
749865

750866
database = db.Node_collection()
751-
standards = database.standards()
867+
standards = list(database.standards())
868+
if OPENCRE_STANDARD_NAME not in standards:
869+
standards.append(OPENCRE_STANDARD_NAME)
752870
return standards
753871

754872

0 commit comments

Comments
 (0)