diff --git a/.github/workflows/readme-visuals.yml b/.github/workflows/readme-visuals.yml new file mode 100644 index 000000000..f3abb8325 --- /dev/null +++ b/.github/workflows/readme-visuals.yml @@ -0,0 +1,44 @@ +name: README visual asset validation + +on: + pull_request: + paths: + - 'README.md' + - 'assets/readme/**' + - 'docs/readme-visual-system.md' + - 'scripts/*readme*' + - 'scripts/tests/test_readme_visuals_xml.py' + - '.github/workflows/readme-visuals.yml' + push: + branches: [main] + paths: + - 'README.md' + - 'assets/readme/**' + - 'docs/readme-visual-system.md' + - 'scripts/*readme*' + - 'scripts/tests/test_readme_visuals_xml.py' + - '.github/workflows/readme-visuals.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + name: Safe and reproducible README visuals + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.11' + - name: Install visual tooling dependency + run: python -m pip install -r scripts/requirements-readme-visuals.txt + - name: Test XML input boundaries + run: python -m unittest discover -s scripts/tests -p test_readme_visuals_xml.py + - name: Verify reproducible and accessible assets + run: | + python scripts/render-readme-visuals.py + python scripts/verify-readme-visuals.py + git diff --exit-code -- assets/readme + test -z "$(git ls-files --others --exclude-standard -- assets/readme)" diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md index fc74c099a..ad6fcd740 100644 --- a/docs/readme-visual-system.md +++ b/docs/readme-visual-system.md @@ -5,11 +5,23 @@ The README uses a Cognitum-inspired palette: midnight backgrounds, cyan gradient ## Rebuild and check ```bash +python3 -m pip install -r scripts/requirements-readme-visuals.txt python3 scripts/render-readme-visuals.py python3 scripts/verify-readme-visuals.py +python3 -m unittest discover -s scripts/tests -p test_readme_visuals_xml.py ``` -The generator uses only Python's standard library and makes no network calls. The committed SVGs are the deployment artifacts. The manifest in `assets/readme/manifest.json` lists every destination. +Install the tooling dependency in a virtual environment. The generator and verifier +use `defusedxml` to reject DTDs, entities and external XML references. SVG inputs +must be smaller than 20,000 bytes; the verifier reads at most that limit and +rejects oversized inputs before parsing. Both tools use UTF-8, and generated +files use LF line endings for reproducibility across platforms. They make no +network calls. This dependency is only for visual maintenance, not the runtime. +Manifest paths must be relative: SVG files stay inside `assets/readme`, and +local link targets stay inside the repository, including after symlink resolution. + +The committed SVGs are the deployment artifacts. The manifest in +`assets/readme/manifest.json` lists every destination. ## Links and rendering diff --git a/scripts/readme_visuals_xml.py b/scripts/readme_visuals_xml.py new file mode 100644 index 000000000..334d02d32 --- /dev/null +++ b/scripts/readme_visuals_xml.py @@ -0,0 +1,50 @@ +"""Bounded, hardened XML parsing for the README visual maintenance tools.""" + +from pathlib import Path, PurePosixPath, PureWindowsPath + +from defusedxml import ElementTree + + +# Keep the visual verifier's existing exclusive per-asset size limit. +MAX_SVG_BYTES = 20_000 + + +def resolve_repo_file(root: Path, relative: str, *, asset: bool = False) -> Path: + """Resolve a portable manifest path inside its trusted repository boundary.""" + if ( + not isinstance(relative, str) + or not relative + or "\\" in relative + or ":" in relative + or "\x00" in relative + or PurePosixPath(relative).is_absolute() + or PureWindowsPath(relative).drive + or ".." in relative.split("/") + ): + raise ValueError("Expected a repository-relative path without traversal") + repository = root.resolve() + boundary = repository / "assets" / "readme" if asset else repository + resolved = (repository / relative).resolve() + if not resolved.is_relative_to(boundary): + raise ValueError("Manifest path escapes its allowed repository directory") + if not resolved.is_file(): + raise ValueError("Manifest path must name an existing file") + return resolved + + +def parse_svg(raw: bytes): + """Reject oversized input and DTD/entity declarations before building a tree.""" + if not isinstance(raw, bytes): + raise TypeError("SVG input must be bytes") + if len(raw) >= MAX_SVG_BYTES: + raise ValueError(f"SVG must be smaller than {MAX_SVG_BYTES} bytes") + return ElementTree.fromstring( + raw, forbid_dtd=True, forbid_entities=True, forbid_external=True + ) + + +def read_svg(path: Path): + """Read at most the rejection boundary, including when a file grows mid-read.""" + with path.open("rb") as stream: + raw = stream.read(MAX_SVG_BYTES) + return raw, parse_svg(raw) diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py index fdc5f67d8..e37a9c58b 100644 --- a/scripts/render-readme-visuals.py +++ b/scripts/render-readme-visuals.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Generate the RuView README visual system. Deterministic, stdlib, no network.""" +"""Generate the RuView README visual system. Deterministic, no network.""" from pathlib import Path from html import escape -import math,json,xml.etree.ElementTree as ET +import math,json +from readme_visuals_xml import parse_svg ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True) C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5' def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''): @@ -46,7 +47,7 @@ def frame(w,h,title,desc,body): return f'''{escape(title)}{escape(desc)}{body}''' manifest=[] def save(name,w,h,title,desc,body,target): - s=frame(w,h,title,desc,body);ET.fromstring(s);(OUT/(name+'.svg')).write_text(s+'\n');manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) + s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) # Main title panel: a radio field passes through an illustrated room. s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18) s+=''+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2) @@ -91,5 +92,5 @@ s+=''+ s+=''+icon('seed',945,150,2.4) for j in range(3):s+=f'' save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one') -(OUT/'manifest.json').write_text(json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n') +(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8')) print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes') diff --git a/scripts/requirements-readme-visuals.txt b/scripts/requirements-readme-visuals.txt new file mode 100644 index 000000000..d17db13ae --- /dev/null +++ b/scripts/requirements-readme-visuals.txt @@ -0,0 +1,2 @@ +# Development-only dependency for README SVG generation and verification. +defusedxml==0.7.1 diff --git a/scripts/tests/test_readme_visuals_xml.py b/scripts/tests/test_readme_visuals_xml.py new file mode 100644 index 000000000..d9786fcda --- /dev/null +++ b/scripts/tests/test_readme_visuals_xml.py @@ -0,0 +1,152 @@ +"""Security regressions for the README visual tools' shared XML boundary.""" + +import io +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +from defusedxml.common import DTDForbidden + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import readme_visuals_xml as svg_xml + + +class ReadmeVisualXmlTests(unittest.TestCase): + def test_manifest_paths_reject_absolute_and_traversal_before_resolution(self): + for relative in [ + '', '/tmp/outside.svg', '//server/share/outside.svg', + 'C:/outside.svg', 'C:outside.svg', r'\\server\share\outside.svg', + r'\outside.svg', r'\\?\C:\outside.svg', 'file.svg:stream', + '../outside.svg', 'assets/readme/../../../outside.svg', + r'assets\readme\..\outside.svg', 'assets/readme/\x00.svg', + ]: + with self.subTest(relative=relative): + with patch.object(Path, 'resolve') as resolve: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(Path('repository'), relative) + resolve.assert_not_called() + + def test_manifest_paths_keep_assets_and_targets_in_separate_boundaries(self): + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory).resolve() + asset = repository / 'assets' / 'readme' / 'ordinary.svg' + asset.parent.mkdir(parents=True) + asset.write_bytes(b'') + target = repository / 'README.md' + target.write_bytes(b'# Readme\n') + self.assertEqual(svg_xml.resolve_repo_file(repository, 'assets/readme/ordinary.svg', asset=True), asset) + self.assertEqual(svg_xml.resolve_repo_file(repository, 'README.md'), target) + with patch.object(Path, 'is_file') as is_file: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(repository, 'README.md', asset=True) + is_file.assert_not_called() + + def test_manifest_paths_reject_symlink_escape_before_file_inspection(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + repository = base / 'repository' + assets = repository / 'assets' / 'readme' + assets.mkdir(parents=True) + outside = base / 'outside.svg' + outside.write_bytes(b'') + try: + (assets / 'escape.svg').symlink_to(outside) + (repository / 'escaped-directory').symlink_to(base, target_is_directory=True) + except (OSError, NotImplementedError) as error: + if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314): + self.skipTest('Windows symlink privilege unavailable') + raise + for relative, asset in [('assets/readme/escape.svg', True), ('escaped-directory/outside.svg', False)]: + with self.subTest(relative=relative): + with patch.object(Path, 'is_file') as is_file: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(repository, relative, asset=asset) + is_file.assert_not_called() + + def test_asset_directory_symlink_cannot_redefine_trusted_boundary(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + repository = base / 'repository' + (repository / 'assets').mkdir(parents=True) + outside = base / 'outside' + outside.mkdir() + (outside / 'escape.svg').write_bytes(b'') + try: + (repository / 'assets' / 'readme').symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError) as error: + if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314): + self.skipTest('Windows symlink privilege unavailable') + raise + with patch.object(Path, 'is_file') as is_file: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(repository, 'assets/readme/escape.svg', asset=True) + is_file.assert_not_called() + + def test_plain_utf8_svg_parses(self): + root = svg_xml.parse_svg('Présence'.encode('utf-8')) + self.assertEqual(root.tag, 'svg') + self.assertEqual(root.find('title').text, 'Présence') + + def test_dtd_and_entity_payloads_are_rejected(self): + for raw in [ + b'', + b']>&payload;', + b']>&payload;', + b'', + ]: + with self.subTest(raw=raw): + with self.assertRaises(DTDForbidden): + svg_xml.parse_svg(raw) + + def test_size_limit_is_enforced_before_parser_runs(self): + for size in [svg_xml.MAX_SVG_BYTES, svg_xml.MAX_SVG_BYTES + 1]: + with self.subTest(size=size): + with patch.object(svg_xml.ElementTree, 'fromstring') as parser: + with self.assertRaisesRegex(ValueError, 'smaller than 20000 bytes'): + svg_xml.parse_svg(b' ' * size) + parser.assert_not_called() + + def test_limit_counts_utf8_bytes_and_allows_last_valid_size(self): + valid = b'' + b' ' * (svg_xml.MAX_SVG_BYTES - 7) + self.assertEqual(len(valid), svg_xml.MAX_SVG_BYTES - 1) + self.assertEqual(svg_xml.parse_svg(valid).tag, 'svg') + text = '' + 'é' * 10000 + '' + self.assertLess(len(text), svg_xml.MAX_SVG_BYTES) + with self.assertRaises(ValueError): + svg_xml.parse_svg(text.encode('utf-8')) + + def test_reader_never_requests_unbounded_input(self): + class BoundedReader(io.BytesIO): + requested = None + + def read(self, size=-1): + self.requested = size + if size < 0 or size > svg_xml.MAX_SVG_BYTES: + raise AssertionError('unbounded read') + return super().read(size) + + stream = BoundedReader(b' ' * (svg_xml.MAX_SVG_BYTES * 2)) + with patch.object(Path, 'open', return_value=stream): + with patch.object(svg_xml.ElementTree, 'fromstring') as parser: + with self.assertRaises(ValueError): + svg_xml.read_svg(Path('oversized.svg')) + parser.assert_not_called() + self.assertEqual(stream.requested, svg_xml.MAX_SVG_BYTES) + + def test_file_reader_uses_same_hardened_parser(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'fixture.svg' + raw = b'ordinary' + path.write_bytes(raw) + read, root = svg_xml.read_svg(path) + self.assertEqual(read, raw) + self.assertEqual(root.find('title').text, 'ordinary') + path.write_bytes(b'') + with self.assertRaises(DTDForbidden): + svg_xml.read_svg(path) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/verify-readme-visuals.py b/scripts/verify-readme-visuals.py index 7847fc5e4..7431903f8 100644 --- a/scripts/verify-readme-visuals.py +++ b/scripts/verify-readme-visuals.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """Verify the generated SVGs and GitHub-compatible image link wrappers.""" from pathlib import Path -import json,re,xml.etree.ElementTree as ET +import json,re from urllib.parse import urlparse +from readme_visuals_xml import read_svg,resolve_repo_file ROOT=Path(__file__).resolve().parents[1];NS='{http://www.w3.org/2000/svg}' -manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text());readme=(ROOT/'README.md').read_text();total=0 +manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text(encoding='utf-8'));readme=(ROOT/'README.md').read_text(encoding='utf-8');total=0 for asset in manifest['assets']: - file=ROOT/asset['file'];raw=file.read_text();total+=file.stat().st_size;root=ET.fromstring(raw) + file=resolve_repo_file(ROOT,asset['file'],asset=True);data,root=read_svg(file);raw=data.decode('utf-8');total+=len(data) assert root.tag==NS+'svg' and root.get('viewBox'),asset['file'] assert root.find(NS+'title') is not None and root.find(NS+'desc') is not None ids=[e.get('id') for e in root.iter() if e.get('id')];assert len(ids)==len(set(ids)) @@ -19,11 +20,10 @@ for asset in manifest['assets']: target=asset['target'];parsed=urlparse(target) if parsed.scheme: assert parsed.scheme=='https' and parsed.hostname in ['cognitum.one','huggingface.co'] - else:assert (ROOT/target.split('#')[0]).is_file(),target + else:resolve_repo_file(ROOT,target.split('#')[0]) escaped=re.escape(asset['file']);destination=re.escape(target) linked=bool(re.search(r'\[!\[[^\]]*\]\('+escaped+r'\)\]\('+destination+r'\)',readme)) linked=linked or bool(re.search(r'\s*