From f808c4f0002d307352452d7fb3b25f958923d2da Mon Sep 17 00:00:00 2001 From: PRAteek-singHWY Date: Wed, 4 Mar 2026 02:29:17 +0530 Subject: [PATCH 1/2] feat(wayfinder): add product security wayfinder API and UI --- application/database/db.py | 33 +++ application/frontend/src/const.ts | 1 + .../src/pages/Wayfinder/Wayfinder.tsx | 234 ++++++++++++++++++ .../src/pages/Wayfinder/wayfinder.scss | 196 +++++++++++++++ application/frontend/src/routes.tsx | 7 + .../src/scaffolding/Header/Header.tsx | 11 + application/frontend/src/types.ts | 45 ++++ application/tests/web_main_test.py | 111 +++++++++ application/utils/wayfinder_metadata.py | 233 +++++++++++++++++ application/web/web_main.py | 122 ++++++++- docs/api/openapi.yaml | 67 +++++ 11 files changed, 1059 insertions(+), 1 deletion(-) create mode 100644 application/frontend/src/pages/Wayfinder/Wayfinder.tsx create mode 100644 application/frontend/src/pages/Wayfinder/wayfinder.scss create mode 100644 application/utils/wayfinder_metadata.py diff --git a/application/database/db.py b/application/database/db.py index 6c1613277..30ee58787 100644 --- a/application/database/db.py +++ b/application/database/db.py @@ -38,6 +38,7 @@ from application.utils import redis from application.defs import cre_defs from application.utils import file +from application.utils import wayfinder_metadata from application.utils.gap_analysis import ( get_path_score, make_resources_key, @@ -1798,6 +1799,38 @@ def standards(self) -> List[str]: ) return list(set([s[0] for s in standards])) + def wayfinder_resources(self) -> List[Dict[str, Any]]: + rows = ( + self.session.query( + Node.name, + Node.ntype, + func.count(Node.id).label("entry_count"), + func.min(Node.link).label("sample_hyperlink"), + ) + .group_by(Node.name, Node.ntype) + .order_by(func.lower(Node.ntype), func.lower(Node.name)) + .all() + ) + + resources: List[Dict[str, Any]] = [] + for row in rows: + name, ntype, entry_count, sample_hyperlink = row + metadata = wayfinder_metadata.get_wayfinder_metadata( + name=str(name), ntype=str(ntype) + ) + resource_id = f"{str(ntype).lower()}:{str(name)}" + resources.append( + { + "id": resource_id, + "name": name, + "doctype": ntype, + "entry_count": int(entry_count or 0), + "hyperlink": sample_hyperlink or "", + "metadata": metadata, + } + ) + return resources + 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..7317f2faa 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 WAYFINDER = '/wayfinder'; export const GA_STRONG_UPPER_LIMIT = 2; // remember to change this in the Python code too diff --git a/application/frontend/src/pages/Wayfinder/Wayfinder.tsx b/application/frontend/src/pages/Wayfinder/Wayfinder.tsx new file mode 100644 index 000000000..bcb949219 --- /dev/null +++ b/application/frontend/src/pages/Wayfinder/Wayfinder.tsx @@ -0,0 +1,234 @@ +import './wayfinder.scss'; + +import axios from 'axios'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Dropdown, Input, Label } from 'semantic-ui-react'; + +import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; +import { useEnvironment } from '../../hooks'; +import { WayfinderFacet, WayfinderResource, WayfinderResponse } from '../../types'; + +const toArray = ( + value: string | number | boolean | (string | number | boolean)[] | undefined +): string[] => { + if (!value) { + return []; + } + if (Array.isArray(value)) { + return value.map((entry) => String(entry)); + } + return [String(value)]; +}; + +const asOptions = (facets: WayfinderFacet[] = []) => + facets.map((facet) => ({ + key: facet.value, + text: `${facet.value} (${facet.count})`, + value: facet.value, + })); + +export const Wayfinder = () => { + const { apiUrl } = useEnvironment(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [response, setResponse] = useState(null); + + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [selectedSdlc, setSelectedSdlc] = useState([]); + const [selectedOrgs, setSelectedOrgs] = useState([]); + const [selectedLicenses, setSelectedLicenses] = useState([]); + const [selectedDoctypes, setSelectedDoctypes] = useState([]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedQuery(query.trim()); + }, 250); + return () => window.clearTimeout(timeoutId); + }, [query]); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(null); + try { + const params: Record = {}; + if (debouncedQuery) params.q = debouncedQuery; + if (selectedSdlc.length) params.sdlc = selectedSdlc; + if (selectedOrgs.length) params.supporting_org = selectedOrgs; + if (selectedLicenses.length) params.license = selectedLicenses; + if (selectedDoctypes.length) params.doctype = selectedDoctypes; + + const res = await axios.get(`${apiUrl}/wayfinder`, { params }); + setResponse(res.data); + } catch (err) { + console.error(err); + setError('Could not load wayfinder data'); + } finally { + setLoading(false); + } + }; + + load(); + }, [apiUrl, debouncedQuery, selectedDoctypes, selectedLicenses, selectedOrgs, selectedSdlc]); + + const stats = response?.stats; + const groups = response?.grouped_by_sdlc || []; + + const sdlcOptions = useMemo(() => asOptions(response?.facets?.sdlc), [response?.facets?.sdlc]); + const orgOptions = useMemo( + () => asOptions(response?.facets?.supporting_orgs), + [response?.facets?.supporting_orgs] + ); + const licenseOptions = useMemo( + () => asOptions(response?.facets?.licenses), + [response?.facets?.licenses] + ); + const doctypeOptions = useMemo( + () => asOptions(response?.facets?.doctypes), + [response?.facets?.doctypes] + ); + + const clearFilters = () => { + setQuery(''); + setSelectedSdlc([]); + setSelectedOrgs([]); + setSelectedLicenses([]); + setSelectedDoctypes([]); + }; + + const renderResourceCard = (resource: WayfinderResource) => ( +
+
+

