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''''''
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(''.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']>',
+ b']>',
+ 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 = ''
+ 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''
+ 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*