diff --git a/CHANGELOG.md b/CHANGELOG.md index 918035c4..91dbe2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Fix: Ontology Hub post-review bug fixes and security hardening** (follow-up to #518, closes security advisory #23, by @KaifAhmad1): + - **Broken registry filters** — `fetchRegistry` was sending toolbar filter values (`owl`, `skos`, `internal`, `external`) to the backend as the `status` query param, which only accepts `published|draft|external`, causing those filters to return empty lists. Removed the spurious `status` param; all format/kind filtering is now applied client-side via `filteredEntries`, which already had the correct logic. + - **Toggle/refresh URI corruption** — `toggle_ontology` and `refresh_ontology` applied `.removesuffix("/toggle")` / `.removesuffix("/refresh")` to the captured path parameter, which would silently corrupt any ontology URI that legitimately ends with those strings. Starlette's route regex (`/{uri:path}/toggle`) already strips the literal suffix via backtracking, so the `removesuffix` calls were removed and the raw `ontology_uri` parameter is used directly. + - **SSRF in URL fetch** — `_fetch_url_sync()` accepted arbitrary user-supplied URLs and called `requests.get()` with no validation, enabling server-side request forgery against internal services. Added `_validate_fetch_url()` which rejects non-`http`/`https` schemes and resolves the hostname via `socket.getaddrinfo`, blocking loopback, private, link-local, reserved, and multicast addresses. Applied to all three fetch sites: preview, load, and refresh. + - **File upload format misdetected** — the file picker accepted `.xml` and `.json` but `fmtMap` had no entries for those extensions, causing them to default to `turtle`. Added `xml: "xml"` and `json: "json-ld"` mappings. Changed the unknown-extension fallback from `|| "turtle"` to `?? ""` (empty string), and omit the `format` key from the request body when empty so the backend `_detect_format()` runs instead of receiving a forced incorrect value. Also added `.n3` to the accepted extension list and dropzone hint. + - **Inconsistent XML hardening** — `_parse_rdf_sync()` called `rdflib.Graph().parse()` directly, bypassing the `defusedxml`-based XXE protection already present in `semantica/explorer/utils/rdf_parser.py`. Now routes through `_safe_parse_rdf()` from that module, applying consistent protection for all RDF/XML parse paths. + - **Search scans whole graph** (`GET /api/ontology/search`) — the endpoint fetched up to 999 999 nodes and performed a linear Python substring scan on every request. Replaced with `session.search(q, limit * 6)` which uses the `GraphSearchIndex`; results are then post-filtered by `_SEARCHABLE_TYPES` and `entity_type` before being returned up to the requested limit. + - **ReDoS in format detector** (security advisory #23, CodeQL `py/polynomial-redos`, CWE-1333/730/400) — `_detect_format()` used `re.match(r"_:\w+|<[^>]+>\s+<[^>]+>", ...)` to detect N-Triples content. The `<[^>]+>\s+<[^>]+>` alternative was flagged as a polynomial regular expression on uncontrolled data. The URI-subject branch was already unreachable (strings starting with `<` return `"xml"` two lines above), so the entire regex was replaced with two O(1) string operations: `stripped.startswith("_:")` and `" <" in stripped`. `import re` removed as now unused. + +- **Feature: Ontology Hub — Registry, Loader, Entity Search & SKOS Vocabulary Manager** (closes #518, part of #517, by @KaifAhmad1): + - Added a sixth workspace, **Ontology Hub** (`ontology-hub`), to the Knowledge Explorer sidebar with a `GitMerge` icon and "Schema Governance" kicker. The workspace shell hosts six tabs — Registry, Editor, Versions, Alignments, Health, and SHACL — with the active tab persisted in the `ontologyTab` URL search parameter via `window.history.replaceState`. + - **Registry tab (`OntologyManager`)** — full CRUD interface for loaded ontologies. Lists entries with color-coded status badges (published / draft / external), format badges (Turtle / XML / JSON-LD / N-Triples), per-ontology stats (class count, concept count, property count), source URL link, and enable/disable toggle, refresh, and remove (with confirmation) actions. Toolbar provides a live search input, All / OWL / SKOS / INTERNAL / EXTERNAL filter pills, an Entity Search button, and a "Load Ontology" button. Empty state surfaces a prominent CTA. Action feedback bar auto-hides after 3 seconds. + - **Load Ontology modal (`OntologyLoader`)** — three-tab modal overlay for importing ontologies: + - *URL Import*: paste any HTTP(S) URL, click "Fetch Preview" to call `POST /api/ontology/preview` (fetches up to 20 MB, parses with rdflib, returns title / namespace / version / license / format / triple count), then "Load Ontology" (`POST /api/ontology/load`). Advanced options toggle exposes format override, custom display name, description, and tags fields. + - *File Upload*: drag-and-drop zone (or browse) accepting `.ttl`, `.rdf`, `.owl`, `.nt`, `.jsonld` files; format auto-detected from extension; multipart `POST /api/ontology/load`. + - *Create New*: three modes — From Scratch (namespace + name + description + tags), From Data (sample data textarea for schema inference via `OntologyEngine.from_data()`), From Text (free-text textarea for LLM-assisted schema generation via `OntologyEngine.from_text()`); calls `POST /api/ontology/create`. + - **Entity Search panel (`OntologySearch`)** — slide-in right panel with debounced 320 ms search across all loaded ontologies via `GET /api/ontology/search`. Type filter pills: All, Class, Property, Individual, Concept, Scheme. Result rows show label, type badge, URI, definition snippet, and source ontology. Selecting a result opens a detail panel that fetches `GET /api/ontology/entity/{uri}` and renders label, URI, definition, superclasses, subclasses, domain, range, instance count, and external URI link. Long lists use a `CollapsibleList` expanding up to 12 items. + - **SKOS Vocabulary Manager (`SKOSVocabularyManager`)** — hierarchical SKOS concept browser activated when a SKOS ontology is selected in the registry. Fetches scheme hierarchy from `GET /api/vocabulary/hierarchy`, renders a recursive `ConceptTreeNode` tree with depth-based indentation, expand/collapse, and selection highlight. Client-side `filterConcepts()` matches label, altLabels, and description. Detail panel fetches `GET /api/ontology/skos/concept/{uri}` and displays all SKOS annotation properties (definition, scopeNote, example, historyNote, editorialNote, changeNote) plus broader / narrower / related / exactMatch / closeMatch lists with clickable navigation. + - **Backend (`semantica/explorer/routes/ontology.py`)** — 12 FastAPI endpoints under `GET|POST /api/ontology`: + - `GET /registry` — returns the in-memory `app.state.ontology_registry` dict as a list, with optional `q` search and `status` filter query params. + - `POST /preview` — streams up to 20 MB from a URL via `requests.get` in `asyncio.to_thread`, parses RDF with rdflib (auto-detects format or accepts `format` param), returns `OntologyPreview` metadata. + - `POST /load` — URL or multipart file load; stores parsed nodes/edges into the active graph session and registers an `OntologyEntry` in the registry. + - `POST /create` — creates an ontology from scratch, sample data, or natural-language text; falls back to a minimal ontology shell if `OntologyEngine` is unavailable. + - `GET /search` — full-text entity search with optional `type` filter across all nodes whose `node_type` maps to class, property, individual, concept, or scheme. + - `GET /entity/{uri:path}` — entity detail: label, type, definition, superclasses, subclasses, domain, range, instance count. + - `GET /skos/schemes` — lists all `skos:ConceptScheme` nodes in the active session. + - `GET /skos/concept/{uri:path}` — full SKOS concept detail including all annotation properties and relation sets. + - `DELETE /{uri:path}`, `PATCH /{uri:path}/toggle`, `POST /{uri:path}/refresh` — remove, enable/disable toggle, and re-fetch/re-parse for registered ontologies. Route ordering places all literal paths before the `:path` wildcards to avoid shadowing. + - Helper internals: `_parse_rdf_sync()` (rdflib parse → nodes/edges/metadata), `_fetch_url_sync()` (streaming requests with 20 MB cap), `_classify_node_type()` (maps raw RDF types to canonical categories), `_uri_to_prefix()` (URI → prefixed form for display). + - Editor, Versions (Subissue 2) and Alignments, Health, SHACL (Subissue 3) tabs render descriptive stub cards with amber subissue badges as placeholders for upcoming implementations. + - TypeScript compiled with zero errors; Vite dev server starts cleanly with the new workspace lazy-loaded via `React.lazy` + `Suspense`. + - **Feature: Explorer landing page redesign** (PR #516 by @ZohaibHassan16, review fixes by @KaifAhmad1): - Replaced the plain welcome screen with a full landing composition: premium hero section, product preview mock with animated SVG graph, live graph status metrics, intelligence capability band, and consolidated workspace launcher. - `WelcomeScreen` fetches `/api/graph/stats` on mount with `AbortController` cleanup and displays live node and edge counts; falls back to `"Live"` / `"Ready"` labels when the endpoint is unavailable. diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 1f057188..4c0fc177 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -19,6 +19,7 @@ "graphology-metrics": "^2.4.0", "graphology-shortest-path": "^2.1.0", "lucide-react": "^1.7.0", + "playwright": "^1.59.1", "react": "^19.2.4", "react-arborist": "^3.4.3", "react-dom": "^19.2.4", @@ -3467,6 +3468,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", diff --git a/explorer/package.json b/explorer/package.json index 23df2b07..53de5b97 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -23,6 +23,7 @@ "graphology-metrics": "^2.4.0", "graphology-shortest-path": "^2.1.0", "lucide-react": "^1.7.0", + "playwright": "^1.59.1", "react": "^19.2.4", "react-arborist": "^3.4.3", "react-dom": "^19.2.4", diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 3aea3078..2713f078 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -29,8 +29,9 @@ const RegistryTab = lazy(() => import('./workspaces/EnrichWorkspace/RegistryTab' const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/EntityResolutionTab').then((module) => ({ default: module.EntityResolutionTab }))); const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab }))); const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab }))); +const OntologyWorkspace = lazy(() => import('./workspaces/OntologyWorkspace').then((module) => ({ default: module.OntologyWorkspace }))); -type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage'; +type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage' | 'ontology-hub'; type ExploreView = 'graph' | 'vocabulary'; type AnalyzeView = 'sparql' | 'reasoning'; type EnrichView = 'import' | 'merge' | 'registry' | 'resolve'; @@ -80,6 +81,7 @@ const navItems: NavItem[] = [ { id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale }, { id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus }, { id: 'manage', label: 'Manage', hint: 'Lineage and governance tooling', icon: Settings2 }, + { id: 'ontology-hub', label: 'Ontology Hub', hint: 'Schema governance, registry, and vocabulary management', icon: GitMerge }, ]; const shellStyles = ` @@ -1236,6 +1238,7 @@ export default function App() { const [enrichView, setEnrichView] = useState('import'); const [manageView, setManageView] = useState('lineage'); + const renderWorkspace = () => { if (activeWorkspace === 'welcome') { return ( @@ -1358,6 +1361,21 @@ export default function App() { ); } + if (activeWorkspace === 'ontology-hub') { + return ( + + }> + + + + ); + } + return ( void; + onClose: () => void; +} + +function Badge({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +function FieldGroup({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +function Input({ + value, + onChange, + placeholder, + type = "text", +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + style={inputStyle} + /> + ); +} + +function Textarea({ + value, + onChange, + placeholder, + rows = 5, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + rows?: number; +}) { + return ( +