diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ef6f82a..094517f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### 보안 패치 (CodeQL extended current-head) +- `nanoid` `5.1.6`의 High DoS 취약점(`CVE-2026-67214`, `GHSA-28wg-ghj8-5hjv`)을 패치 버전 `5.1.16`으로 올리고, package manifest·workspace override·frozen lockfile을 하나의 보안 계약으로 동기화했습니다. 실제 lockfile 회귀 테스트와 APA 7 근거는 `docs/doctoring/nanoid-cve-2026-67214.md`에 기록했습니다. - `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다. - CodeQL `extended` 기본 설정이 current `develop`에서 확인한 Critical 8건·High 21건·Medium 1건을 코드 경계에서 제거합니다. 서버 요청은 검증된 loopback/HTTPS origin, 동일 OIDC issuer origin, 허용 API 경로·쿼리만 재구성하고 redirect를 자동 추종하지 않으며, 공개 IPv6 authority를 보존합니다. UI smoke는 고정 Node/Next 실행 파일과 인자, localhost:3001 allowlist, private `mkdtemp` artifact 디렉터리 및 containment 검사만 사용합니다. - OIDC token endpoint는 운영 환경에서 서버 전용 `OIDC_ALLOWED_HOSTS` 정확 호스트 allowlist를 필수로 적용합니다. hostname의 모든 DNS 결과가 공인 주소인지 검증한 뒤 해당 주소 집합을 native HTTP(S) 연결의 `lookup`에 고정하고, 원래 issuer hostname은 Host/TLS SNI로 유지해 사설 주소 해석과 DNS rebinding 사이의 TOCTOU를 차단합니다. 실패 로그는 입력 URL·token 대신 고정된 configuration/DNS·transport/response/backend-verification reason code만 남깁니다. diff --git a/backend/api/dav.py b/backend/api/dav.py index d618c25f3..37efa5777 100644 --- a/backend/api/dav.py +++ b/backend/api/dav.py @@ -13,8 +13,11 @@ router = APIRouter(prefix="/dav", tags=["dav"]) +IMPLEMENTED_DAV_METHODS = ("OPTIONS", "PROPFIND") + def _normalize_dav_authorization_path(path: str) -> str: + """Decode a DAV path within a bounded authorization-normalization loop.""" normalized_path = path.replace("\\", "/") for _ in range(100): decoded_path = unquote(normalized_path).replace("\\", "/") @@ -25,6 +28,7 @@ def _normalize_dav_authorization_path(path: str) -> str: def _dav_path_owner_user_id(path: str) -> str | None: + """Return the owner segment for a traversal-safe DAV path.""" path = _normalize_dav_authorization_path(path) if any(segment in {".", ".."} for segment in path.split("/")): return None @@ -36,6 +40,7 @@ def _dav_path_owner_user_id(path: str) -> str | None: def _ensure_dav_owner_scope(path: str, auth_context: AuthContext) -> None: + """Require the DAV path owner to match the authenticated user.""" owner_user_id = _dav_path_owner_user_id(path) if owner_user_id is None: raise HTTPException( @@ -51,10 +56,12 @@ def _ensure_dav_owner_scope(path: str, auth_context: AuthContext) -> None: def _dav_path_segments(path: str) -> list[str]: + """Return non-empty DAV path segments.""" return [segment for segment in path.strip("/").split("/") if segment] def _dav_multistatus_xml(responses: list[str]) -> str: + """Render one DAV multistatus document from validated response fragments.""" response_xml = "\n".join(responses) if response_xml: response_xml = f"\n{response_xml}\n" @@ -68,6 +75,7 @@ def _dav_response_xml( display_name: str, is_collection: bool = True, ) -> str: + """Render one escaped DAV response element.""" resourcetype = "" if is_collection else "" escaped_href = escape_xml_text(href) escaped_display_name = escape_xml_text(display_name) @@ -84,6 +92,7 @@ def _dav_response_xml( def _dav_xml_response(responses: list[str]) -> Response: + """Return a DAV 207 multistatus response.""" return Response( content=_dav_multistatus_xml(responses), media_type="application/xml", @@ -92,6 +101,7 @@ def _dav_xml_response(responses: list[str]) -> Response: def _dav_depth(request: Request) -> str: + """Normalize supported DAV Depth values to zero or one.""" depth = request.headers.get("Depth", "1").strip().lower() if depth == "0": return "0" @@ -99,6 +109,7 @@ def _dav_depth(request: Request) -> str: def _project_folder_response(path_owner_user_id: str, folder: dict) -> str: + """Render one project-folder collection response.""" folder_uid = str(folder["folder_uid"]) project_name = str(folder["project_name"]) return _dav_response_xml( @@ -115,6 +126,7 @@ async def _handle_project_propfind( auth_context: AuthContext, db: AsyncSession, ) -> Response: + """Return tenant-scoped project collection metadata for PROPFIND.""" segments = _dav_path_segments(path) if len(segments) < 2 or segments[1] != "projects": raise HTTPException(status_code=404, detail="DAV collection not found") @@ -158,73 +170,37 @@ async def _handle_project_propfind( @router.api_route( "/{path:path}", - methods=["PROPFIND", "REPORT", "MKCOL", "GET", "PUT", "DELETE", "OPTIONS"], + methods=list(IMPLEMENTED_DAV_METHODS), ) async def dav_handler( request: Request, path: str, auth_context: AuthContext = Depends(get_auth_context), db: AsyncSession = Depends(get_db), -): - """ - Route the authenticated DAV surface that is implemented for this slice. +) -> Response: + """Serve only the authenticated DAV capabilities implemented in production. - Collection discovery is served from the server-side project registry. - Provider-backed writeback stays fail-closed until source capability and - ETag/If-Match enforcement are available through signed writeback intents. + ScopeWeave-compatible clients can discover project collections through + ``PROPFIND``. Provider-backed mutation verbs are deliberately not registered; + the framework returns ``405 Method Not Allowed`` rather than advertising a + handler that can only return ``501 Not Implemented``. """ _ensure_dav_owner_scope(path, auth_context) safe_path = repr(path)[1:-1] logger.info("DAV Request: %s /%s", request.method, safe_path) if request.method == "OPTIONS": - headers = { - "DAV": "1, 2, 3, calendar-access, addressbook", - "Allow": ( - "OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, COPY, MOVE, MKCOL, " - "PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT" - ), - } - return Response(status_code=200, headers=headers) - - if request.method == "PROPFIND": - return await _handle_project_propfind( - request=request, - path=path, - auth_context=auth_context, - db=db, - ) - - if request.method == "PUT": - body = await request.body() - safe_path = repr(path)[1:-1] - logger.info("DAV PUT received %s bytes at /%s", len(body), safe_path) - logger.warning( - "DAV PUT rejected at /%s: provider-backed DAV writeback is not " - "implemented; signed writeback-intent API is required", - safe_path, - ) return Response( - content=( - "Provider-backed DAV writeback is not implemented; use signed " - "writeback-intent APIs until source, capability, and " - "ETag/If-Match checks are enforced." - ), - media_type="text/plain", - status_code=501, + status_code=200, + headers={ + "DAV": "1", + "Allow": ", ".join(IMPLEMENTED_DAV_METHODS), + }, ) - logger.warning( - "DAV %s rejected at /%s: method is not implemented for the " - "provider-backed DAV gateway", - request.method, - safe_path, - ) - return Response( - content=( - "Provider-backed DAV method is not implemented; use supported " - "PROPFIND/OPTIONS discovery or signed writeback-intent APIs." - ), - media_type="text/plain", - status_code=501, + return await _handle_project_propfind( + request=request, + path=path, + auth_context=auth_context, + db=db, ) diff --git a/backend/docs/dav-production-capabilities.md b/backend/docs/dav-production-capabilities.md new file mode 100644 index 000000000..678ab4cce --- /dev/null +++ b/backend/docs/dav-production-capabilities.md @@ -0,0 +1,48 @@ +# DAV Production Capability Contract + +Naruon exposes only DAV operations that have complete tenant authorization, +persisted data, deterministic tests, and production handlers. + +## Supported DAV methods + +| Method | Status | Production behavior | +| --- | --- | --- | +| `OPTIONS` | Supported | Returns `DAV: 1` and advertises only `OPTIONS, PROPFIND`. | +| `PROPFIND` | Supported | Returns tenant-scoped project collections from PostgreSQL. | + +The router does not register `GET`, `PUT`, `DELETE`, `MKCOL`, `REPORT`, +`PROPPATCH`, `COPY`, `MOVE`, `LOCK`, or `UNLOCK`. FastAPI therefore returns +`405 Method Not Allowed` rather than exposing success-shaped or `501` stubs. +Mutation is available only through Naruon's authenticated writeback-intent +workflow until each DAV verb has provider capability discovery, precondition +handling (`ETag`/`If-Match` where applicable), durable audit evidence, and real +connector execution. + +## Data and identity boundary + +- DAV paths must contain the authenticated owner user ID. +- Project folders come from the persisted `project_folder` registry and are + scoped by user and organization. +- Connected WebDAV accounts come from `webdav_account` records; hard-coded demo + accounts and folders are forbidden. +- Runtime code must not return successful attachment synchronization when no + provider write occurred. +- XML values are escaped and request-path control characters are encoded before + logging. + +## Verification + +Every changed DAV capability requires: + +1. a fail-first API regression; +2. tenant and owner authorization tests; +3. positive provider or database integration evidence; +4. negative tests for unsupported methods and stale preconditions; +5. production statement and branch coverage of 100%; and +6. exact-head security and required checks before merge. + +## Primary standard — APA 7th + +Dusseault, L. (2007). *HTTP extensions for Web Distributed Authoring and +Versioning (WebDAV)* (RFC 4918). Internet Engineering Task Force. +https://doi.org/10.17487/RFC4918 diff --git a/backend/services/webdav_service.py b/backend/services/webdav_service.py index 284bd1696..a60d1b6e7 100644 --- a/backend/services/webdav_service.py +++ b/backend/services/webdav_service.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Dict, List from sqlalchemy import select @@ -7,103 +6,16 @@ from db.models import Email, ProjectFolder, TicketTask, WebdavAccount from services.knowledge_extractor import SELF_SENT_KNOWLEDGE_SOURCE -logger = logging.getLogger(__name__) - def safe_webdav_source_label(source_id: str | None) -> str: + """Return an operator-safe label that exposes only the opaque source ID.""" if not source_id: return "WebDAV source" return f"WebDAV source {source_id}" -async def sync_webdav_folders(session, user_id: str, organization_id: str | None): - """ - Fetch folder structures for all WebDAV accounts of the user. - """ - from urllib.parse import urlsplit - - from core.url_validation import _reject_unsafe_ip_literal - - logger.info(f"Syncing WebDAV folders for user {user_id}") - organization_filter = ( - WebdavAccount.organization_id == organization_id - if organization_id is not None - else WebdavAccount.organization_id.is_(None) - ) - stmt = select(WebdavAccount.server_url, WebdavAccount.source_uid).where( - WebdavAccount.user_id == user_id, organization_filter - ) - res = await session.execute(stmt) - accounts = res.all() - for server_url, source_uid in accounts: - try: - if server_url: - parsed = urlsplit(server_url) - if parsed.scheme != "https": - raise ValueError("WebDAV server_url must use HTTPS") - if not parsed.hostname: - raise ValueError("WebDAV server_url must include a hostname") - _reject_unsafe_ip_literal("WebDAV server_url", parsed.hostname) - except ValueError as exc: - logger.warning( - "Invalid WebDAV server URL for source %s: %s", - source_uid or "unknown", - exc, - ) - continue - - logger.info( - "Fetched folder structures for WebDAV source %s", - source_uid or "unknown", - ) - return True - - class WebDavService: - def __init__(self): - self._mock_accounts = { - "demo_user": [ - { - "source_id": "webdav_src_demo_primary", - "server_url": "https://webdav.naruon.net", - "username": "demo_user", - "display_label": "WebDAV source webdav_src_demo_primary", - "writeback_enabled": True, - "etag": "etag-webdav-demo-primary", - } - ] - } - self._mock_folders = { - "demo_user": [ - { - "folder_uid": "webdav_folder_demo_roadmap", - "project_name": "Naruon Roadmap 2026", - "webdav_path": "/Projects/Naruon_Roadmap_2026", - "owner_user_id": "demo_user", - "organization_id": None, - }, - { - "folder_uid": "webdav_folder_demo_marketing", - "project_name": "Marketing Assets", - "webdav_path": "/Projects/Marketing_Assets", - "owner_user_id": "demo_user", - "organization_id": None, - }, - ] - } - - def get_connected_accounts(self, user_id: str) -> List[Dict[str, Any]]: - """ - Fetch connected WebDAV accounts for a user. - In a real implementation, this queries the database. - """ - return self._mock_accounts.get(user_id, []) - - def get_project_folders(self, user_id: str) -> List[Dict[str, Any]]: - """ - Fetch the list of project folders structured by AI. - """ - return self._mock_folders.get(user_id, []) + """Resolve tenant-scoped WebDAV discovery and signed writeback intents.""" async def get_connected_accounts_from_db( self, @@ -112,6 +24,7 @@ async def get_connected_accounts_from_db( organization_id: str | None = None, workspace_id: str | None = None, ) -> List[Dict[str, Any]]: + """Return database-authoritative WebDAV account capabilities for a scope.""" scope_filters = [ WebdavAccount.user_id == user_id, WebdavAccount.organization_id == organization_id @@ -143,6 +56,7 @@ async def get_project_folders_from_db( organization_id: str | None, folder_uid: str | None = None, ) -> List[Dict[str, Any]]: + """Return tenant-scoped project folders from the persisted registry.""" stmt = select(ProjectFolder).where( ProjectFolder.user_id == user_id, ProjectFolder.organization_id == organization_id @@ -163,28 +77,6 @@ async def get_project_folders_from_db( for folder in result.scalars().all() ] - def sync_attachments_to_folder(self, email_id: str, project_name: str) -> bool: - """ - Organizes an email's attachments into the specified WebDAV project folder. - """ - logger.info( - f"Syncing attachments from email {email_id} to project {project_name}" - ) - # Mock implementation: in reality, this would download from storage and upload via webdavclient3 - return True - - def determine_webdav_writeback_intent( - self, user_id: str, target_source_id: str | None = None - ) -> Dict[str, Any]: - """ - Server-authoritative WebDAV writeback source selection. - """ - accounts = self.get_connected_accounts(user_id) - return self.determine_webdav_writeback_intent_from_accounts( - accounts, - target_source_id=target_source_id, - ) - async def determine_webdav_writeback_intent_from_db( self, session: AsyncSession, @@ -193,6 +85,7 @@ async def determine_webdav_writeback_intent_from_db( workspace_id: str | None = None, target_source_id: str | None = None, ) -> Dict[str, Any]: + """Select a writable persisted account without executing provider writes.""" accounts = await self.get_connected_accounts_from_db( session, user_id, organization_id, workspace_id ) @@ -210,6 +103,7 @@ async def determine_knowledge_materialization_intent_from_db( source_task_id: str, target_source_id: str | None = None, ) -> Dict[str, Any]: + """Create a provenance-bound intent for materializing self-sent knowledge.""" task_result = await session.execute( select(TicketTask, Email.message_id) .outerjoin( @@ -278,6 +172,7 @@ def determine_webdav_writeback_intent_from_accounts( accounts: List[Dict[str, Any]], target_source_id: str | None = None, ) -> Dict[str, Any]: + """Select one write-enabled account from a server-authoritative inventory.""" writable_accounts = { account["source_id"]: account for account in accounts diff --git a/backend/tests/test_dav_api.py b/backend/tests/test_dav_api.py index 70d455ea9..050e1d6c0 100644 --- a/backend/tests/test_dav_api.py +++ b/backend/tests/test_dav_api.py @@ -1,6 +1,9 @@ -import defusedxml.ElementTree as ET +import asyncio +import logging +import defusedxml.ElementTree as ET import pytest +from fastapi import Request from fastapi.testclient import TestClient from main import app @@ -61,11 +64,19 @@ def test_dav_route_uses_signed_session_dependency(): assert response.status_code == 401 -def test_dav_options(dev_auth_dependency_overrides): +def test_dav_options_advertises_only_implemented_capabilities( + dev_auth_dependency_overrides, +): with TestClient(app) as client: response = client.options("/dav/user123/projects/", headers=AUTH_HEADERS) - assert response.status_code == 200 - assert "calendar-access" in response.headers.get("DAV", "") + + assert response.status_code == 200 + assert response.headers["DAV"] == "1" + assert { + method.strip() + for method in response.headers["Allow"].split(",") + if method.strip() + } == {"OPTIONS", "PROPFIND"} def test_dav_rejects_different_user_path(dev_auth_dependency_overrides): @@ -119,58 +130,35 @@ def test_dav_propfind_escapes_path_values( ET.fromstring(response.text) -def test_dav_put(dev_auth_dependency_overrides, caplog): - import logging - - caplog.set_level(logging.WARNING, logger="api.dav") +@pytest.mark.parametrize( + "method", + ["GET", "PUT", "DELETE", "MKCOL", "REPORT", "PROPPATCH", "COPY", "MOVE", "LOCK", "UNLOCK"], +) +def test_dav_unimplemented_methods_are_not_registered( + dev_auth_dependency_overrides, + method, +): with TestClient(app) as client: - response = client.put( + response = client.request( + method, "/dav/user123/projects/file.ics", - content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR", + content=b"BEGIN:VCALENDAR\r\nEND:VCALENDAR" if method == "PUT" else None, headers=AUTH_HEADERS, ) - assert response.status_code == 501 - assert "Provider-backed DAV writeback is not implemented" in response.text - assert "etag" not in {header.lower() for header in response.headers} - assert any( - "provider-backed DAV writeback is not implemented" in record.getMessage() - for record in caplog.records - ) - - -def test_dav_unsupported_method_logs_reason(dev_auth_dependency_overrides, caplog): - import logging - caplog.set_level(logging.WARNING, logger="api.dav") - with TestClient(app) as client: - response = client.delete( - "/dav/user123/projects/file.ics", - headers=AUTH_HEADERS, - ) + assert response.status_code == 405 + assert "etag" not in {header.lower() for header in response.headers} + assert { + allowed.strip() + for allowed in response.headers["Allow"].split(",") + if allowed.strip() + } == {"OPTIONS", "PROPFIND"} - assert response.status_code == 501 - assert "Provider-backed DAV method is not implemented" in response.text - assert any( - "method is not implemented for the provider-backed DAV gateway" - in record.getMessage() - for record in caplog.records - ) def test_dav_log_injection_prevention(dev_auth_dependency_overrides, caplog): - """ - Test that DAV handlers safely encode control characters in the requested path, - preventing log injection vulnerabilities. - """ - import logging - + """DAV request logs encode control characters rather than emitting them.""" caplog.set_level(logging.INFO) malicious_path = "user123/projects/test\x1b[31minjected\n\r" - - # Since HTTP clients block raw control chars and starlette unquotes but might reject it before reaching our route, - # we test the handler directly to ensure the logger is using repr(). - import asyncio - from fastapi import Request - scope = { "type": "http", "method": "OPTIONS", @@ -180,24 +168,29 @@ def test_dav_log_injection_prevention(dev_auth_dependency_overrides, caplog): async def run_handler(): req = Request(scope) from api.auth import AuthContext - auth_ctx = AuthContext(user_id="user123", organization_id="org1", role="user", group_ids=[], workspace_id="ws1") - from api.dav import dav_handler + + auth_ctx = AuthContext( + user_id="user123", + organization_id="org1", + role="user", + group_ids=[], + workspace_id="ws1", + ) await dav_handler(request=req, path=malicious_path, auth_context=auth_ctx) asyncio.run(run_handler()) - # In some fastapi versions, returning an unexpected path might return 404. Let's just assert the log was captured. - # The vulnerability is about the logger. - - # Assert that the raw ansi escape / newline was not logged, but encoded raw_ansi = "\x1b[31m" found_in_logs = False for record in caplog.records: if "DAV Request" in record.message: assert raw_ansi not in record.message, "Raw ANSI escape sequence found in logs!" assert "\n" not in record.message[12:], "Raw newline found in log message body!" - assert "\\x1b[31minjected\\n\\r" in record.message or "\\x1b[31minjected\\r\\n" in record.message, "Escaped characters missing from log message!" + assert ( + "\\x1b[31minjected\\n\\r" in record.message + or "\\x1b[31minjected\\r\\n" in record.message + ), "Escaped characters missing from log message!" found_in_logs = True assert found_in_logs, "DAV Request log was not found" diff --git a/backend/tests/test_dav_sync.py b/backend/tests/test_dav_sync.py index 8f187eaf3..2b5fe7428 100644 --- a/backend/tests/test_dav_sync.py +++ b/backend/tests/test_dav_sync.py @@ -1,5 +1,6 @@ +from unittest.mock import AsyncMock, MagicMock, patch + import pytest -from unittest.mock import AsyncMock, patch, MagicMock def test_webdav_source_label_uses_opaque_source_id(): @@ -15,19 +16,18 @@ def test_webdav_source_label_uses_opaque_source_id(): @pytest.mark.asyncio async def test_caldav_event_parsing_and_sync(): from services.caldav_service import sync_caldav_accounts - + session_mock = AsyncMock() - # Mock finding accounts account_mock = MagicMock() account_mock.id = 1 account_mock.server_url = "https://alice:secret@caldav.example.com/calendars" account_mock.username = "user" account_mock.credentials_encrypted = "pass" - + execute_res = MagicMock() execute_res.scalars.return_value.all.return_value = [account_mock] session_mock.execute.return_value = execute_res - + with patch("services.caldav_service.logger") as logger_mock: synced = await sync_caldav_accounts(session_mock, "user_1") @@ -39,102 +39,24 @@ async def test_caldav_event_parsing_and_sync(): assert logged_url == "https://caldav.example.com/calendars" assert "secret" not in logged_url -@pytest.mark.asyncio -async def test_webdav_file_listing_and_sync(): - from services.webdav_service import sync_webdav_folders - - session_mock = AsyncMock() - account_mock = MagicMock() - account_mock.source_uid = "webdav_src_primary" - account_mock.server_url = "https://webdav.example.com" - - execute_res = MagicMock() - execute_res.scalars.return_value.all.return_value = [account_mock] - execute_res.all.return_value = [('https://webdav.example.com', 'webdav_src_primary')] - session_mock.execute.return_value = execute_res - - with patch("services.webdav_service.logger") as logger_mock: - await sync_webdav_folders(session_mock, "user_1", "org_1") - logger_mock.info.assert_any_call( - "Fetched folder structures for WebDAV source %s", - "webdav_src_primary", - ) - logged_args = " ".join( - str(arg) - for call in logger_mock.info.call_args_list - for arg in call.args - ) - assert "https://webdav.example.com" not in logged_args - - statement_text = str(session_mock.execute.call_args.args[0]) - assert "webdav_accounts.user_id" in statement_text - assert "webdav_accounts.organization_id" in statement_text - - -@pytest.mark.asyncio -async def test_webdav_sync_skips_hostless_https_url(): - from services.webdav_service import sync_webdav_folders - - session_mock = AsyncMock() - account_mock = MagicMock() - account_mock.source_uid = "webdav_src_primary" - account_mock.server_url = "https:///missing-host" - - execute_res = MagicMock() - execute_res.scalars.return_value.all.return_value = [account_mock] - execute_res.all.return_value = [('https:///missing-host', 'webdav_src_primary')] - session_mock.execute.return_value = execute_res - with patch("services.webdav_service.logger") as logger_mock: - await sync_webdav_folders(session_mock, "user_1", "org_1") +def test_webdav_service_has_no_demo_backing_store_or_noop_write_api(): + from services.webdav_service import WebDavService - logger_mock.warning.assert_called_once() - success_calls = [ - call.args - for call in logger_mock.info.call_args_list - if call.args[:1] - == ("Fetched folder structures for WebDAV source %s",) - ] - assert success_calls == [] - - statement_text = str(session_mock.execute.call_args.args[0]) - assert "webdav_accounts.user_id" in statement_text - assert "webdav_accounts.organization_id" in statement_text + service = WebDavService() + forbidden_attributes = { + "_mock_accounts", + "_mock_folders", + "get_connected_accounts", + "get_project_folders", + "sync_attachments_to_folder", + "determine_webdav_writeback_intent", + } -@pytest.mark.asyncio -async def test_webdav_sync_skips_non_https_url(): - """ - Test that sync skips a WebDAV account if the URL is not HTTPS. - """ - from services.webdav_service import sync_webdav_folders + assert forbidden_attributes.isdisjoint(dir(service)) - session_mock = AsyncMock() - account_mock = MagicMock() - account_mock.source_uid = "webdav_src_primary" - account_mock.server_url = "http://webdav.example.com" - execute_res = MagicMock() - execute_res.scalars.return_value.all.return_value = [account_mock] - execute_res.all.return_value = [('http://webdav.example.com', 'webdav_src_primary')] - session_mock.execute.return_value = execute_res +def test_webdav_runtime_module_has_no_success_shaped_fake_sync(): + from services import webdav_service as module - with patch("services.webdav_service.logger") as logger_mock: - await sync_webdav_folders(session_mock, "user_1", "org_1") - - logger_mock.warning.assert_called_once() - warning_args = logger_mock.warning.call_args.args - assert "Invalid WebDAV server URL" in warning_args[0] - assert isinstance(warning_args[2], ValueError) - assert str(warning_args[2]) == "WebDAV server_url must use HTTPS" - - success_calls = [ - call.args - for call in logger_mock.info.call_args_list - if call.args[:1] - == ("Fetched folder structures for WebDAV source %s",) - ] - assert success_calls == [] - - statement_text = str(session_mock.execute.call_args.args[0]) - assert "webdav_accounts.user_id" in statement_text - assert "webdav_accounts.organization_id" in statement_text + assert not hasattr(module, "sync_webdav_folders") diff --git a/backend/tests/test_frontend_nanoid_security.py b/backend/tests/test_frontend_nanoid_security.py index f80b23a01..c465ed802 100644 --- a/backend/tests/test_frontend_nanoid_security.py +++ b/backend/tests/test_frontend_nanoid_security.py @@ -1,4 +1,4 @@ -"""Fail closed when the frontend lock resolves the vulnerable Nano ID release.""" +"""Fail closed when the frontend lock resolves a vulnerable Nano ID release.""" from __future__ import annotations @@ -8,21 +8,21 @@ REPO_ROOT = Path(__file__).resolve().parents[2] FRONTEND_LOCK = REPO_ROOT / "frontend" / "pnpm-lock.yaml" -PATCHED_NANOID_VERSION = "3.3.18" +PATCHED_NANOID_VERSION = "5.1.16" -def test_frontend_lock_resolves_only_patched_nanoid_3x() -> None: - """Require PostCSS's Nano ID dependency to resolve to the reviewed patched 3.x release.""" +def test_frontend_lock_resolves_only_patched_nanoid() -> None: + """Require PostCSS's Nano ID dependency to resolve to the reviewed patched pin.""" lock = yaml.safe_load(FRONTEND_LOCK.read_text(encoding="utf-8")) for section_name in ("packages", "snapshots"): section = lock[section_name] - nanoid_3x = sorted( + nanoid_keys = sorted( package_key for package_key in section - if package_key.startswith("nanoid@3.") + if package_key.startswith("nanoid@") ) - assert nanoid_3x == [f"nanoid@{PATCHED_NANOID_VERSION}"] + assert nanoid_keys == [f"nanoid@{PATCHED_NANOID_VERSION}"] postcss_snapshot = lock["snapshots"]["postcss@8.5.24"] assert postcss_snapshot["dependencies"]["nanoid"] == PATCHED_NANOID_VERSION diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index a23c70746..5634f85dc 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -227,6 +227,96 @@ def test_strix_ci_requirements_use_security_quality_clean_pins() -> None: assert "python-multipart==0.0.32" in strix_ci_requirements +def test_cryptography_runtime_pins_are_bleichenbacher_oracle_fixed() -> None: + """Require every governed Python surface to use the first oracle-safe release.""" + backend_requirements = read_repo_text("backend/requirements.txt") + backend_project_text = read_repo_text("backend/pyproject.toml") + backend_project = tomllib.loads(backend_project_text) + backend_lock = tomllib.loads(read_repo_text("backend/uv.lock")) + backend_hashes = read_repo_text("backend/requirements-hashes.txt") + strix_requirements = read_repo_text("requirements-strix-ci.txt") + strix_hashes = read_repo_text("requirements-strix-ci-hashes.txt") + + def pins(text: str, package: str) -> list[str]: + return re.findall(rf"(?m)^{re.escape(package)}==[^\s\\]+", text) + + for governed_text in ( + backend_requirements, + backend_hashes, + strix_requirements, + strix_hashes, + ): + assert pins(governed_text, "cryptography") == ["cryptography==50.0.0"] + assert [ + dependency + for dependency in backend_project["project"]["dependencies"] + if dependency.startswith("cryptography") + ] == ["cryptography==50.0.0"] + cryptography_versions = { + package["version"] + for package in backend_lock["package"] + if package["name"] == "cryptography" + } + assert cryptography_versions == {"50.0.0"} + assert pins(strix_requirements, "protobuf") == ["protobuf==6.33.6"] + assert pins(strix_hashes, "protobuf") == ["protobuf==6.33.6"] + + +def test_frontend_postcss_lock_is_cve_2026_69153_fixed() -> None: + """Keep every manifest and lock surface on the first currently governed fix.""" + frontend_package = json.loads(read_repo_text("frontend/package.json")) + frontend_workspace = yaml.safe_load(read_repo_text("frontend/pnpm-workspace.yaml")) + frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml")) + + assert frontend_package["devDependencies"]["postcss"] == "8.5.24" + assert frontend_package["overrides"]["postcss"] == "8.5.24" + assert frontend_package["resolutions"]["postcss"] == "8.5.24" + assert frontend_workspace["overrides"]["postcss"] == "8.5.24" + assert frontend_lock["overrides"]["postcss"] == "8.5.24" + assert frontend_lock["importers"]["."]["devDependencies"]["postcss"] == { + "specifier": "8.5.24", + "version": "8.5.24", + } + + for section in ("packages", "snapshots"): + postcss_keys = [ + package + for package in frontend_lock[section] + if package.startswith("postcss@") + ] + assert postcss_keys == ["postcss@8.5.24"] + + +def test_frontend_tooling_lock_uses_current_audit_fixed_transitive_versions() -> None: + """Keep newly disclosed audit fixes aligned across manifest and pnpm lock.""" + frontend_package = json.loads(read_repo_text("frontend/package.json")) + frontend_workspace = yaml.safe_load(read_repo_text("frontend/pnpm-workspace.yaml")) + frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml")) + + assert frontend_package["devDependencies"]["jsdom"] == "^30.0.1" + for dependency, expected_version in ( + ("brace-expansion", "5.0.9"), + ("nanoid", "5.1.16"), + ("undici", "8.9.0"), + ): + assert frontend_package["overrides"][dependency] == expected_version + assert frontend_package["resolutions"][dependency] == expected_version + assert frontend_workspace["overrides"][dependency] == expected_version + assert frontend_lock["overrides"][dependency] == expected_version + + for section in ("packages", "snapshots"): + locked_keys = [ + package + for package in frontend_lock[section] + if package.startswith(f"{dependency}@") + ] + assert locked_keys == [f"{dependency}@{expected_version}"] + + assert [ + package for package in frontend_lock["packages"] if package.startswith("jsdom@") + ] == ["jsdom@30.0.1"] + + def test_changelog_follows_keep_a_changelog_for_initial_korean_release() -> None: changelog = read_repo_text("CHANGELOG.md") @@ -273,6 +363,26 @@ def test_github_actions_are_pinned_to_exact_sha() -> None: assert missing_version_comments == [], "\n".join(missing_version_comments) +def test_github_workflows_never_push_repository_source_branches() -> None: + """Keep branch publication in reviewed clients, not ephemeral Actions jobs.""" + governed_workflows = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted( + WORKFLOW_DIR.glob("*.yaml") + ) + source_publishers: list[str] = [] + + for workflow_path in governed_workflows: + workflow = workflow_path.read_text(encoding="utf-8") + if "contents: write" in workflow and "git push" in workflow: + source_publishers.append( + workflow_path.relative_to(REPO_ROOT).as_posix() + ) + + assert source_publishers == [], ( + "GitHub workflows must not commit or push repository source branches: " + + ", ".join(source_publishers) + ) + + def test_github_workflows_do_not_define_duplicate_top_level_keys() -> None: assert WORKFLOW_DIR.exists(), ( "required governance artifact is missing: .github/workflows" diff --git a/backend/tests/test_workflow_source_publication_governance.py b/backend/tests/test_workflow_source_publication_governance.py new file mode 100644 index 000000000..2dc84faf8 --- /dev/null +++ b/backend/tests/test_workflow_source_publication_governance.py @@ -0,0 +1,195 @@ +"""Semantic regression tests that keep Actions from publishing source refs.""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows" +SOURCE_REF_ACTIONS = { + "ad-m/github-push-action", + "endbug/add-and-commit", + "peter-evans/create-pull-request", + "stefanzweifel/git-auto-commit-action", +} + + +def _effective_contents_permission( + workflow: dict[str, Any], job: dict[str, Any] +) -> str: + """Return the effective GitHub token contents permission for one job.""" + permissions = job.get("permissions", workflow.get("permissions")) + if permissions == "write-all": + return "write" + if permissions == "read-all": + return "read" + if not isinstance(permissions, dict): + return "implicit" + return str(permissions.get("contents", "none")).lower() + + +def _step_publishes_source_ref(step: dict[str, Any]) -> bool: + """Detect commands or actions that create or update repository source refs.""" + run = str(step.get("run", "")) + run_lower = run.lower() + if re.search(r"\bgit\s+push\b", run_lower): + return True + + refs_api = re.search( + r"(?:api\.github\.com|\bgh\s+api\b).*?/git/refs", + run_lower, + re.DOTALL, + ) + mutating_method = re.search( + r"(?:-x|--request|--method)\s+(?:post|patch)\b", run_lower + ) + if refs_api and mutating_method: + return True + + action = str(step.get("uses", "")).split("@", 1)[0].lower() + if action in SOURCE_REF_ACTIONS: + return True + + with_values = step.get("with") + script = ( + str(with_values.get("script", "")) + if isinstance(with_values, dict) + else "" + ) + normalized_script = re.sub(r"\s+", "", script).lower() + return any( + token in normalized_script + for token in ( + ".rest.git.createref(", + ".rest.git.updateref(", + "github.git.createref(", + "github.git.updateref(", + ) + ) + + +def _source_publishing_jobs(workflow: dict[str, Any]) -> Iterator[tuple[str, str]]: + """Yield jobs that can publish repository source refs and their permissions.""" + jobs = workflow.get("jobs") + if not isinstance(jobs, dict): + return + for job_name, raw_job in jobs.items(): + if not isinstance(raw_job, dict): + continue + permission = _effective_contents_permission(workflow, raw_job) + steps = raw_job.get("steps", []) + if not isinstance(steps, list): + continue + if any( + isinstance(step, dict) and _step_publishes_source_ref(step) + for step in steps + ): + yield str(job_name), permission + + +def _load_workflow(text: str) -> dict[str, Any]: + """Parse one synthetic workflow and assert its mapping shape.""" + workflow = yaml.safe_load(text) + assert isinstance(workflow, dict) + return workflow + + +def test_repository_workflows_do_not_publish_source_refs() -> None: + """Reject source-ref publishers while allowing package-only publication.""" + offenders: list[str] = [] + governed_workflows = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted( + WORKFLOW_DIR.glob("*.yaml") + ) + + for workflow_path in governed_workflows: + workflow = _load_workflow(workflow_path.read_text(encoding="utf-8")) + for job_name, permission in _source_publishing_jobs(workflow): + offenders.append( + f"{workflow_path.relative_to(REPO_ROOT).as_posix()}:{job_name}" + f" (contents={permission})" + ) + + assert offenders == [], ( + "GitHub workflows must not create or update repository source refs: " + + ", ".join(offenders) + ) + + +@pytest.mark.parametrize( + ("workflow_text", "expected"), + [ + ( + """permissions: write-all +jobs: + publish: + steps: + - run: curl -X POST https://api.github.com/repos/o/r/git/refs +""", + [("publish", "write")], + ), + ( + """permissions: + contents: read +jobs: + publish: + permissions: + contents: write + steps: + - uses: actions/github-script@0123456789012345678901234567890123456789 + with: + script: | + github.rest.git.createRef({ + owner: 'o', repo: 'r', ref: 'refs/heads/x', sha: 'a' + }) +""", + [("publish", "write")], + ), + ( + """permissions: + contents: write +jobs: + publish: + steps: + - run: git push origin HEAD:refs/heads/generated +""", + [("publish", "write")], + ), + ( + """permissions: + contents: write +jobs: + publish: + steps: + - run: | + curl --request PATCH \\ + https://api.github.com/repos/o/r/git/refs/heads/generated +""", + [("publish", "write")], + ), + ( + """permissions: + contents: read + packages: write +jobs: + package: + steps: + - uses: docker/build-push-action@0123456789012345678901234567890123456789 + with: + push: true +""", + [], + ), + ], +) +def test_source_publication_detector_covers_permission_and_operation_forms( + workflow_text: str, + expected: list[tuple[str, str]], +) -> None: + """Prove semantic detection across workflow/job permissions and ref writers.""" + assert list(_source_publishing_jobs(_load_workflow(workflow_text))) == expected diff --git a/docs/doctoring/nanoid-cve-2026-67214.md b/docs/doctoring/nanoid-cve-2026-67214.md new file mode 100644 index 000000000..b013ac553 --- /dev/null +++ b/docs/doctoring/nanoid-cve-2026-67214.md @@ -0,0 +1,95 @@ +# Dependency Security Doctoring: `nanoid` CVE-2026-67214 + +## Finding and evidence + +`ContextualWisdomLab/naruon#1296` was evaluated at head +`00bb2d6bd80faee9e0ff5fec6c558003207f7bf3`. The Security Scan and Dependency +Review workflows rejected `frontend/pnpm-lock.yaml` because the resolved +`nanoid` version was `5.1.6`. The relevant workflow runs were +[Security Scan run 31315927356](https://github.com/ContextualWisdomLab/naruon/actions/runs/31315927356) +and +[Dependency Review run 31315927337](https://github.com/ContextualWisdomLab/naruon/actions/runs/31315927337). + +The GitHub Advisory Database classifies `GHSA-28wg-ghj8-5hjv` / `CVE-2026-67214` +as High severity. Its reviewed package ranges are `< 3.3.16` and `>= 4.0.0, +< 5.1.16`, with first patched versions `3.3.16` and `5.1.16`, respectively. +The non-secure module can loop indefinitely when an attacker-controlled +negative size reaches `customAlphabet` or `nanoid`, causing denial of service. +The application therefore treats the advisory as a dependency-closure defect +even when the affected transitive call path is not a product-facing API. + +## Resolution contract + +The current graph selects the patched 5.x line in every authority that can +influence the frontend dependency graph: + +1. `frontend/package.json` `overrides.nanoid` and `resolutions.nanoid` are + `5.1.16`. +2. `frontend/pnpm-workspace.yaml` `overrides.nanoid` is `5.1.16`. +3. `frontend/pnpm-lock.yaml` records the `5.1.16` package resolution, + integrity, snapshot, and the `postcss` dependency edge. + +The lockfile remains frozen-installable, and the fix does not bypass or weaken +Dependency Review, OSV, Trivy, or the repository's release-age policy. + +## Regression test + +`frontend/src/lib/dependency-security.test.ts` reads the checked-in manifest, +workspace configuration, and lockfile. It keeps the current graph pinned to +`5.1.16`, parses the `packages` and `snapshots` sections independently, and +rejects exactly the reviewed affected ranges (`< 3.3.16` and `>= 4.0.0, +< 5.1.16`). Boundary cases prove that `3.3.16` and `5.1.16` are accepted while +`3.3.15`, `4.0.0`, and `5.1.15` are rejected. This protects the actual +supply-chain artifacts without incorrectly rejecting the patched 3.x line. + +## Verification + +The following evidence was obtained after the dependency change: + +- `pnpm install --frozen-lockfile --ignore-scripts` — completed successfully. +- `pnpm exec vitest run src/lib/dependency-security.test.ts src/lib/csp-headers.test.ts src/lib/backend-url.test.ts --reporter=dot` — 3 files and 20 tests passed on the predecessor test shape. +- `pnpm exec vitest run --reporter=dot` — 50 files and 428 tests passed on the predecessor test shape. +- `pnpm exec eslint src/lib/dependency-security.test.ts` — passed on the predecessor test shape. +- `pnpm run lint` — passed. +- `pnpm run typecheck` — passed. +- Historical diagnostic only: `uv run --project backend --frozen --offline pytest -q` on CPython 3.14.4 completed with 1,701 passed, 33 skipped, and ten warnings. Because warnings were present, that run is **not** accepted as backend-contract evidence. +- Required current-head backend verification is `PYTHONWARNINGS=error DISABLE_BACKGROUND_WORKERS=1 uv run --project backend --frozen --offline pytest -q`. It may be recorded as passing only after it exits successfully with no `Timeout`, `Fatal`, `Warn`, or `Denied` output; current-head CI remains authoritative until such evidence exists. +- `uv run --project backend --frozen --offline ruff check backend/tests/test_release_governance.py` — passed on the predecessor governance-test shape. +- Local Trivy filesystem scan of `frontend/pnpm-lock.yaml` at HIGH/CRITICAL severity — 0 findings. +- `git diff --check` — passed on the predecessor change set. + +The direct local OSV lockfile scan still reports `js-yaml@4.3.0`, which is +inherited from the base dependency graph and is outside this PR's introduced +`nanoid` change. The central OSV workflow compares base and head results, so +the merge decision must use its new current-head report rather than treating a +whole-lock local inventory as a PR-introduced finding. + +The GitHub workflows must be re-queried against each newly pushed HEAD before +the pull request is considered mergeable. A green result from an older head is +not accepted as evidence for a newer change. + +## Rollback criteria + +Do not roll back into either reviewed affected range (`< 3.3.16` or `>= 4.0.0, +< 5.1.16`). A future version change may replace the current `5.1.16` pin only +after the advisory database confirms the candidate is outside affected ranges, +the three resolution authorities and lockfile are regenerated together, the +regression test is updated if the advisory changes, and current-head Security +Scan and Dependency Review results are green. + +## References (APA 7) + +GitHub, Inc. (2026). *Nanoid: Non-secure generators can loop indefinitely with +negative size (CVE-2026-67214; GHSA-28wg-ghj8-5hjv).* GitHub Advisory Database. +https://github.com/advisories/GHSA-28wg-ghj8-5hjv + +GitHub, Inc. (2026). *Release 5.1.16.* GitHub. https://github.com/ai/nanoid/releases/tag/5.1.16 + +National Institute of Standards and Technology. (2026). *CVE-2026-67214 +detail.* National Vulnerability Database. https://nvd.nist.gov/vuln/detail/CVE-2026-67214 + +MITRE. (n.d.). *CWE-835: Loop with unreachable exit condition ('Infinite +Loop').* https://cwe.mitre.org/data/definitions/835.html + +pnpm. (n.d.). *ERR_PNPM_OUTDATED_LOCKFILE.* pnpm documentation. +https://pnpm.io/errors#err_pnpm_outdated-lockfile diff --git a/frontend/package.json b/frontend/package.json index 191b7c90b..d60b704a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,12 +48,14 @@ }, "overrides": { "brace-expansion": "5.0.9", + "nanoid": "5.1.16", "postcss": "8.5.24", "undici": "8.9.0", "uuid": "^14.0.0" }, "resolutions": { "brace-expansion": "5.0.9", + "nanoid": "5.1.16", "postcss": "8.5.24", "undici": "8.9.0" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 610a0e7ca..d9acf5d69 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: brace-expansion: 5.0.9 + nanoid: 5.1.16 postcss: 8.5.24 sharp: 0.35.0 undici: 8.9.0 @@ -2246,9 +2247,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} hasBin: true napi-postinstall@0.3.4: @@ -5049,7 +5050,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.18: {} + nanoid@5.1.16: {} napi-postinstall@0.3.4: {} @@ -5191,7 +5192,7 @@ snapshots: postcss@8.5.24: dependencies: - nanoid: 3.3.18 + nanoid: 5.1.16 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index d028031d2..42c0ccb4c 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -15,6 +15,7 @@ supportedArchitectures: overrides: brace-expansion: "5.0.9" + nanoid: "5.1.16" postcss: "8.5.24" sharp: "0.35.0" undici: 8.9.0 diff --git a/frontend/src/lib/dependency-security.test.ts b/frontend/src/lib/dependency-security.test.ts new file mode 100644 index 000000000..0dd37eba0 --- /dev/null +++ b/frontend/src/lib/dependency-security.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const frontendRoot = fileURLToPath(new URL('../..', import.meta.url)); +const packageManifest = JSON.parse( + readFileSync(`${frontendRoot}/package.json`, 'utf8'), +) as { + overrides?: Record; + resolutions?: Record; +}; +const lockfile = readFileSync(`${frontendRoot}/pnpm-lock.yaml`, 'utf8'); +const workspaceConfig = readFileSync(`${frontendRoot}/pnpm-workspace.yaml`, 'utf8'); + +type NanoidLockSection = 'packages' | 'snapshots'; + +const compareVersions = (left: string, right: readonly number[]) => { + const parts = left.split('.').map(Number); + return parts[0] - right[0] || parts[1] - right[1] || parts[2] - right[2]; +}; + +const isAffectedNanoidVersion = (version: string) => + compareVersions(version, [3, 3, 16]) < 0 || + (compareVersions(version, [4, 0, 0]) >= 0 && + compareVersions(version, [5, 1, 16]) < 0); + +const lockedNanoidVersions = (): Record => { + const versions: Record = { + packages: [], + snapshots: [], + }; + let section: NanoidLockSection | null = null; + + for (const line of lockfile.split('\n')) { + if (line === 'packages:') { + section = 'packages'; + continue; + } + if (line === 'snapshots:') { + section = 'snapshots'; + continue; + } + if (/^[A-Za-z][A-Za-z0-9_-]*:$/.test(line)) { + section = null; + continue; + } + if (section === null) { + continue; + } + + const match = /^ nanoid@(\d+\.\d+\.\d+):/.exec(line); + if (match) { + versions[section].push(match[1]); + } + } + + return versions; +}; + +describe('frontend dependency security contract', () => { + it.each(['3.3.15', '4.0.0', '5.1.15'])( + 'recognizes affected nanoid version %s', + (version) => { + expect(isAffectedNanoidVersion(version)).toBe(true); + }, + ); + + it.each(['3.3.16', '3.3.99', '5.1.16', '6.0.0'])( + 'recognizes patched nanoid version %s', + (version) => { + expect(isAffectedNanoidVersion(version)).toBe(false); + }, + ); + + it('keeps each locked nanoid version outside the advisory ranges', () => { + expect(packageManifest.overrides?.nanoid).toBe('5.1.16'); + expect(packageManifest.resolutions?.nanoid).toBe('5.1.16'); + expect(workspaceConfig).toContain(' nanoid: "5.1.16"\n'); + expect(lockfile).toContain(' nanoid: 5.1.16\n'); + + const versions = lockedNanoidVersions(); + for (const section of ['packages', 'snapshots'] as const) { + expect(versions[section]).toEqual(['5.1.16']); + for (const version of versions[section]) { + expect(isAffectedNanoidVersion(version)).toBe(false); + } + } + }); +});