diff --git a/application/database/db.py b/application/database/db.py index 6c1613277..4317721d9 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,67 @@ 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() + ) + + aggregated: Dict[Tuple[str, str], Dict[str, Any]] = {} + for row in rows: + name, ntype, entry_count, sample_hyperlink = row + canonical_name = wayfinder_metadata.canonical_resource_name( + name=str(name), ntype=str(ntype) + ) + 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": canonical_name, + "doctype": ntype, + "entry_count": int(entry["entry_count"]), + "hyperlink": entry["hyperlink"], + "aliases": aliases, + "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..cace9898a --- /dev/null +++ b/application/frontend/src/pages/Wayfinder/Wayfinder.tsx @@ -0,0 +1,255 @@ +import './wayfinder.scss'; + +import axios from 'axios'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Dropdown, Input } 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 { + // 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); + 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 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( + () => 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}

+ {resource.doctype} +
+ +
+
+ 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))} + /> +
+ +
+ + + + {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 && ( +
+ {visibleGroups.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..2c2c67679 --- /dev/null +++ b/application/frontend/src/pages/Wayfinder/wayfinder.scss @@ -0,0 +1,301 @@ +#wayfinder-content { + --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); + 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.5rem, 2.4vw, 2rem); + letter-spacing: 0.01em; +} + +.wayfinder-intro p { + margin: 0.45rem 0 0; + color: var(--wf-muted); +} + +.wayfinder-stats { + margin-top: 0.85rem; + 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: 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.8rem; + letter-spacing: 0.01em; +} + +.wayfinder-stats strong { + display: block; + margin-top: 0.2rem; + font-size: 1.45rem; + color: #f7fbff; +} + +.wayfinder-filters { + margin-top: 0.9rem; + border: 1px solid var(--wf-border); + 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.7rem; + display: grid; + 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: 0.95rem; +} + +.wayfinder-lane { + border: 1px solid var(--wf-border); + 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 { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.65rem; +} + +.wayfinder-lane__header h2 { + margin: 0; + font-size: 1.06rem; + color: #f5faff; +} + +.wayfinder-lane__header span { + color: var(--wf-muted); + font-size: 0.8rem; +} + +.wayfinder-lane__cards { + display: grid; + 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-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.62rem; + box-shadow: 0 8px 18px rgba(3, 12, 31, 0.35); +} + +.wayfinder-card__header { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: flex-start; +} + +.wayfinder-card__header h3 { + margin: 0; + font-size: 1rem; + 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: rgba(8, 25, 49, 0.58); + border: 1px solid rgba(125, 211, 252, 0.22); + border-radius: 8px; + padding: 0.5rem 0.55rem; + display: grid; + gap: 0.38rem; + font-size: 0.84rem; + color: #dceafe; +} + +.wayfinder-card__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + font-size: 0.83rem; + color: var(--wf-muted); +} + +.wayfinder-card__footer a { + color: #7dd3fc; + font-weight: 700; +} + +.wayfinder-card__footer a:hover { + color: #bae6fd; +} + +.wayfinder-card__muted, +.wayfinder-lane__empty { + color: var(--wf-muted); +} + +@media (max-width: 1024px) { + .wayfinder-filter-grid { + grid-template-columns: repeat(2, minmax(170px, 1fr)); + } +} + +@media (max-width: 640px) { + #wayfinder-content { + padding: 0.95rem 0.75rem 1.6rem; + } + + .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..3ad4471c6 --- /dev/null +++ b/application/utils/wayfinder_metadata.py @@ -0,0 +1,287 @@ +import re +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)", +} + +_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": { + "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..46b7e467d 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,104 @@ 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