📦 deps(thirdparty): update snapshots
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "antigravity-bundle-aas-documents-presentations",
|
||||
"version": "13.1.0",
|
||||
"version": "13.1.1",
|
||||
"description": "Editorial \"AAS Documents & Presentations\" bundle for Claude Code from Antigravity Awesome Skills.",
|
||||
"author": {
|
||||
"name": "sickn33 and contributors",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agyb-aas-documents-presentations",
|
||||
"version": "13.1.0",
|
||||
"version": "13.1.1",
|
||||
"description": "Install the \"AAS Documents & Presentations\" workflow plugin from Antigravity Awesome Skills.",
|
||||
"author": {
|
||||
"name": "sickn33 and contributors",
|
||||
|
||||
+12
@@ -16,6 +16,17 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_input_tree(input_dir: Path):
|
||||
root = input_dir.resolve(strict=True)
|
||||
for path in input_dir.rglob("*"):
|
||||
if path.is_symlink():
|
||||
raise ValueError(f"Refusing to pack symlink: {path}")
|
||||
try:
|
||||
path.resolve(strict=True).relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Refusing to pack path outside input directory: {path}") from None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Pack a directory into an Office file")
|
||||
parser.add_argument("input_directory", help="Unpacked Office document directory")
|
||||
@@ -60,6 +71,7 @@ def pack_document(input_dir, output_file, validate=False):
|
||||
raise ValueError(f"{input_dir} is not a directory")
|
||||
if output_file.suffix.lower() not in {".docx", ".pptx", ".xlsx"}:
|
||||
raise ValueError(f"{output_file} must be a .docx, .pptx, or .xlsx file")
|
||||
validate_input_tree(input_dir)
|
||||
|
||||
# Work in temporary directory to avoid modifying original
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
|
||||
+28
-7
@@ -8,6 +8,11 @@ import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
MAX_ARCHIVE_MEMBERS = 5000
|
||||
MAX_MEMBER_SIZE = 100 * 1024 * 1024
|
||||
MAX_TOTAL_UNCOMPRESSED = 512 * 1024 * 1024
|
||||
MAX_COMPRESSION_RATIO = 1000
|
||||
|
||||
|
||||
def _is_zip_symlink(member: zipfile.ZipInfo) -> bool:
|
||||
return stat.S_ISLNK(member.external_attr >> 16)
|
||||
@@ -29,19 +34,35 @@ def _extract_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo, output_ro
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
|
||||
def _validate_archive_members(archive: zipfile.ZipFile, output_root: Path):
|
||||
members = archive.infolist()
|
||||
if len(members) > MAX_ARCHIVE_MEMBERS:
|
||||
raise ValueError("Archive contains too many entries")
|
||||
|
||||
total_size = 0
|
||||
for member in members:
|
||||
if _is_zip_symlink(member):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if not _is_safe_destination(output_root, member.filename):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if member.file_size > MAX_MEMBER_SIZE:
|
||||
raise ValueError(f"Archive entry too large: {member.filename}")
|
||||
total_size += member.file_size
|
||||
if total_size > MAX_TOTAL_UNCOMPRESSED:
|
||||
raise ValueError("Archive uncompressed size is too large")
|
||||
if member.compress_size and member.file_size / member.compress_size > MAX_COMPRESSION_RATIO:
|
||||
raise ValueError(f"Archive entry compression ratio too high: {member.filename}")
|
||||
|
||||
return members
|
||||
|
||||
|
||||
def extract_archive_safely(input_file: str | Path, output_dir: str | Path):
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
output_root = output_path.resolve()
|
||||
|
||||
with zipfile.ZipFile(input_file) as archive:
|
||||
for member in archive.infolist():
|
||||
if _is_zip_symlink(member):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if not _is_safe_destination(output_root, member.filename):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
|
||||
for member in archive.infolist():
|
||||
for member in _validate_archive_members(archive, output_root):
|
||||
_extract_member(archive, member, output_path)
|
||||
|
||||
|
||||
|
||||
+37
-14
@@ -3,11 +3,37 @@ Base validator with common validation logic for document files.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import lxml.etree
|
||||
|
||||
|
||||
def hardened_xml_parser():
|
||||
return lxml.etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False)
|
||||
|
||||
|
||||
def parse_xml(source, **kwargs):
|
||||
return lxml.etree.parse(source, parser=hardened_xml_parser(), **kwargs)
|
||||
|
||||
|
||||
def safe_extract_all(zip_ref, destination):
|
||||
"""Extract a zip archive without allowing members to escape destination."""
|
||||
destination = Path(destination).resolve()
|
||||
for member in zip_ref.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zip_ref.open(member) as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
class BaseSchemaValidator:
|
||||
"""Base validator with common validation logic for document files."""
|
||||
|
||||
@@ -131,7 +157,7 @@ class BaseSchemaValidator:
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
# Try to parse the XML file
|
||||
lxml.etree.parse(str(xml_file))
|
||||
parse_xml(str(xml_file))
|
||||
except lxml.etree.XMLSyntaxError as e:
|
||||
errors.append(
|
||||
f" {xml_file.relative_to(self.unpacked_dir)}: "
|
||||
@@ -159,7 +185,7 @@ class BaseSchemaValidator:
|
||||
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
declared = set(root.nsmap.keys()) - {None} # Exclude default namespace
|
||||
|
||||
for attr_val in [
|
||||
@@ -190,7 +216,7 @@ class BaseSchemaValidator:
|
||||
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
file_ids = {} # Track IDs that must be unique within this file
|
||||
|
||||
# Remove all mc:AlternateContent elements from the tree
|
||||
@@ -310,7 +336,7 @@ class BaseSchemaValidator:
|
||||
for rels_file in rels_files:
|
||||
try:
|
||||
# Parse relationships file
|
||||
rels_root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
rels_root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Get the directory where this .rels file is located
|
||||
rels_dir = rels_file.parent
|
||||
@@ -411,7 +437,7 @@ class BaseSchemaValidator:
|
||||
|
||||
try:
|
||||
# Parse the .rels file to get valid relationship IDs and their types
|
||||
rels_root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
rels_root = parse_xml(str(rels_file)).getroot()
|
||||
rid_to_type = {}
|
||||
|
||||
for rel in rels_root.findall(
|
||||
@@ -434,7 +460,7 @@ class BaseSchemaValidator:
|
||||
rid_to_type[rid] = type_name
|
||||
|
||||
# Parse the XML file to find all r:id references
|
||||
xml_root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
xml_root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Find all elements with r:id attributes
|
||||
for elem in xml_root.iter():
|
||||
@@ -531,7 +557,7 @@ class BaseSchemaValidator:
|
||||
|
||||
try:
|
||||
# Parse and get all declared parts and extensions
|
||||
root = lxml.etree.parse(str(content_types_file)).getroot()
|
||||
root = parse_xml(str(content_types_file)).getroot()
|
||||
declared_parts = set()
|
||||
declared_extensions = set()
|
||||
|
||||
@@ -593,7 +619,7 @@ class BaseSchemaValidator:
|
||||
continue
|
||||
|
||||
try:
|
||||
root_tag = lxml.etree.parse(str(xml_file)).getroot().tag
|
||||
root_tag = parse_xml(str(xml_file)).getroot().tag
|
||||
root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag
|
||||
|
||||
if root_name in declarable_roots and path_str not in declared_parts:
|
||||
@@ -832,15 +858,12 @@ class BaseSchemaValidator:
|
||||
try:
|
||||
# Load schema
|
||||
with open(schema_path, "rb") as xsd_file:
|
||||
parser = lxml.etree.XMLParser()
|
||||
xsd_doc = lxml.etree.parse(
|
||||
xsd_file, parser=parser, base_url=str(schema_path)
|
||||
)
|
||||
xsd_doc = parse_xml(xsd_file, base_url=str(schema_path))
|
||||
schema = lxml.etree.XMLSchema(xsd_doc)
|
||||
|
||||
# Load and preprocess XML
|
||||
with open(xml_file, "r") as f:
|
||||
xml_doc = lxml.etree.parse(f)
|
||||
xml_doc = parse_xml(f)
|
||||
|
||||
xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
|
||||
xml_doc = self._preprocess_for_mc_ignorable(xml_doc)
|
||||
@@ -888,7 +911,7 @@ class BaseSchemaValidator:
|
||||
|
||||
# Extract original file
|
||||
with zipfile.ZipFile(self.original_file, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_path)
|
||||
safe_extract_all(zip_ref, temp_path)
|
||||
|
||||
# Find corresponding file in original
|
||||
original_xml_file = temp_path / relative_path
|
||||
|
||||
+26
-7
@@ -3,12 +3,31 @@ Validator for Word document XML files against XSD schemas.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import lxml.etree
|
||||
|
||||
from .base import BaseSchemaValidator
|
||||
from .base import BaseSchemaValidator, parse_xml
|
||||
|
||||
|
||||
def safe_extract_all(zip_ref, destination):
|
||||
"""Extract a zip archive without allowing members to escape destination."""
|
||||
destination = Path(destination).resolve()
|
||||
for member in zip_ref.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zip_ref.open(member) as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
@@ -81,7 +100,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Find all w:t elements
|
||||
for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"):
|
||||
@@ -134,7 +153,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Find all w:t elements that are descendants of w:del elements
|
||||
namespaces = {"w": self.WORD_2006_NAMESPACE}
|
||||
@@ -180,7 +199,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
# Count all w:p elements
|
||||
paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
|
||||
count = len(paragraphs)
|
||||
@@ -198,11 +217,11 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Unpack original docx
|
||||
with zipfile.ZipFile(self.original_file, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_dir)
|
||||
safe_extract_all(zip_ref, temp_dir)
|
||||
|
||||
# Parse document.xml
|
||||
doc_xml_path = temp_dir + "/word/document.xml"
|
||||
root = lxml.etree.parse(doc_xml_path).getroot()
|
||||
root = parse_xml(doc_xml_path).getroot()
|
||||
|
||||
# Count all w:p elements
|
||||
paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
|
||||
@@ -225,7 +244,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
namespaces = {"w": self.WORD_2006_NAMESPACE}
|
||||
|
||||
# Find w:delText in w:ins that are NOT within w:del
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ Validator for PowerPoint presentation XML files against XSD schemas.
|
||||
|
||||
import re
|
||||
|
||||
from .base import BaseSchemaValidator
|
||||
from .base import BaseSchemaValidator, parse_xml
|
||||
|
||||
|
||||
class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
@@ -86,7 +86,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Check all elements for ID attributes
|
||||
for elem in root.iter():
|
||||
@@ -142,7 +142,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
for slide_master in slide_masters:
|
||||
try:
|
||||
# Parse the slide master file
|
||||
root = lxml.etree.parse(str(slide_master)).getroot()
|
||||
root = parse_xml(str(slide_master)).getroot()
|
||||
|
||||
# Find the corresponding _rels file for this slide master
|
||||
rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels"
|
||||
@@ -155,7 +155,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
# Parse the relationships file
|
||||
rels_root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
rels_root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Build a set of valid relationship IDs that point to slide layouts
|
||||
valid_layout_rids = set()
|
||||
@@ -209,7 +209,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
|
||||
for rels_file in slide_rels_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Find all slideLayout relationships
|
||||
layout_rels = [
|
||||
@@ -258,7 +258,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
for rels_file in slide_rels_files:
|
||||
try:
|
||||
# Parse the relationships file
|
||||
root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Find all notesSlide relationships
|
||||
for rel in root.findall(
|
||||
|
||||
+21
-5
@@ -2,11 +2,31 @@
|
||||
Validator for tracked changes in Word documents.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
|
||||
def safe_extract_all(zip_ref, destination):
|
||||
"""Extract a zip archive without allowing members to escape destination."""
|
||||
destination = Path(destination).resolve()
|
||||
for member in zip_ref.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zip_ref.open(member) as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
class RedliningValidator:
|
||||
"""Validator for tracked changes in Word documents."""
|
||||
@@ -29,8 +49,6 @@ class RedliningValidator:
|
||||
|
||||
# First, check if there are any tracked changes by Claude to validate
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
tree = ET.parse(modified_file)
|
||||
root = tree.getroot()
|
||||
|
||||
@@ -67,7 +85,7 @@ class RedliningValidator:
|
||||
# Unpack original docx
|
||||
try:
|
||||
with zipfile.ZipFile(self.original_docx, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_path)
|
||||
safe_extract_all(zip_ref, temp_path)
|
||||
except Exception as e:
|
||||
print(f"FAILED - Error unpacking original docx: {e}")
|
||||
return False
|
||||
@@ -81,8 +99,6 @@ class RedliningValidator:
|
||||
|
||||
# Parse both XML files using xml.etree.ElementTree for redlining validation
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
modified_tree = ET.parse(modified_file)
|
||||
modified_root = modified_tree.getroot()
|
||||
original_tree = ET.parse(original_file)
|
||||
|
||||
+12
@@ -16,6 +16,17 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_input_tree(input_dir: Path):
|
||||
root = input_dir.resolve(strict=True)
|
||||
for path in input_dir.rglob("*"):
|
||||
if path.is_symlink():
|
||||
raise ValueError(f"Refusing to pack symlink: {path}")
|
||||
try:
|
||||
path.resolve(strict=True).relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Refusing to pack path outside input directory: {path}") from None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Pack a directory into an Office file")
|
||||
parser.add_argument("input_directory", help="Unpacked Office document directory")
|
||||
@@ -60,6 +71,7 @@ def pack_document(input_dir, output_file, validate=False):
|
||||
raise ValueError(f"{input_dir} is not a directory")
|
||||
if output_file.suffix.lower() not in {".docx", ".pptx", ".xlsx"}:
|
||||
raise ValueError(f"{output_file} must be a .docx, .pptx, or .xlsx file")
|
||||
validate_input_tree(input_dir)
|
||||
|
||||
# Work in temporary directory to avoid modifying original
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
|
||||
+28
-7
@@ -8,6 +8,11 @@ import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
MAX_ARCHIVE_MEMBERS = 5000
|
||||
MAX_MEMBER_SIZE = 100 * 1024 * 1024
|
||||
MAX_TOTAL_UNCOMPRESSED = 512 * 1024 * 1024
|
||||
MAX_COMPRESSION_RATIO = 1000
|
||||
|
||||
|
||||
def _is_zip_symlink(member: zipfile.ZipInfo) -> bool:
|
||||
return stat.S_ISLNK(member.external_attr >> 16)
|
||||
@@ -29,19 +34,35 @@ def _extract_member(archive: zipfile.ZipFile, member: zipfile.ZipInfo, output_ro
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
|
||||
def _validate_archive_members(archive: zipfile.ZipFile, output_root: Path):
|
||||
members = archive.infolist()
|
||||
if len(members) > MAX_ARCHIVE_MEMBERS:
|
||||
raise ValueError("Archive contains too many entries")
|
||||
|
||||
total_size = 0
|
||||
for member in members:
|
||||
if _is_zip_symlink(member):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if not _is_safe_destination(output_root, member.filename):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if member.file_size > MAX_MEMBER_SIZE:
|
||||
raise ValueError(f"Archive entry too large: {member.filename}")
|
||||
total_size += member.file_size
|
||||
if total_size > MAX_TOTAL_UNCOMPRESSED:
|
||||
raise ValueError("Archive uncompressed size is too large")
|
||||
if member.compress_size and member.file_size / member.compress_size > MAX_COMPRESSION_RATIO:
|
||||
raise ValueError(f"Archive entry compression ratio too high: {member.filename}")
|
||||
|
||||
return members
|
||||
|
||||
|
||||
def extract_archive_safely(input_file: str | Path, output_dir: str | Path):
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
output_root = output_path.resolve()
|
||||
|
||||
with zipfile.ZipFile(input_file) as archive:
|
||||
for member in archive.infolist():
|
||||
if _is_zip_symlink(member):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
if not _is_safe_destination(output_root, member.filename):
|
||||
raise ValueError(f"Unsafe archive entry: {member.filename}")
|
||||
|
||||
for member in archive.infolist():
|
||||
for member in _validate_archive_members(archive, output_root):
|
||||
_extract_member(archive, member, output_path)
|
||||
|
||||
|
||||
|
||||
+37
-14
@@ -3,11 +3,37 @@ Base validator with common validation logic for document files.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import lxml.etree
|
||||
|
||||
|
||||
def hardened_xml_parser():
|
||||
return lxml.etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False)
|
||||
|
||||
|
||||
def parse_xml(source, **kwargs):
|
||||
return lxml.etree.parse(source, parser=hardened_xml_parser(), **kwargs)
|
||||
|
||||
|
||||
def safe_extract_all(zip_ref, destination):
|
||||
"""Extract a zip archive without allowing members to escape destination."""
|
||||
destination = Path(destination).resolve()
|
||||
for member in zip_ref.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zip_ref.open(member) as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
class BaseSchemaValidator:
|
||||
"""Base validator with common validation logic for document files."""
|
||||
|
||||
@@ -131,7 +157,7 @@ class BaseSchemaValidator:
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
# Try to parse the XML file
|
||||
lxml.etree.parse(str(xml_file))
|
||||
parse_xml(str(xml_file))
|
||||
except lxml.etree.XMLSyntaxError as e:
|
||||
errors.append(
|
||||
f" {xml_file.relative_to(self.unpacked_dir)}: "
|
||||
@@ -159,7 +185,7 @@ class BaseSchemaValidator:
|
||||
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
declared = set(root.nsmap.keys()) - {None} # Exclude default namespace
|
||||
|
||||
for attr_val in [
|
||||
@@ -190,7 +216,7 @@ class BaseSchemaValidator:
|
||||
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
file_ids = {} # Track IDs that must be unique within this file
|
||||
|
||||
# Remove all mc:AlternateContent elements from the tree
|
||||
@@ -310,7 +336,7 @@ class BaseSchemaValidator:
|
||||
for rels_file in rels_files:
|
||||
try:
|
||||
# Parse relationships file
|
||||
rels_root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
rels_root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Get the directory where this .rels file is located
|
||||
rels_dir = rels_file.parent
|
||||
@@ -411,7 +437,7 @@ class BaseSchemaValidator:
|
||||
|
||||
try:
|
||||
# Parse the .rels file to get valid relationship IDs and their types
|
||||
rels_root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
rels_root = parse_xml(str(rels_file)).getroot()
|
||||
rid_to_type = {}
|
||||
|
||||
for rel in rels_root.findall(
|
||||
@@ -434,7 +460,7 @@ class BaseSchemaValidator:
|
||||
rid_to_type[rid] = type_name
|
||||
|
||||
# Parse the XML file to find all r:id references
|
||||
xml_root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
xml_root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Find all elements with r:id attributes
|
||||
for elem in xml_root.iter():
|
||||
@@ -531,7 +557,7 @@ class BaseSchemaValidator:
|
||||
|
||||
try:
|
||||
# Parse and get all declared parts and extensions
|
||||
root = lxml.etree.parse(str(content_types_file)).getroot()
|
||||
root = parse_xml(str(content_types_file)).getroot()
|
||||
declared_parts = set()
|
||||
declared_extensions = set()
|
||||
|
||||
@@ -593,7 +619,7 @@ class BaseSchemaValidator:
|
||||
continue
|
||||
|
||||
try:
|
||||
root_tag = lxml.etree.parse(str(xml_file)).getroot().tag
|
||||
root_tag = parse_xml(str(xml_file)).getroot().tag
|
||||
root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag
|
||||
|
||||
if root_name in declarable_roots and path_str not in declared_parts:
|
||||
@@ -832,15 +858,12 @@ class BaseSchemaValidator:
|
||||
try:
|
||||
# Load schema
|
||||
with open(schema_path, "rb") as xsd_file:
|
||||
parser = lxml.etree.XMLParser()
|
||||
xsd_doc = lxml.etree.parse(
|
||||
xsd_file, parser=parser, base_url=str(schema_path)
|
||||
)
|
||||
xsd_doc = parse_xml(xsd_file, base_url=str(schema_path))
|
||||
schema = lxml.etree.XMLSchema(xsd_doc)
|
||||
|
||||
# Load and preprocess XML
|
||||
with open(xml_file, "r") as f:
|
||||
xml_doc = lxml.etree.parse(f)
|
||||
xml_doc = parse_xml(f)
|
||||
|
||||
xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
|
||||
xml_doc = self._preprocess_for_mc_ignorable(xml_doc)
|
||||
@@ -888,7 +911,7 @@ class BaseSchemaValidator:
|
||||
|
||||
# Extract original file
|
||||
with zipfile.ZipFile(self.original_file, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_path)
|
||||
safe_extract_all(zip_ref, temp_path)
|
||||
|
||||
# Find corresponding file in original
|
||||
original_xml_file = temp_path / relative_path
|
||||
|
||||
+26
-7
@@ -3,12 +3,31 @@ Validator for Word document XML files against XSD schemas.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import lxml.etree
|
||||
|
||||
from .base import BaseSchemaValidator
|
||||
from .base import BaseSchemaValidator, parse_xml
|
||||
|
||||
|
||||
def safe_extract_all(zip_ref, destination):
|
||||
"""Extract a zip archive without allowing members to escape destination."""
|
||||
destination = Path(destination).resolve()
|
||||
for member in zip_ref.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zip_ref.open(member) as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
@@ -81,7 +100,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Find all w:t elements
|
||||
for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"):
|
||||
@@ -134,7 +153,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Find all w:t elements that are descendants of w:del elements
|
||||
namespaces = {"w": self.WORD_2006_NAMESPACE}
|
||||
@@ -180,7 +199,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
# Count all w:p elements
|
||||
paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
|
||||
count = len(paragraphs)
|
||||
@@ -198,11 +217,11 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Unpack original docx
|
||||
with zipfile.ZipFile(self.original_file, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_dir)
|
||||
safe_extract_all(zip_ref, temp_dir)
|
||||
|
||||
# Parse document.xml
|
||||
doc_xml_path = temp_dir + "/word/document.xml"
|
||||
root = lxml.etree.parse(doc_xml_path).getroot()
|
||||
root = parse_xml(doc_xml_path).getroot()
|
||||
|
||||
# Count all w:p elements
|
||||
paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
|
||||
@@ -225,7 +244,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
namespaces = {"w": self.WORD_2006_NAMESPACE}
|
||||
|
||||
# Find w:delText in w:ins that are NOT within w:del
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ Validator for PowerPoint presentation XML files against XSD schemas.
|
||||
|
||||
import re
|
||||
|
||||
from .base import BaseSchemaValidator
|
||||
from .base import BaseSchemaValidator, parse_xml
|
||||
|
||||
|
||||
class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
@@ -86,7 +86,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
|
||||
for xml_file in self.xml_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(xml_file)).getroot()
|
||||
root = parse_xml(str(xml_file)).getroot()
|
||||
|
||||
# Check all elements for ID attributes
|
||||
for elem in root.iter():
|
||||
@@ -142,7 +142,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
for slide_master in slide_masters:
|
||||
try:
|
||||
# Parse the slide master file
|
||||
root = lxml.etree.parse(str(slide_master)).getroot()
|
||||
root = parse_xml(str(slide_master)).getroot()
|
||||
|
||||
# Find the corresponding _rels file for this slide master
|
||||
rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels"
|
||||
@@ -155,7 +155,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
continue
|
||||
|
||||
# Parse the relationships file
|
||||
rels_root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
rels_root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Build a set of valid relationship IDs that point to slide layouts
|
||||
valid_layout_rids = set()
|
||||
@@ -209,7 +209,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
|
||||
for rels_file in slide_rels_files:
|
||||
try:
|
||||
root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Find all slideLayout relationships
|
||||
layout_rels = [
|
||||
@@ -258,7 +258,7 @@ class PPTXSchemaValidator(BaseSchemaValidator):
|
||||
for rels_file in slide_rels_files:
|
||||
try:
|
||||
# Parse the relationships file
|
||||
root = lxml.etree.parse(str(rels_file)).getroot()
|
||||
root = parse_xml(str(rels_file)).getroot()
|
||||
|
||||
# Find all notesSlide relationships
|
||||
for rel in root.findall(
|
||||
|
||||
+21
-5
@@ -2,11 +2,31 @@
|
||||
Validator for tracked changes in Word documents.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from defusedxml import ElementTree as ET
|
||||
|
||||
|
||||
def safe_extract_all(zip_ref, destination):
|
||||
"""Extract a zip archive without allowing members to escape destination."""
|
||||
destination = Path(destination).resolve()
|
||||
for member in zip_ref.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zip_ref.open(member) as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
class RedliningValidator:
|
||||
"""Validator for tracked changes in Word documents."""
|
||||
@@ -29,8 +49,6 @@ class RedliningValidator:
|
||||
|
||||
# First, check if there are any tracked changes by Claude to validate
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
tree = ET.parse(modified_file)
|
||||
root = tree.getroot()
|
||||
|
||||
@@ -67,7 +85,7 @@ class RedliningValidator:
|
||||
# Unpack original docx
|
||||
try:
|
||||
with zipfile.ZipFile(self.original_docx, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_path)
|
||||
safe_extract_all(zip_ref, temp_path)
|
||||
except Exception as e:
|
||||
print(f"FAILED - Error unpacking original docx: {e}")
|
||||
return False
|
||||
@@ -81,8 +99,6 @@ class RedliningValidator:
|
||||
|
||||
# Parse both XML files using xml.etree.ElementTree for redlining validation
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
modified_tree = ET.parse(modified_file)
|
||||
modified_root = modified_tree.getroot()
|
||||
original_tree = ET.parse(original_file)
|
||||
|
||||
Reference in New Issue
Block a user