{resource.name}

+ +
+ +
+
+ Supporting org: {resource.metadata.supporting_orgs.join(', ')} +
+
+ License: {resource.metadata.licenses.join(', ')} +
+
+ +
+ {resource.entry_count} mapped entries + {resource.hyperlink ? ( + + Open source + + ) : ( + No direct link + )} +
+
+ ); + + return ( +
+
+

Product Security Wayfinder

+

+ Explore standards and tools known to OpenCRE by SDLC stage, then narrow the view using metadata + facets. +

+
+
+ Total resources + {stats?.total_resources || 0} +
+
+ Filtered resources + {stats?.filtered_resources || 0} +
+
+ Mapped entries + {stats?.filtered_entries || 0} +
+
+
+ +
+ setQuery(event.target.value)} + /> +
+ setSelectedSdlc(toArray(data.value))} + /> + setSelectedOrgs(toArray(data.value))} + /> + setSelectedLicenses(toArray(data.value))} + /> + setSelectedDoctypes(toArray(data.value))} + /> +
+ +
+ + + + {!loading && !error && ( +
+ {groups.map((group) => ( +
+
+

{group.phase}

+ {group.resources.length} resources +
+ +
+ {group.resources.length > 0 ? ( + group.resources.map((resource) => renderResourceCard(resource)) + ) : ( +
No resources for this lane with current filters.
+ )} +
+
+ ))} +
+ )} +
+ ); +}; diff --git a/application/frontend/src/pages/Wayfinder/wayfinder.scss b/application/frontend/src/pages/Wayfinder/wayfinder.scss new file mode 100644 index 000000000..f08cef5d4 --- /dev/null +++ b/application/frontend/src/pages/Wayfinder/wayfinder.scss @@ -0,0 +1,196 @@ +#wayfinder-content { + --wf-bg: #f4f6f3; + --wf-card: #ffffff; + --wf-border: #d4dfd8; + --wf-text: #13362f; + --wf-muted: #4f6a5f; + --wf-accent: #e7721b; + --wf-accent-soft: #fff1e4; + --wf-lane: linear-gradient(120deg, #ecf7f4 0%, #ffffff 100%); + + background: radial-gradient(circle at 15% 5%, #f5fff8 0%, var(--wf-bg) 45%, #edf2ef 100%); + min-height: calc(100vh - 72px); + padding: 2rem clamp(1rem, 2vw, 2.5rem) 3rem; + color: var(--wf-text); +} + +.wayfinder-intro { + border: 1px solid var(--wf-border); + background: linear-gradient(130deg, #fdf7f1 0%, #f5fffa 100%); + border-radius: 14px; + padding: 1.2rem; + margin-bottom: 1rem; + box-shadow: 0 8px 24px rgba(9, 43, 32, 0.08); +} + +.wayfinder-intro h1 { + margin: 0; + font-size: clamp(1.6rem, 2.5vw, 2.2rem); + letter-spacing: 0.02em; +} + +.wayfinder-intro p { + margin-top: 0.5rem; + color: var(--wf-muted); +} + +.wayfinder-stats { + margin-top: 1rem; + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.wayfinder-stats > div { + border: 1px solid var(--wf-border); + border-radius: 10px; + background: var(--wf-card); + padding: 0.6rem 0.75rem; +} + +.wayfinder-stats span { + color: var(--wf-muted); + font-size: 0.82rem; +} + +.wayfinder-stats strong { + display: block; + margin-top: 0.25rem; + font-size: 1.25rem; +} + +.wayfinder-filters { + border: 1px solid var(--wf-border); + border-radius: 14px; + background: var(--wf-card); + padding: 1rem; + margin-bottom: 1rem; +} + +.wayfinder-filter-grid { + margin-top: 0.75rem; + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(4, minmax(170px, 1fr)); +} + +.wayfinder-lanes { + display: grid; + gap: 1rem; +} + +.wayfinder-lane { + border: 1px solid var(--wf-border); + border-radius: 14px; + background: var(--wf-lane); + padding: 0.9rem; + box-shadow: 0 4px 18px rgba(15, 42, 31, 0.06); +} + +.wayfinder-lane__header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.65rem; +} + +.wayfinder-lane__header h2 { + margin: 0; + font-size: 1.1rem; +} + +.wayfinder-lane__header span { + color: var(--wf-muted); + font-size: 0.82rem; +} + +.wayfinder-lane__cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.75rem; +} + +.wayfinder-card { + border: 1px solid var(--wf-border); + border-left: 4px solid var(--wf-accent); + border-radius: 12px; + background: var(--wf-card); + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + animation: wayfinder-entry 0.28s ease-out; +} + +.wayfinder-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.wayfinder-card__header h3 { + margin: 0; + font-size: 1rem; + line-height: 1.25; +} + +.wayfinder-card__meta { + background: var(--wf-accent-soft); + border-radius: 8px; + padding: 0.45rem 0.55rem; + font-size: 0.84rem; + color: var(--wf-muted); + display: grid; + gap: 0.35rem; +} + +.wayfinder-card__footer { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: center; + font-size: 0.84rem; +} + +.wayfinder-card__footer a { + color: #005b70; + font-weight: 600; +} + +.wayfinder-card__muted, +.wayfinder-lane__empty { + color: var(--wf-muted); +} + +@keyframes wayfinder-entry { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1024px) { + .wayfinder-filter-grid { + grid-template-columns: repeat(2, minmax(170px, 1fr)); + } +} + +@media (max-width: 640px) { + #wayfinder-content { + padding: 1rem 0.75rem 2rem; + } + + .wayfinder-filter-grid { + grid-template-columns: 1fr; + } + + .wayfinder-card__footer { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/application/frontend/src/routes.tsx b/application/frontend/src/routes.tsx index 05edecbbd..a4aef6525 100644 --- a/application/frontend/src/routes.tsx +++ b/application/frontend/src/routes.tsx @@ -12,6 +12,7 @@ import { SECTION_ID, STANDARD, SUBSECTION, + WAYFINDER, } 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 { Wayfinder } from './pages/Wayfinder/Wayfinder'; export interface IRoute { path: string; @@ -125,4 +127,9 @@ export const ROUTES = (capabilities: Capabilities): IRoute[] => [ component: Explorer, showFilter: false, }, + { + path: `${WAYFINDER}`, + component: Wayfinder, + showFilter: false, + }, ]; diff --git a/application/frontend/src/scaffolding/Header/Header.tsx b/application/frontend/src/scaffolding/Header/Header.tsx index 9f2cd117b..129a44bae 100644 --- a/application/frontend/src/scaffolding/Header/Header.tsx +++ b/application/frontend/src/scaffolding/Header/Header.tsx @@ -75,6 +75,9 @@ export const Header = ({ capabilities }: HeaderProps) => { Explorer + + Wayfinder + {capabilities.myopencre && ( MyOpenCRE @@ -198,6 +201,14 @@ export const Header = ({ capabilities }: HeaderProps) => { > Explorer + + Wayfinder + {capabilities.myopencre && ( None: self.assertEqual(200, response.status_code) self.assertEqual(expected, json.loads(response.data)) + @patch.object(db, "Node_collection") + def test_wayfinder_payload_shape(self, node_mock) -> None: + node_mock.return_value.wayfinder_resources.return_value = [ + { + "id": "standard:ASVS", + "name": "ASVS", + "doctype": "Standard", + "entry_count": 10, + "hyperlink": "https://example.com/asvs", + "metadata": { + "sdlc": ["Requirements", "Verification"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["standard"], + "source": "static_map", + }, + }, + { + "id": "standard:CWE", + "name": "CWE", + "doctype": "Standard", + "entry_count": 15, + "hyperlink": "https://example.com/cwe", + "metadata": { + "sdlc": ["Design", "Verification"], + "supporting_orgs": ["MITRE"], + "licenses": ["Copyright MITRE"], + "keywords": ["weakness"], + "source": "static_map", + }, + }, + { + "id": "tool:OWASP Juice Shop", + "name": "OWASP Juice Shop", + "doctype": "Tool", + "entry_count": 5, + "hyperlink": "https://example.com/juice-shop", + "metadata": { + "sdlc": ["Training", "Verification"], + "supporting_orgs": ["OWASP"], + "licenses": ["MIT"], + "keywords": ["training"], + "source": "static_map", + }, + }, + ] + + with self.app.test_client() as client: + response = client.get( + "/rest/v1/wayfinder", + headers={"Content-Type": "application/json"}, + ) + payload = json.loads(response.data) + + self.assertEqual(200, response.status_code) + self.assertEqual(3, payload["stats"]["total_resources"]) + self.assertEqual(3, payload["stats"]["filtered_resources"]) + self.assertEqual(30, payload["stats"]["total_entries"]) + self.assertEqual(3, len(payload["data"])) + self.assertIn("grouped_by_sdlc", payload) + self.assertIn("facets", payload) + self.assertIn("sdlc", payload["facets"]) + self.assertIn("supporting_orgs", payload["facets"]) + self.assertIn("licenses", payload["facets"]) + self.assertIn("doctypes", payload["facets"]) + + @patch.object(db, "Node_collection") + def test_wayfinder_filters(self, node_mock) -> None: + node_mock.return_value.wayfinder_resources.return_value = [ + { + "id": "standard:ASVS", + "name": "ASVS", + "doctype": "Standard", + "entry_count": 10, + "hyperlink": "https://example.com/asvs", + "metadata": { + "sdlc": ["Requirements", "Verification"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["verification"], + "source": "static_map", + }, + }, + { + "id": "tool:OWASP Juice Shop", + "name": "OWASP Juice Shop", + "doctype": "Tool", + "entry_count": 5, + "hyperlink": "https://example.com/juice-shop", + "metadata": { + "sdlc": ["Training", "Verification"], + "supporting_orgs": ["OWASP"], + "licenses": ["MIT"], + "keywords": ["training"], + "source": "static_map", + }, + }, + ] + + with self.app.test_client() as client: + response = client.get( + "/rest/v1/wayfinder?sdlc=Verification&supporting_org=OWASP&doctype=Tool", + headers={"Content-Type": "application/json"}, + ) + payload = json.loads(response.data) + + self.assertEqual(200, response.status_code) + self.assertEqual(1, payload["stats"]["filtered_resources"]) + self.assertEqual(1, len(payload["data"])) + self.assertEqual("OWASP Juice Shop", payload["data"][0]["name"]) + def test_gap_analysis_weak_links_no_cache(self) -> None: with self.app.test_client() as client: response = client.get( diff --git a/application/utils/wayfinder_metadata.py b/application/utils/wayfinder_metadata.py new file mode 100644 index 000000000..107b05afb --- /dev/null +++ b/application/utils/wayfinder_metadata.py @@ -0,0 +1,233 @@ +from typing import Any, Dict, List + +# Stable lane ordering for the Wayfinder UI. +SDLC_PHASE_ORDER = [ + "Requirements", + "Design", + "Implementation", + "Verification", + "Operations", + "Governance", + "Training", + "Uncategorized", +] + + +def _normalize_name(name: str) -> str: + return " ".join(str(name or "").replace("_", " ").strip().lower().split()) + + +def _normalize_items(values: List[str]) -> List[str]: + seen = set() + result = [] + for value in values or []: + cleaned = " ".join(str(value).strip().split()) + if not cleaned: + continue + lowered = cleaned.lower() + if lowered in seen: + continue + seen.add(lowered) + result.append(cleaned) + return result + + +def _default_metadata(ntype: str) -> Dict[str, Any]: + normalized_type = str(ntype or "").strip().lower() + if normalized_type == "tool": + return { + "sdlc": ["Implementation", "Verification"], + "supporting_orgs": ["Unknown"], + "licenses": ["Unknown"], + "keywords": ["tooling"], + } + + if normalized_type == "standard": + return { + "sdlc": ["Requirements", "Verification"], + "supporting_orgs": ["Unknown"], + "licenses": ["Unknown"], + "keywords": ["standard"], + } + + return { + "sdlc": ["Uncategorized"], + "supporting_orgs": ["Unknown"], + "licenses": ["Unknown"], + "keywords": [], + } + + +_ALIASES = { + "owasp top10 2021": "owasp top 10 2021", + "owasp top 10": "owasp top 10 2021", + "pci dss": "pci dss v4.0", + "devsecops maturity model (dsom)": "devsecops maturity model (dsomm)", +} + + +_STATIC_METADATA_BY_NAME: Dict[str, Dict[str, Any]] = { + "asvs": { + "sdlc": ["Requirements", "Design", "Verification"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["application security verification"], + }, + "owasp top 10 2021": { + "sdlc": ["Requirements", "Design", "Verification", "Operations"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["risk awareness", "vulnerability classes"], + }, + "owasp web security testing guide (wstg)": { + "sdlc": ["Verification"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["testing guide", "penetration testing"], + }, + "owasp cheat sheets": { + "sdlc": ["Design", "Implementation", "Operations"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["implementation guidance"], + }, + "owasp proactive controls": { + "sdlc": ["Design", "Implementation"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["secure coding"], + }, + "samm": { + "sdlc": [ + "Governance", + "Requirements", + "Design", + "Implementation", + "Verification", + "Operations", + "Training", + ], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["maturity model", "assurance"], + }, + "owasp wrongsecrets": { + "sdlc": ["Training", "Implementation"], + "supporting_orgs": ["OWASP"], + "licenses": ["MIT"], + "keywords": ["hands-on", "training"], + }, + "owasp juice shop": { + "sdlc": ["Training", "Verification"], + "supporting_orgs": ["OWASP"], + "licenses": ["MIT"], + "keywords": ["ctf", "training", "testing"], + }, + "zap rule": { + "sdlc": ["Verification", "Operations"], + "supporting_orgs": ["OWASP"], + "licenses": ["Apache-2.0"], + "keywords": ["dynamic analysis", "scanner"], + }, + "cwe": { + "sdlc": ["Design", "Implementation", "Verification"], + "supporting_orgs": ["MITRE"], + "licenses": ["Copyright MITRE"], + "keywords": ["weakness taxonomy"], + }, + "capec": { + "sdlc": ["Design", "Verification"], + "supporting_orgs": ["MITRE"], + "licenses": ["Copyright MITRE"], + "keywords": ["attack patterns", "threat modeling"], + }, + "cloud controls matrix": { + "sdlc": ["Governance", "Requirements", "Operations"], + "supporting_orgs": ["Cloud Security Alliance"], + "licenses": ["CC BY-SA"], + "keywords": ["cloud controls"], + }, + "iso 27001": { + "sdlc": ["Governance", "Requirements", "Operations"], + "supporting_orgs": ["ISO"], + "licenses": ["Commercial"], + "keywords": ["isms", "governance"], + }, + "nist 800-53 v5": { + "sdlc": ["Requirements", "Design", "Operations", "Governance"], + "supporting_orgs": ["NIST"], + "licenses": ["Public Domain"], + "keywords": ["security controls"], + }, + "nist 800-63": { + "sdlc": ["Requirements", "Design", "Verification"], + "supporting_orgs": ["NIST"], + "licenses": ["Public Domain"], + "keywords": ["digital identity"], + }, + "nist ssdf": { + "sdlc": [ + "Requirements", + "Design", + "Implementation", + "Verification", + "Operations", + "Governance", + "Training", + ], + "supporting_orgs": ["NIST"], + "licenses": ["Public Domain"], + "keywords": ["secure software development framework"], + }, + "devsecops maturity model (dsomm)": { + "sdlc": [ + "Governance", + "Implementation", + "Verification", + "Operations", + "Training", + ], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["devsecops", "maturity model"], + }, + "pci dss v4.0": { + "sdlc": ["Requirements", "Verification", "Operations", "Governance"], + "supporting_orgs": ["PCI SSC"], + "licenses": ["Commercial"], + "keywords": ["payment security"], + }, + "secure headers project": { + "sdlc": ["Implementation", "Verification", "Operations"], + "supporting_orgs": ["OWASP"], + "licenses": ["CC BY-SA"], + "keywords": ["http headers", "hardening"], + }, +} + + +def get_wayfinder_metadata(name: str, ntype: str) -> Dict[str, Any]: + normalized_name = _normalize_name(name) + canonical_name = _ALIASES.get(normalized_name, normalized_name) + base_metadata = _STATIC_METADATA_BY_NAME.get(canonical_name) + + if base_metadata is None: + metadata = _default_metadata(ntype=ntype) + metadata["source"] = "fallback" + else: + metadata = dict(base_metadata) + metadata["source"] = "static_map" + + metadata["sdlc"] = _normalize_items(metadata.get("sdlc", [])) + metadata["supporting_orgs"] = _normalize_items(metadata.get("supporting_orgs", [])) + metadata["licenses"] = _normalize_items(metadata.get("licenses", [])) + metadata["keywords"] = _normalize_items(metadata.get("keywords", [])) + + if not metadata["sdlc"]: + metadata["sdlc"] = ["Uncategorized"] + if not metadata["supporting_orgs"]: + metadata["supporting_orgs"] = ["Unknown"] + if not metadata["licenses"]: + metadata["licenses"] = ["Unknown"] + + return metadata diff --git a/application/web/web_main.py b/application/web/web_main.py index 29567470a..6ec6fd556 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 collections import Counter from functools import wraps import json import logging @@ -25,7 +26,7 @@ from application.config import ENABLE_MYOPENCRE from application.utils import spreadsheet as sheet_utils -from application.utils import mdutils, redirectors, gap_analysis +from application.utils import mdutils, redirectors, gap_analysis, wayfinder_metadata from application.prompt_client import prompt_client as prompt_client from enum import Enum from flask import json as flask_json @@ -90,6 +91,35 @@ def _normalize_source_name(source: Any) -> str | None: return normalized_source[:64] +def _normalize_filter_values(raw_values: list[str]) -> set[str]: + normalized = set() + for raw in raw_values or []: + for part in str(raw).split(","): + cleaned = part.strip().lower() + if cleaned: + normalized.add(cleaned) + return normalized + + +def _facet_counts(resources: list[dict[str, Any]], key: str) -> list[dict[str, Any]]: + counter: Counter[str] = Counter() + for resource in resources: + values = [] + if key == "doctype": + values = [resource.get("doctype")] + else: + values = resource.get("metadata", {}).get(key, []) + for value in values: + cleaned = str(value).strip() + if cleaned: + counter[cleaned] += 1 + + return [ + {"value": value, "count": counter[value]} + for value in sorted(counter.keys(), key=lambda x: x.lower()) + ] + + def extend_cre_with_tag_links( cre: defs.CRE, collection: db.Node_collection ) -> defs.CRE: @@ -442,6 +472,96 @@ def standards() -> Any: return standards +@app.route("/rest/v1/wayfinder", methods=["GET"]) +def wayfinder() -> Any: + """ + Return non-CRE resources enriched with Wayfinder metadata and grouped by SDLC lane. + Query parameters (repeatable): sdlc, supporting_org, license, doctype + Query parameter: q (case-insensitive free text) + """ + database = db.Node_collection() + resources = database.wayfinder_resources() + + sdlc_filters = _normalize_filter_values(request.args.getlist("sdlc")) + org_filters = _normalize_filter_values(request.args.getlist("supporting_org")) + license_filters = _normalize_filter_values(request.args.getlist("license")) + doctype_filters = _normalize_filter_values(request.args.getlist("doctype")) + search_query = str(request.args.get("q", "")).strip().lower() + + def _matches(resource: dict[str, Any]) -> bool: + metadata = resource.get("metadata", {}) + resource_name = str(resource.get("name", "")) + doctype = str(resource.get("doctype", "")).lower() + + resource_sdlc = {str(x).lower() for x in metadata.get("sdlc", [])} + resource_orgs = {str(x).lower() for x in metadata.get("supporting_orgs", [])} + resource_licenses = {str(x).lower() for x in metadata.get("licenses", [])} + resource_keywords = " ".join( + [str(x).lower() for x in metadata.get("keywords", [])] + ) + + if sdlc_filters and not sdlc_filters.intersection(resource_sdlc): + return False + if org_filters and not org_filters.intersection(resource_orgs): + return False + if license_filters and not license_filters.intersection(resource_licenses): + return False + if doctype_filters and doctype not in doctype_filters: + return False + if search_query: + searchable_text = f"{resource_name.lower()} {resource_keywords}" + if search_query not in searchable_text: + return False + return True + + filtered_resources = [resource for resource in resources if _matches(resource)] + + grouped = {phase: [] for phase in wayfinder_metadata.SDLC_PHASE_ORDER} + for resource in filtered_resources: + phases = resource.get("metadata", {}).get("sdlc", []) or ["Uncategorized"] + for phase in phases: + if phase not in grouped: + grouped[phase] = [] + grouped[phase].append(resource) + + extra_phases = sorted( + [phase for phase in grouped.keys() if phase not in wayfinder_metadata.SDLC_PHASE_ORDER] + ) + phase_order = [*wayfinder_metadata.SDLC_PHASE_ORDER, *extra_phases] + grouped_by_sdlc = [ + { + "phase": phase, + "resources": sorted( + grouped[phase], key=lambda resource: str(resource.get("name", "")).lower() + ), + } + for phase in phase_order + ] + + payload = { + "data": sorted( + filtered_resources, key=lambda resource: str(resource.get("name", "")).lower() + ), + "grouped_by_sdlc": grouped_by_sdlc, + "facets": { + "sdlc": _facet_counts(resources, "sdlc"), + "supporting_orgs": _facet_counts(resources, "supporting_orgs"), + "licenses": _facet_counts(resources, "licenses"), + "doctypes": _facet_counts(resources, "doctype"), + }, + "sdlc_order": phase_order, + "stats": { + "total_resources": len(resources), + "filtered_resources": len(filtered_resources), + "total_entries": sum(int(resource.get("entry_count", 0)) for resource in resources), + "filtered_entries": sum( + int(resource.get("entry_count", 0)) for resource in filtered_resources + ), + }, + } + return jsonify(payload) + + @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..bfabe7efa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -211,6 +211,73 @@ paths: items: type: string + /rest/v1/wayfinder: + get: + summary: Product Security Wayfinder data + description: > + Retrieve non-CRE resources enriched with metadata (for example SDLC + phase, supporting organization, and license), grouped for Wayfinder + visualization. + parameters: + - name: sdlc + in: query + required: false + description: Filter by one or more SDLC phases (repeatable) + schema: + type: array + items: + type: string + - name: supporting_org + in: query + required: false + description: Filter by one or more supporting organizations (repeatable) + schema: + type: array + items: + type: string + - name: license + in: query + required: false + description: Filter by one or more license values (repeatable) + schema: + type: array + items: + type: string + - name: doctype + in: query + required: false + description: Filter by one or more document types (repeatable) + schema: + type: array + items: + type: string + - name: q + in: query + required: false + description: Free-text filter against resource names and metadata keywords + schema: + type: string + responses: + '200': + description: Wayfinder payload + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + grouped_by_sdlc: + type: array + items: + type: object + facets: + type: object + stats: + type: object + /rest/v1/text_search: get: summary: Text search From 67e42e34d49558d67a5f58f7b588e79941171c17 Mon Sep 17 00:00:00 2001 From: PRAteek-singHWY Date: Wed, 4 Mar 2026 02:55:34 +0530 Subject: [PATCH 2/2] style: format web_main.py with black --- application/web/web_main.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/application/web/web_main.py b/application/web/web_main.py index 6ec6fd556..46b7e467d 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -525,14 +525,19 @@ def _matches(resource: dict[str, Any]) -> bool: grouped[phase].append(resource) extra_phases = sorted( - [phase for phase in grouped.keys() if phase not in wayfinder_metadata.SDLC_PHASE_ORDER] + [ + phase + for phase in grouped.keys() + if phase not in wayfinder_metadata.SDLC_PHASE_ORDER + ] ) phase_order = [*wayfinder_metadata.SDLC_PHASE_ORDER, *extra_phases] grouped_by_sdlc = [ { "phase": phase, "resources": sorted( - grouped[phase], key=lambda resource: str(resource.get("name", "")).lower() + grouped[phase], + key=lambda resource: str(resource.get("name", "")).lower(), ), } for phase in phase_order @@ -540,7 +545,8 @@ def _matches(resource: dict[str, Any]) -> bool: payload = { "data": sorted( - filtered_resources, key=lambda resource: str(resource.get("name", "")).lower() + filtered_resources, + key=lambda resource: str(resource.get("name", "")).lower(), ), "grouped_by_sdlc": grouped_by_sdlc, "facets": { @@ -553,7 +559,9 @@ def _matches(resource: dict[str, Any]) -> bool: "stats": { "total_resources": len(resources), "filtered_resources": len(filtered_resources), - "total_entries": sum(int(resource.get("entry_count", 0)) for resource in resources), + "total_entries": sum( + int(resource.get("entry_count", 0)) for resource in resources + ), "filtered_entries": sum( int(resource.get("entry_count", 0)) for resource in filtered_resources ),