From 27ec02220ee65df7f9dfe63dae45c3eac5bf8513 Mon Sep 17 00:00:00 2001 From: PRAteek-singHWY Date: Wed, 4 Mar 2026 02:29:17 +0530 Subject: [PATCH 1/3] 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 6ef08f8f638d819b3100e895142c4f628690209e Mon Sep 17 00:00:00 2001 From: PRAteek-singHWY Date: Wed, 4 Mar 2026 02:55:34 +0530 Subject: [PATCH 2/3] 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 ), From 12781731485ecee1314d5afcccbf812e2bf0e1b2 Mon Sep 17 00:00:00 2001 From: PRAteek-singHWY Date: Wed, 4 Mar 2026 03:59:50 +0530 Subject: [PATCH 3/3] feat(wayfinder): refine metadata normalization and UI filtering polish --- application/database/db.py | 41 ++- .../src/pages/Wayfinder/Wayfinder.tsx | 49 +++- .../src/pages/Wayfinder/wayfinder.scss | 245 +++++++++++++----- application/tests/wayfinder_metadata_test.py | 40 +++ application/utils/wayfinder_metadata.py | 54 ++++ 5 files changed, 339 insertions(+), 90 deletions(-) create mode 100644 application/tests/wayfinder_metadata_test.py diff --git a/application/database/db.py b/application/database/db.py index 30ee58787..4317721d9 100644 --- a/application/database/db.py +++ b/application/database/db.py @@ -1812,20 +1812,49 @@ def wayfinder_resources(self) -> List[Dict[str, Any]]: .all() ) - resources: List[Dict[str, Any]] = [] + aggregated: Dict[Tuple[str, str], Dict[str, Any]] = {} for row in rows: name, ntype, entry_count, sample_hyperlink = row - metadata = wayfinder_metadata.get_wayfinder_metadata( + canonical_name = wayfinder_metadata.canonical_resource_name( name=str(name), ntype=str(ntype) ) - resource_id = f"{str(ntype).lower()}:{str(name)}" + if not canonical_name: + continue + + key = (str(ntype), canonical_name) + if key not in aggregated: + aggregated[key] = { + "doctype": str(ntype), + "name": canonical_name, + "entry_count": 0, + "hyperlink": "", + "aliases": set(), + } + + entry = aggregated[key] + entry["entry_count"] += int(entry_count or 0) + entry["aliases"].add(str(name)) + if sample_hyperlink and not entry["hyperlink"]: + entry["hyperlink"] = str(sample_hyperlink) + + resources: List[Dict[str, Any]] = [] + for (ntype, canonical_name), entry in sorted( + aggregated.items(), key=lambda x: (x[0][0].lower(), x[0][1].lower()) + ): + metadata = wayfinder_metadata.get_wayfinder_metadata( + name=canonical_name, ntype=ntype + ) + resource_id = f"{ntype.lower()}:{canonical_name}" + aliases = sorted(list(entry["aliases"]), key=lambda x: x.lower()) + resources.append( { "id": resource_id, - "name": name, + "name": canonical_name, "doctype": ntype, - "entry_count": int(entry_count or 0), - "hyperlink": sample_hyperlink or "", + "entry_count": int(entry["entry_count"]), + "hyperlink": entry["hyperlink"], + "aliases": aliases, "metadata": metadata, } ) diff --git a/application/frontend/src/pages/Wayfinder/Wayfinder.tsx b/application/frontend/src/pages/Wayfinder/Wayfinder.tsx index bcb949219..cace9898a 100644 --- a/application/frontend/src/pages/Wayfinder/Wayfinder.tsx +++ b/application/frontend/src/pages/Wayfinder/Wayfinder.tsx @@ -2,7 +2,7 @@ import './wayfinder.scss'; import axios from 'axios'; import React, { useEffect, useMemo, useState } from 'react'; -import { Button, Dropdown, Input, Label } from 'semantic-ui-react'; +import { Button, Dropdown, Input } from 'semantic-ui-react'; import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator'; import { useEnvironment } from '../../hooks'; @@ -53,14 +53,17 @@ export const Wayfinder = () => { 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 }); + // Use URLSearchParams so Flask receives repeated keys (sdlc=x&sdlc=y), not sdlc[]=x. + const params = new URLSearchParams(); + if (debouncedQuery) params.append('q', debouncedQuery); + selectedSdlc.forEach((value) => params.append('sdlc', value)); + selectedOrgs.forEach((value) => params.append('supporting_org', value)); + selectedLicenses.forEach((value) => params.append('license', value)); + selectedDoctypes.forEach((value) => params.append('doctype', value)); + + const res = await axios.get(`${apiUrl}/wayfinder`, { + params, + }); setResponse(res.data); } catch (err) { console.error(err); @@ -75,6 +78,9 @@ export const Wayfinder = () => { const stats = response?.stats; const groups = response?.grouped_by_sdlc || []; + const hasNoData = !loading && !error && (stats?.total_resources || 0) === 0; + const hasNoMatches = !loading && !error && !hasNoData && (stats?.filtered_resources || 0) === 0; + const visibleGroups = useMemo(() => groups.filter((group) => group.resources.length > 0), [groups]); const sdlcOptions = useMemo(() => asOptions(response?.facets?.sdlc), [response?.facets?.sdlc]); const orgOptions = useMemo( @@ -102,9 +108,7 @@ export const Wayfinder = () => {

{resource.name}

- + {resource.doctype}
@@ -209,9 +213,26 @@ export const Wayfinder = () => { - {!loading && !error && ( + {hasNoData && ( +
+

No resources available yet

+

+ Wayfinder is active, but your local dataset has no imported non-CRE resources. Import/sync data and + refresh this page to populate lanes and facets. +

+
+ )} + + {hasNoMatches && ( +
+

No resources match the current filters

+

Try clearing one or more filters to broaden the Wayfinder results.

+
+ )} + + {!loading && !error && !hasNoData && !hasNoMatches && (
- {groups.map((group) => ( + {visibleGroups.map((group) => (

{group.phase}

diff --git a/application/frontend/src/pages/Wayfinder/wayfinder.scss b/application/frontend/src/pages/Wayfinder/wayfinder.scss index f08cef5d4..2c2c67679 100644 --- a/application/frontend/src/pages/Wayfinder/wayfinder.scss +++ b/application/frontend/src/pages/Wayfinder/wayfinder.scss @@ -1,41 +1,45 @@ #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; + --wf-bg-0: #040a1a; + --wf-bg-1: #071327; + --wf-bg-2: #0b1f3d; + --wf-panel: #0f2142; + --wf-panel-soft: #132b53; + --wf-card: #11284d; + --wf-border: rgba(96, 165, 250, 0.35); + --wf-border-strong: rgba(96, 165, 250, 0.6); + --wf-text: #e8f2ff; + --wf-muted: #afc6e6; + --wf-accent: #60a5fa; + --wf-accent-2: #1d4ed8; + --wf-ok: #38bdf8; + + min-height: calc(100vh - 64px); + padding: 1.5rem clamp(1rem, 2vw, 2.25rem) 2.5rem; color: var(--wf-text); + background: #f6f8fc; } .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); + border-radius: 12px; + background: linear-gradient(160deg, rgba(18, 46, 92, 0.92) 0%, rgba(14, 33, 66, 0.92) 100%); + padding: 1.1rem 1rem 0.95rem; + box-shadow: 0 12px 30px rgba(2, 8, 22, 0.4); } .wayfinder-intro h1 { margin: 0; - font-size: clamp(1.6rem, 2.5vw, 2.2rem); - letter-spacing: 0.02em; + font-size: clamp(1.5rem, 2.4vw, 2rem); + letter-spacing: 0.01em; } .wayfinder-intro p { - margin-top: 0.5rem; + margin: 0.45rem 0 0; color: var(--wf-muted); } .wayfinder-stats { - margin-top: 1rem; + margin-top: 0.85rem; display: grid; gap: 0.65rem; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); @@ -44,47 +48,137 @@ .wayfinder-stats > div { border: 1px solid var(--wf-border); border-radius: 10px; - background: var(--wf-card); - padding: 0.6rem 0.75rem; + background: linear-gradient(160deg, rgba(14, 34, 68, 0.92) 0%, rgba(10, 25, 49, 0.92) 100%); + padding: 0.55rem 0.75rem; } .wayfinder-stats span { color: var(--wf-muted); - font-size: 0.82rem; + font-size: 0.8rem; + letter-spacing: 0.01em; } .wayfinder-stats strong { display: block; - margin-top: 0.25rem; - font-size: 1.25rem; + margin-top: 0.2rem; + font-size: 1.45rem; + color: #f7fbff; } .wayfinder-filters { + margin-top: 0.9rem; border: 1px solid var(--wf-border); - border-radius: 14px; - background: var(--wf-card); - padding: 1rem; - margin-bottom: 1rem; + border-radius: 12px; + background: linear-gradient(170deg, rgba(13, 31, 62, 0.92) 0%, rgba(9, 24, 48, 0.9) 100%); + padding: 0.8rem; } .wayfinder-filter-grid { - margin-top: 0.75rem; + margin-top: 0.7rem; display: grid; - gap: 0.75rem; + gap: 0.65rem; grid-template-columns: repeat(4, minmax(170px, 1fr)); } +.wayfinder-filters .ui.input > input, +.wayfinder-filters .ui.selection.dropdown { + background: rgba(9, 23, 44, 0.9); + border: 1px solid var(--wf-border); + color: var(--wf-text); +} + +.wayfinder-filters .ui.input > input::placeholder { + color: rgba(175, 198, 230, 0.8); +} + +.wayfinder-filters .ui.selection.dropdown .text, +.wayfinder-filters .ui.selection.dropdown .default.text, +.wayfinder-filters .ui.selection.dropdown .dropdown.icon { + color: var(--wf-text) !important; + opacity: 0.95; +} + +.wayfinder-filters .ui.selection.dropdown .menu { + background: #0d254a; + border: 1px solid var(--wf-border-strong); +} + +.wayfinder-filters .ui.selection.dropdown .menu > .item { + color: var(--wf-text); +} + +.wayfinder-filters .ui.selection.dropdown .menu > .item:hover { + background: rgba(59, 130, 246, 0.2); +} + +.wayfinder-filters .ui.label { + background: rgba(37, 99, 235, 0.32); + color: #eff7ff; + border: 1px solid rgba(147, 197, 253, 0.45); + text-shadow: none !important; + filter: none !important; + font-family: inherit; + font-weight: 600; +} + +.wayfinder-filters .ui.multiple.dropdown > .label, +.wayfinder-filters .ui.multiple.dropdown > .label > .text, +.wayfinder-filters .ui.multiple.dropdown > .label > .delete.icon { + text-shadow: none !important; + filter: none !important; + opacity: 1 !important; + color: #eff7ff !important; + -webkit-font-smoothing: antialiased; +} + +.wayfinder-filters .ui.multiple.dropdown > .label > .delete.icon { + margin-left: 0.35rem; +} + +.wayfinder-filters .ui.basic.button { + margin-top: 0.65rem; + border: 1px solid rgba(147, 197, 253, 0.7) !important; + color: #e8f2ff !important; + background: rgba(30, 64, 175, 0.32) !important; + font-weight: 600; +} + +.wayfinder-filters .ui.basic.button:hover, +.wayfinder-filters .ui.basic.button:focus { + border-color: rgba(147, 197, 253, 0.95) !important; + color: #f8fbff !important; + background: rgba(37, 99, 235, 0.45) !important; +} + +.wayfinder-empty { + margin-top: 0.95rem; + border: 1px solid rgba(125, 211, 252, 0.45); + background: linear-gradient(160deg, rgba(12, 40, 72, 0.9), rgba(9, 26, 53, 0.9)); + border-radius: 12px; + padding: 1rem; +} + +.wayfinder-empty h2 { + margin: 0; + font-size: 1.1rem; +} + +.wayfinder-empty p { + margin: 0.45rem 0 0; + color: var(--wf-muted); +} + .wayfinder-lanes { + margin-top: 0.95rem; display: grid; - gap: 1rem; + gap: 0.95rem; } .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); + border-radius: 12px; + background: linear-gradient(155deg, rgba(17, 40, 77, 0.86) 0%, rgba(13, 31, 62, 0.86) 100%); + padding: 0.8rem; } .wayfinder-lane__header { @@ -96,66 +190,88 @@ .wayfinder-lane__header h2 { margin: 0; - font-size: 1.1rem; + font-size: 1.06rem; + color: #f5faff; } .wayfinder-lane__header span { color: var(--wf-muted); - font-size: 0.82rem; + font-size: 0.8rem; } .wayfinder-lane__cards { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 0.75rem; + gap: 0.7rem; + grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); } .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; + border-left: 4px solid var(--wf-ok); + border-radius: 10px; + background: linear-gradient(160deg, rgba(15, 39, 75, 0.94) 0%, rgba(10, 29, 58, 0.94) 100%); + padding: 0.68rem; display: flex; flex-direction: column; - gap: 0.65rem; - animation: wayfinder-entry 0.28s ease-out; + gap: 0.62rem; + box-shadow: 0 8px 18px rgba(3, 12, 31, 0.35); } .wayfinder-card__header { display: flex; - align-items: flex-start; justify-content: space-between; gap: 0.5rem; + align-items: flex-start; } .wayfinder-card__header h3 { margin: 0; font-size: 1rem; - line-height: 1.25; + line-height: 1.24; + color: #f4f9ff; +} + +.wayfinder-card__doctype { + border-radius: 999px; + background: linear-gradient(180deg, #3b82f6 0%, #1e40af 100%); + border: 1px solid rgba(147, 197, 253, 0.45); + color: #ecf5ff; + font-size: 0.72rem; + line-height: 1; + font-weight: 700; + letter-spacing: 0.01em; + padding: 0.35rem 0.55rem; + white-space: nowrap; + text-shadow: none; } .wayfinder-card__meta { - background: var(--wf-accent-soft); + background: rgba(8, 25, 49, 0.58); + border: 1px solid rgba(125, 211, 252, 0.22); border-radius: 8px; - padding: 0.45rem 0.55rem; - font-size: 0.84rem; - color: var(--wf-muted); + padding: 0.5rem 0.55rem; display: grid; - gap: 0.35rem; + gap: 0.38rem; + font-size: 0.84rem; + color: #dceafe; } .wayfinder-card__footer { display: flex; justify-content: space-between; - gap: 0.5rem; align-items: center; - font-size: 0.84rem; + gap: 0.5rem; + font-size: 0.83rem; + color: var(--wf-muted); } .wayfinder-card__footer a { - color: #005b70; - font-weight: 600; + color: #7dd3fc; + font-weight: 700; +} + +.wayfinder-card__footer a:hover { + color: #bae6fd; } .wayfinder-card__muted, @@ -163,17 +279,6 @@ 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)); @@ -182,7 +287,7 @@ @media (max-width: 640px) { #wayfinder-content { - padding: 1rem 0.75rem 2rem; + padding: 0.95rem 0.75rem 1.6rem; } .wayfinder-filter-grid { diff --git a/application/tests/wayfinder_metadata_test.py b/application/tests/wayfinder_metadata_test.py new file mode 100644 index 000000000..e2bc38774 --- /dev/null +++ b/application/tests/wayfinder_metadata_test.py @@ -0,0 +1,40 @@ +import unittest + +from application.utils import wayfinder_metadata + + +class TestWayfinderMetadata(unittest.TestCase): + def test_noise_resource_detection(self): + self.assertTrue(wayfinder_metadata.is_noise_resource("standard", "Standard")) + self.assertTrue(wayfinder_metadata.is_noise_resource("Tool", "Tool")) + self.assertTrue(wayfinder_metadata.is_noise_resource("", "Standard")) + self.assertFalse(wayfinder_metadata.is_noise_resource("ASVS", "Standard")) + + def test_canonical_resource_name(self): + self.assertEqual( + wayfinder_metadata.canonical_resource_name("cwe-22", "Standard"), "CWE" + ) + self.assertEqual( + wayfinder_metadata.canonical_resource_name("capec-111", "Standard"), + "CAPEC", + ) + self.assertEqual( + wayfinder_metadata.canonical_resource_name("owasp top 10", "Standard"), + "OWASP Top 10 2021", + ) + self.assertEqual( + wayfinder_metadata.canonical_resource_name("standard", "Standard"), "" + ) + + def test_get_wayfinder_metadata_fallback(self): + metadata = wayfinder_metadata.get_wayfinder_metadata( + "Unknown Framework", "Tool" + ) + self.assertEqual(metadata["source"], "fallback") + self.assertIn("Implementation", metadata["sdlc"]) + self.assertIn("Unknown", metadata["supporting_orgs"]) + self.assertIn("Unknown", metadata["licenses"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/application/utils/wayfinder_metadata.py b/application/utils/wayfinder_metadata.py index 107b05afb..3ad4471c6 100644 --- a/application/utils/wayfinder_metadata.py +++ b/application/utils/wayfinder_metadata.py @@ -1,3 +1,4 @@ +import re from typing import Any, Dict, List # Stable lane ordering for the Wayfinder UI. @@ -65,6 +66,59 @@ def _default_metadata(ntype: str) -> Dict[str, Any]: "devsecops maturity model (dsom)": "devsecops maturity model (dsomm)", } +_GENERIC_PLACEHOLDER_NAMES = { + "standard", + "tool", + "code", + "node", + "resource", + "document", +} + + +def is_noise_resource(name: str, ntype: str) -> bool: + normalized_name = _normalize_name(name) + normalized_type = _normalize_name(ntype) + + if not normalized_name: + return True + if normalized_name in _GENERIC_PLACEHOLDER_NAMES: + return True + if normalized_name == normalized_type: + return True + return False + + +def canonical_resource_name(name: str, ntype: str) -> str: + """ + Normalize noisy resource names so the Wayfinder groups related entries. + """ + normalized_name = _normalize_name(name) + canonical_name = _ALIASES.get(normalized_name, normalized_name) + + if is_noise_resource(canonical_name, ntype): + return "" + + if re.match(r"^cwe-\d+$", canonical_name): + return "CWE" + if re.match(r"^capec-\d+$", canonical_name): + return "CAPEC" + if re.match(r"^zap rule$", canonical_name): + return "ZAP Rule" + if re.match(r"^owasp top 10( 2021)?$", canonical_name): + return "OWASP Top 10 2021" + if canonical_name == "owasp web security testing guide (wstg)": + return "OWASP Web Security Testing Guide (WSTG)" + if canonical_name == "devsecops maturity model (dsomm)": + return "DevSecOps Maturity Model (DSOMM)" + if canonical_name == "cloud controls matrix": + return "Cloud Controls Matrix" + if canonical_name == "nist ssdf": + return "NIST SSDF" + + cleaned_original = " ".join(str(name or "").split()).strip() + return cleaned_original or name + _STATIC_METADATA_BY_NAME: Dict[str, Dict[str, Any]] = { "asvs": {