fix(docs): harden README visual XML and path validation

This commit is contained in:
ruv committed 2026-10-10 13:41:26 -04:00
1 parent 4696804b23
commit 8a140c1906
7 files changed
+271 -10

No files matched your search

+44
View File
@@ -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)"
+13 -1
View File
@@ -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
+50
View File
@@ -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)
+5 -4
View File
@@ -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'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-labelledby="title desc"><title id="title">{escape(title)}</title><desc id="desc">{escape(desc)}</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{{font-family:Arial,Helvetica,sans-serif}}.mono{{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}}.flow{{stroke-dasharray:5 12;animation:flow 12s linear infinite}}.breathe{{animation:breathe 6s ease-in-out infinite}}.trace{{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}}.float{{animation:float 9s ease-in-out infinite}}@keyframes flow{{to{{stroke-dashoffset:-136}}}}@keyframes breathe{{50%{{opacity:.4}}}}@keyframes trace{{0%,100%{{stroke-dashoffset:0}}50%{{stroke-dashoffset:90}}}}@keyframes float{{50%{{transform:translateY(-6px)}}}}@media(prefers-reduced-motion:reduce){{*{{animation:none!important}}}}</style><rect x=".5" y=".5" width="{w-1}" height="{h-1}" rx="18" fill="url(#wash)" stroke="#245360"/>{body}</svg>'''
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+='<rect x="48" y="326" width="270" height="52" rx="12" fill="url(#accent)"/>'+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2)
@@ -91,5 +92,5 @@ s+='<rect x="44" y="224" width="256" height="46" rx="11" fill="url(#accent)"/>'+
s+='<circle cx="947" cy="151" r="173" fill="url(#halo)"/>'+icon('seed',945,150,2.4)
for j in range(3):s+=f'<ellipse cx="945" cy="150" rx="{140+j*20}" ry="{30+j*13}" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/>'
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')
+2
View File
@@ -0,0 +1,2 @@
# Development-only dependency for README SVG generation and verification.
defusedxml==0.7.1
+152
View File
@@ -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'<svg/>')
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'<svg/>')
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'<svg/>')
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('<svg><title>Présence</title></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'<!DOCTYPE svg><svg/>',
b'<!DOCTYPE svg [<!ENTITY payload "expanded">]><svg>&payload;</svg>',
b'<!DOCTYPE svg [<!ENTITY payload SYSTEM "file:///private.txt">]><svg>&payload;</svg>',
b'<!DOCTYPE svg SYSTEM "https://example.invalid/remote.dtd"><svg/>',
]:
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'<svg/>' + 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 = '<svg>' + 'é' * 10000 + '</svg>'
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'<svg><title>ordinary</title></svg>'
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'<!DOCTYPE svg><svg/>')
with self.assertRaises(DTDForbidden):
svg_xml.read_svg(path)
if __name__ == '__main__':
unittest.main()
+5 -5
View File
@@ -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'<a href="'+destination+r'">\s*<img src="'+escaped+r'"',readme))
assert linked,('Missing clickable wrapper',asset['file'])
assert file.stat().st_size<20000,'Unexpected asset growth'
assert total<100000
print(f'PASS: {len(manifest["assets"])} accessible animated SVGs, link targets and README wrappers; {total:,} bytes. No scripts, remote resources or SVG-internal links.')