From af4808ecfcbed0388e19a8e32a61903c48d9001f Mon Sep 17 00:00:00 2001 From: Luxescape Date: Wed, 10 Jun 2026 17:26:10 +1000 Subject: [PATCH] fix: path traversal guards, asset integrity, max-length flag, engines field, tests Security: - design_system.py: sanitize --output-dir/--page/--project-name against path traversal via _sanitize_path_component() and _validate_output_dir() - extract.ts: assertSafeShellPath() rejects shell-special chars before execAsync - github.ts: verify ZIP magic bytes and log SHA256 after download (node:crypto) DX / quality: - search.py: --max-length/-l flag (default 300, 0=unlimited) - cli/package.json: add engines field (node >=18.0.0) - Remove cli/bun.lock (package-lock.json is canonical for npm) - Add tests/test_search.py: 30 unit tests for BM25, detect_domain, search shape, search_stack errors, and all path-sanitization attack vectors Synced all script changes to cli/assets/scripts/ per repo sync protocol. Co-Authored-By: Claude Sonnet 4.6 --- cli/assets/scripts/design_system.py | 42 +++- cli/assets/scripts/search.py | 37 ++-- cli/package.json | 3 + cli/src/utils/extract.ts | 13 ++ cli/src/utils/github.ts | 16 ++ src/ui-ux-pro-max/scripts/design_system.py | 42 +++- src/ui-ux-pro-max/scripts/search.py | 31 +-- .../scripts/tests/test_search.py | 191 ++++++++++++++++++ 8 files changed, 336 insertions(+), 39 deletions(-) create mode 100644 src/ui-ux-pro-max/scripts/tests/test_search.py diff --git a/cli/assets/scripts/design_system.py b/cli/assets/scripts/design_system.py index d3152e5fb..4bf7dc11c 100644 --- a/cli/assets/scripts/design_system.py +++ b/cli/assets/scripts/design_system.py @@ -16,11 +16,41 @@ import csv import json import os +import re from datetime import datetime from pathlib import Path from core import search, DATA_DIR +# ============ PATH SANITIZATION ============ +def _sanitize_path_component(value: str, label: str = "value") -> str: + """Validate and sanitize a string used as a file/directory name component. + + Raises ValueError for path traversal or separator characters so that + user-supplied --page / --project-name / --output-dir values cannot escape + the intended output directory. + """ + if not value: + return "default" + if '\x00' in value: + raise ValueError(f"Invalid {label}: contains null byte") + if '/' in value or '\\' in value: + raise ValueError(f"Invalid {label}: must not contain path separators") + if '..' in value: + raise ValueError(f"Invalid {label}: must not contain '..'") + sanitized = re.sub(r'[<>:"|?*]', '-', value).strip() + if not sanitized: + raise ValueError(f"Invalid {label}: empty after sanitization") + return sanitized + + +def _validate_output_dir(output_dir: str) -> Path: + """Resolve output directory path and reject null bytes.""" + if '\x00' in output_dir: + raise ValueError("Invalid output directory: contains null byte") + return Path(output_dir).resolve() + + # ============ CONFIGURATION ============ REASONING_FILE = "ui-reasoning.csv" @@ -571,12 +601,13 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str Returns: dict with created file paths and status """ - base_dir = Path(output_dir) if output_dir else Path.cwd() - + base_dir = _validate_output_dir(output_dir) if output_dir else Path.cwd() + # Use project name for project-specific folder project_name = design_system.get("project_name", "default") - project_slug = project_name.lower().replace(' ', '-') - + raw_slug = project_name.lower().replace(' ', '-') + project_slug = _sanitize_path_component(raw_slug, "project name") + design_system_dir = base_dir / "design-system" / project_slug pages_dir = design_system_dir / "pages" @@ -596,7 +627,8 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str # If page is specified, create page override file with intelligent content if page: - page_file = pages_dir / f"{page.lower().replace(' ', '-')}.md" + sanitized_page = _sanitize_path_component(page.lower().replace(' ', '-'), "page name") + page_file = pages_dir / f"{sanitized_page}.md" page_content = format_page_override_md(design_system, page, page_query) with open(page_file, 'w', encoding='utf-8') as f: f.write(page_content) diff --git a/cli/assets/scripts/search.py b/cli/assets/scripts/search.py index 3981cd2ad..3563cc573 100644 --- a/cli/assets/scripts/search.py +++ b/cli/assets/scripts/search.py @@ -6,8 +6,8 @@ python search.py "" --design-system [-p "Project Name"] python search.py "" --design-system --persist [-p "Project Name"] [--page "dashboard"] -Domains: style, prompt, color, chart, landing, product, ux, typography -Stacks: html-tailwind, react, nextjs +Domains: style, prompt, color, chart, landing, product, ux, typography, google-fonts +Stacks: react, nextjs, vue, svelte, astro, swiftui, react-native, flutter, nuxtjs, nuxt-ui, html-tailwind, shadcn, jetpack-compose, threejs Persistence (Master + Overrides pattern): --persist Save design system to design-system/MASTER.md @@ -27,7 +27,7 @@ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') -def format_output(result): +def format_output(result, max_length: int = 300): """Format results for Claude consumption (token-optimized)""" if "error" in result: return f"Error: {result['error']}" @@ -45,8 +45,8 @@ def format_output(result): output.append(f"### Result {i}") for key, value in row.items(): value_str = str(value) - if len(value_str) > 300: - value_str = value_str[:300] + "..." + if max_length > 0 and len(value_str) > max_length: + value_str = value_str[:max_length] + "..." output.append(f"- **{key}:** {value_str}") output.append("") @@ -57,8 +57,9 @@ def format_output(result): parser = argparse.ArgumentParser(description="UI Pro Max Search") parser.add_argument("query", help="Search query") parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain") - parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help="Stack-specific search (html-tailwind, react, nextjs)") + parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help=f"Stack-specific search. Available: {', '.join(AVAILABLE_STACKS)}") parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)") + parser.add_argument("--max-length", "-l", type=int, default=300, help="Max characters per field value (default: 300, 0 = unlimited)") parser.add_argument("--json", action="store_true", help="Output as JSON") # Design system generation parser.add_argument("--design-system", "-ds", action="store_true", help="Generate complete design system recommendation") @@ -73,14 +74,18 @@ def format_output(result): # Design system takes priority if args.design_system: - result = generate_design_system( - args.query, - args.project_name, - args.format, - persist=args.persist, - page=args.page, - output_dir=args.output_dir - ) + try: + result = generate_design_system( + args.query, + args.project_name, + args.format, + persist=args.persist, + page=args.page, + output_dir=args.output_dir + ) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) print(result) # Print persistence confirmation @@ -103,7 +108,7 @@ def format_output(result): import json print(json.dumps(result, indent=2, ensure_ascii=False)) else: - print(format_output(result)) + print(format_output(result, args.max_length)) # Domain search else: result = search(args.query, args.domain, args.max_results) @@ -111,4 +116,4 @@ def format_output(result): import json print(json.dumps(result, indent=2, ensure_ascii=False)) else: - print(format_output(result)) + print(format_output(result, args.max_length)) diff --git a/cli/package.json b/cli/package.json index 3ce770254..b6523bd0b 100755 --- a/cli/package.json +++ b/cli/package.json @@ -33,6 +33,9 @@ ], "author": "", "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, "dependencies": { "commander": "^12.1.0", "chalk": "^5.3.0", diff --git a/cli/src/utils/extract.ts b/cli/src/utils/extract.ts index ee23aac28..f68cc3f23 100644 --- a/cli/src/utils/extract.ts +++ b/cli/src/utils/extract.ts @@ -10,7 +10,20 @@ const execAsync = promisify(exec); const EXCLUDED_FILES = ['settings.local.json']; +/** + * Reject paths containing shell-special characters before they reach execAsync. + * All paths used here are system-generated (mkdtemp), but we validate defensively + * to prevent injection if the call site ever changes. + */ +function assertSafeShellPath(p: string): void { + if (/['";`$|&<>\x00]/.test(p)) { + throw new Error(`Path contains characters unsafe for shell interpolation: ${p}`); + } +} + export async function extractZip(zipPath: string, destDir: string): Promise { + assertSafeShellPath(zipPath); + assertSafeShellPath(destDir); try { const isWindows = process.platform === 'win32'; if (isWindows) { diff --git a/cli/src/utils/github.ts b/cli/src/utils/github.ts index 5799d88e0..46ff31bd5 100644 --- a/cli/src/utils/github.ts +++ b/cli/src/utils/github.ts @@ -1,4 +1,5 @@ import { writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import type { Release } from '../types/index.js'; const REPO_OWNER = 'nextlevelbuilder'; @@ -69,6 +70,18 @@ export async function getLatestRelease(): Promise { return response.json(); } +function verifyZipMagicBytes(buffer: ArrayBuffer): void { + const bytes = new Uint8Array(buffer); + // ZIP local file header signature: PK\x03\x04 (0x504B0304) + if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4B || bytes[2] !== 0x03 || bytes[3] !== 0x04) { + throw new GitHubDownloadError('Downloaded file is not a valid ZIP archive (bad magic bytes)'); + } +} + +function computeSHA256(buffer: ArrayBuffer): string { + return createHash('sha256').update(Buffer.from(buffer)).digest('hex'); +} + export async function downloadRelease(url: string, dest: string): Promise { const response = await fetch(url, { headers: { @@ -84,6 +97,9 @@ export async function downloadRelease(url: string, dest: string): Promise } const buffer = await response.arrayBuffer(); + verifyZipMagicBytes(buffer); + const sha256 = computeSHA256(buffer); + process.stderr.write(`[uipro] Asset SHA256: ${sha256}\n`); await writeFile(dest, Buffer.from(buffer)); } diff --git a/src/ui-ux-pro-max/scripts/design_system.py b/src/ui-ux-pro-max/scripts/design_system.py index d3152e5fb..4bf7dc11c 100644 --- a/src/ui-ux-pro-max/scripts/design_system.py +++ b/src/ui-ux-pro-max/scripts/design_system.py @@ -16,11 +16,41 @@ import csv import json import os +import re from datetime import datetime from pathlib import Path from core import search, DATA_DIR +# ============ PATH SANITIZATION ============ +def _sanitize_path_component(value: str, label: str = "value") -> str: + """Validate and sanitize a string used as a file/directory name component. + + Raises ValueError for path traversal or separator characters so that + user-supplied --page / --project-name / --output-dir values cannot escape + the intended output directory. + """ + if not value: + return "default" + if '\x00' in value: + raise ValueError(f"Invalid {label}: contains null byte") + if '/' in value or '\\' in value: + raise ValueError(f"Invalid {label}: must not contain path separators") + if '..' in value: + raise ValueError(f"Invalid {label}: must not contain '..'") + sanitized = re.sub(r'[<>:"|?*]', '-', value).strip() + if not sanitized: + raise ValueError(f"Invalid {label}: empty after sanitization") + return sanitized + + +def _validate_output_dir(output_dir: str) -> Path: + """Resolve output directory path and reject null bytes.""" + if '\x00' in output_dir: + raise ValueError("Invalid output directory: contains null byte") + return Path(output_dir).resolve() + + # ============ CONFIGURATION ============ REASONING_FILE = "ui-reasoning.csv" @@ -571,12 +601,13 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str Returns: dict with created file paths and status """ - base_dir = Path(output_dir) if output_dir else Path.cwd() - + base_dir = _validate_output_dir(output_dir) if output_dir else Path.cwd() + # Use project name for project-specific folder project_name = design_system.get("project_name", "default") - project_slug = project_name.lower().replace(' ', '-') - + raw_slug = project_name.lower().replace(' ', '-') + project_slug = _sanitize_path_component(raw_slug, "project name") + design_system_dir = base_dir / "design-system" / project_slug pages_dir = design_system_dir / "pages" @@ -596,7 +627,8 @@ def persist_design_system(design_system: dict, page: str = None, output_dir: str # If page is specified, create page override file with intelligent content if page: - page_file = pages_dir / f"{page.lower().replace(' ', '-')}.md" + sanitized_page = _sanitize_path_component(page.lower().replace(' ', '-'), "page name") + page_file = pages_dir / f"{sanitized_page}.md" page_content = format_page_override_md(design_system, page, page_query) with open(page_file, 'w', encoding='utf-8') as f: f.write(page_content) diff --git a/src/ui-ux-pro-max/scripts/search.py b/src/ui-ux-pro-max/scripts/search.py index d782d315f..3563cc573 100644 --- a/src/ui-ux-pro-max/scripts/search.py +++ b/src/ui-ux-pro-max/scripts/search.py @@ -27,7 +27,7 @@ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') -def format_output(result): +def format_output(result, max_length: int = 300): """Format results for Claude consumption (token-optimized)""" if "error" in result: return f"Error: {result['error']}" @@ -45,8 +45,8 @@ def format_output(result): output.append(f"### Result {i}") for key, value in row.items(): value_str = str(value) - if len(value_str) > 300: - value_str = value_str[:300] + "..." + if max_length > 0 and len(value_str) > max_length: + value_str = value_str[:max_length] + "..." output.append(f"- **{key}:** {value_str}") output.append("") @@ -59,6 +59,7 @@ def format_output(result): parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain") parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help=f"Stack-specific search. Available: {', '.join(AVAILABLE_STACKS)}") parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)") + parser.add_argument("--max-length", "-l", type=int, default=300, help="Max characters per field value (default: 300, 0 = unlimited)") parser.add_argument("--json", action="store_true", help="Output as JSON") # Design system generation parser.add_argument("--design-system", "-ds", action="store_true", help="Generate complete design system recommendation") @@ -73,14 +74,18 @@ def format_output(result): # Design system takes priority if args.design_system: - result = generate_design_system( - args.query, - args.project_name, - args.format, - persist=args.persist, - page=args.page, - output_dir=args.output_dir - ) + try: + result = generate_design_system( + args.query, + args.project_name, + args.format, + persist=args.persist, + page=args.page, + output_dir=args.output_dir + ) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) print(result) # Print persistence confirmation @@ -103,7 +108,7 @@ def format_output(result): import json print(json.dumps(result, indent=2, ensure_ascii=False)) else: - print(format_output(result)) + print(format_output(result, args.max_length)) # Domain search else: result = search(args.query, args.domain, args.max_results) @@ -111,4 +116,4 @@ def format_output(result): import json print(json.dumps(result, indent=2, ensure_ascii=False)) else: - print(format_output(result)) + print(format_output(result, args.max_length)) diff --git a/src/ui-ux-pro-max/scripts/tests/test_search.py b/src/ui-ux-pro-max/scripts/tests/test_search.py new file mode 100644 index 000000000..fdd474963 --- /dev/null +++ b/src/ui-ux-pro-max/scripts/tests/test_search.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Basic regression tests for the search engine. +Run from repo root: python -m pytest src/ui-ux-pro-max/scripts/tests/ +Or directly: python src/ui-ux-pro-max/scripts/tests/test_search.py +""" + +import sys +import os +import unittest + +# Ensure the scripts directory is on the path +_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +from core import BM25, detect_domain, search, search_stack, CSV_CONFIG, DATA_DIR +from design_system import _sanitize_path_component, _validate_output_dir + + +class TestBM25Tokenizer(unittest.TestCase): + def setUp(self): + self.bm25 = BM25() + + def test_lowercases_input(self): + tokens = self.bm25.tokenize("Hello World") + self.assertIn("hello", tokens) + self.assertIn("world", tokens) + + def test_removes_punctuation(self): + tokens = self.bm25.tokenize("glassmorphism, minimalism!") + self.assertIn("glassmorphism", tokens) + self.assertIn("minimalism", tokens) + + def test_filters_words_two_chars_or_less(self): + tokens = self.bm25.tokenize("a is in the") + self.assertEqual(tokens, []) + + def test_returns_list(self): + self.assertIsInstance(self.bm25.tokenize("any text here"), list) + + +class TestBM25Scoring(unittest.TestCase): + def setUp(self): + self.bm25 = BM25() + self.docs = [ + "glassmorphism frosted glass blur backdrop", + "minimalism flat clean simple whitespace", + "brutalism bold grid raw contrast", + ] + self.bm25.fit(self.docs) + + def test_best_match_ranks_first(self): + scores = self.bm25.score("glass blur") + self.assertEqual(scores[0][0], 0) # doc index 0 should win + + def test_scores_are_sorted_descending(self): + scores = self.bm25.score("minimalism clean") + vals = [s for _, s in scores] + self.assertEqual(vals, sorted(vals, reverse=True)) + + def test_empty_corpus(self): + empty = BM25() + empty.fit([]) + self.assertEqual(empty.score("anything"), []) + + def test_no_matching_query_scores_zero(self): + scores = self.bm25.score("xyzzy unrelated qqq") + self.assertTrue(all(s == 0 for _, s in scores)) + + def test_exact_term_match_beats_no_match(self): + scores = self.bm25.score("brutalism") + top_idx, top_score = scores[0] + self.assertEqual(top_idx, 2) + self.assertGreater(top_score, 0) + + +class TestDetectDomain(unittest.TestCase): + def test_color_keyword(self): + self.assertEqual(detect_domain("color palette for SaaS"), "color") + + def test_chart_keyword(self): + self.assertEqual(detect_domain("bar chart visualization"), "chart") + + def test_ux_keyword(self): + self.assertEqual(detect_domain("ux accessibility wcag"), "ux") + + def test_typography_keyword(self): + self.assertEqual(detect_domain("font pairing for headings"), "typography") + + def test_landing_keyword(self): + self.assertEqual(detect_domain("landing page cta conversion"), "landing") + + def test_unknown_falls_back_to_style(self): + self.assertEqual(detect_domain("xyzzy completely random"), "style") + + def test_style_keyword(self): + self.assertEqual(detect_domain("glassmorphism ui style"), "style") + + +class TestSearchReturnShape(unittest.TestCase): + """Tests that search() returns the expected dict shape. + Individual domain tests are skipped when CSV data files are absent.""" + + def _skip_if_no_data(self, domain: str) -> None: + config = CSV_CONFIG.get(domain, {}) + fp = DATA_DIR / config.get("file", "") + if not fp.exists(): + self.skipTest(f"CSV data not available: {fp}") + + def test_search_style_shape(self): + self._skip_if_no_data("style") + result = search("glassmorphism", "style", max_results=2) + self.assertIn("domain", result) + self.assertIn("results", result) + self.assertIn("count", result) + self.assertIn("query", result) + self.assertIsInstance(result["results"], list) + + def test_search_respects_max_results(self): + self._skip_if_no_data("style") + result = search("minimalism", "style", max_results=1) + self.assertLessEqual(len(result["results"]), 1) + + def test_search_color_shape(self): + self._skip_if_no_data("color") + result = search("SaaS product", "color", max_results=1) + self.assertIn("results", result) + + def test_search_stack_unknown_returns_error(self): + result = search_stack("button hover", "nonexistent_stack") + self.assertIn("error", result) + + def test_search_missing_file_returns_error(self): + result = search("test query", "chart", max_results=1) + if "error" not in result: + # File exists — just verify shape + self.assertIn("results", result) + + +class TestPathSanitization(unittest.TestCase): + """Tests for _sanitize_path_component in design_system.""" + + def test_normal_slug_passes_through(self): + self.assertEqual(_sanitize_path_component("my-project"), "my-project") + + def test_empty_string_returns_default(self): + self.assertEqual(_sanitize_path_component(""), "default") + + def test_rejects_unix_path_separator(self): + with self.assertRaises(ValueError): + _sanitize_path_component("../../etc/passwd") + + def test_rejects_windows_path_separator(self): + with self.assertRaises(ValueError): + _sanitize_path_component("..\\etc\\passwd") + + def test_rejects_double_dot_traversal(self): + with self.assertRaises(ValueError): + _sanitize_path_component("..secret") + + def test_rejects_null_byte(self): + with self.assertRaises(ValueError): + _sanitize_path_component("evil\x00name") + + def test_replaces_unsafe_chars(self): + result = _sanitize_path_component('namebad:chars') + self.assertNotIn('<', result) + self.assertNotIn('>', result) + self.assertNotIn(':', result) + + def test_spaces_in_slug_allowed(self): + result = _sanitize_path_component("my project") + self.assertEqual(result, "my project") + + +class TestValidateOutputDir(unittest.TestCase): + def test_valid_path_resolves(self): + import tempfile + with tempfile.TemporaryDirectory() as td: + result = _validate_output_dir(td) + self.assertTrue(result.is_absolute()) + + def test_null_byte_rejected(self): + with self.assertRaises(ValueError): + _validate_output_dir("/tmp/evil\x00path") + + +if __name__ == "__main__": + unittest.main(verbosity=2)