b7cb061fee
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
209 lines
8.1 KiB
Python
209 lines
8.1 KiB
Python
"""Point 12: the install must work on a network with no way out.
|
|
|
|
The factory floor at Tràfilo has no internet. A page that fetches a library from
|
|
a CDN there does not load slowly, it does not load: the interface never starts.
|
|
|
|
The libraries were brought in-house; this is what keeps them there. The failure
|
|
mode this guards against is not dramatic - someone adds a `<script src="https://
|
|
cdn...">` because it is the quickest way to try something, it works at their desk,
|
|
and nobody finds out until the machine is on the shop floor.
|
|
"""
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
|
TEMPLATES = APP_ROOT / "templates"
|
|
STATIC = APP_ROOT / "static"
|
|
|
|
# Anything loaded by the browser: src=, href=, url(), importScripts, workerSrc.
|
|
_EXTERNAL_ASSET_RX = re.compile(
|
|
r"""(?:src|href)\s*=\s*["']https?://""", re.I,
|
|
)
|
|
_EXTERNAL_URL_RX = re.compile(r"""https?://[^\s"'<>)]+""")
|
|
|
|
# Hosts a comment may legitimately mention: the check is about what gets fetched,
|
|
# not about what gets written down. Only documentation links live here.
|
|
_DOC_HOSTS = (
|
|
"tailwindcss.com", "github.com", "bugzilla.mozilla.org",
|
|
"bugs.chromium.org", "bugs.webkit.org", "www.w3.org", "schema.org",
|
|
"developer.mozilla.org",
|
|
)
|
|
|
|
|
|
def _tracked_files():
|
|
"""The files git knows about, or None when git cannot answer.
|
|
|
|
Being outside `vendor/` is not the same as being ours. A scratch copy of a
|
|
library dropped into `static/js/` to debug something looks first-party and is
|
|
not: it is not in the repository, it never reaches the shop floor, and its
|
|
comments are not our comments. Trackedness is the honest test for that.
|
|
|
|
Trackedness, and not `.gitignore`: an ignore pattern says nothing about a
|
|
file that is already tracked, so matching patterns would be the wrong
|
|
question.
|
|
|
|
When git cannot answer we scan everything, as before. A guard test that goes
|
|
quiet the moment it loses its footing is worse than one that cries wolf.
|
|
"""
|
|
try:
|
|
listed = subprocess.run(
|
|
["git", "ls-files", "-z"],
|
|
cwd=APP_ROOT, capture_output=True, text=True, check=True,
|
|
).stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
return {(APP_ROOT / name).resolve() for name in listed.split("\0") if name}
|
|
|
|
|
|
def _first_party_files(suffixes):
|
|
tracked = _tracked_files()
|
|
|
|
def is_ours(path):
|
|
return tracked is None or path.resolve() in tracked
|
|
|
|
for path in sorted(STATIC.rglob("*")):
|
|
if path.suffix in suffixes and "vendor" not in path.parts and is_ours(path):
|
|
yield path
|
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
|
if is_ours(path):
|
|
yield path
|
|
|
|
|
|
def test_no_template_loads_anything_from_the_network():
|
|
"""A src= or href= pointing outside is the whole bug, in one line."""
|
|
offenders = []
|
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
|
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
if _EXTERNAL_ASSET_RX.search(line):
|
|
offenders.append(f"{path.relative_to(APP_ROOT)}:{n}")
|
|
assert not offenders, f"asset caricati dalla rete: {offenders}"
|
|
|
|
|
|
def test_no_first_party_script_calls_out():
|
|
"""Not only tags: a fetch() or a workerSrc reaches the network just the same."""
|
|
offenders = []
|
|
for path in _first_party_files({".js", ".css"}):
|
|
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
for url in _EXTERNAL_URL_RX.findall(line):
|
|
if any(host in url for host in _DOC_HOSTS):
|
|
continue
|
|
offenders.append(f"{path.relative_to(APP_ROOT)}:{n} {url}")
|
|
assert not offenders, f"riferimenti esterni: {offenders}"
|
|
|
|
|
|
def test_the_libraries_are_actually_in_the_box():
|
|
"""Repointing the tags without shipping the files is the same white screen."""
|
|
vendor = STATIC / "vendor"
|
|
missing = [
|
|
name for name in (
|
|
"alpinejs-3.15.12.min.js",
|
|
"plotly-2.32.0.min.js",
|
|
"pdf-3.11.174.min.js",
|
|
"pdf.worker-3.11.174.min.js",
|
|
"fabric-5.3.1.min.js",
|
|
"fonts.css",
|
|
)
|
|
if not (vendor / name).is_file()
|
|
]
|
|
assert not missing, f"librerie dichiarate ma non presenti: {missing}"
|
|
assert list((vendor / "fonts").glob("*.woff2")), "i font non sono nel pacchetto"
|
|
|
|
|
|
def test_the_pdf_worker_is_local_everywhere_it_is_set():
|
|
"""The trap of this point: the library loads from disk and its worker does not.
|
|
|
|
Everything looks right until someone opens a drawing.
|
|
"""
|
|
setters = []
|
|
for path in sorted(TEMPLATES.rglob("*.html")):
|
|
text = path.read_text(encoding="utf-8")
|
|
if "workerSrc" not in text:
|
|
continue
|
|
setters.append(path.relative_to(APP_ROOT).as_posix())
|
|
for n, line in enumerate(text.splitlines(), 1):
|
|
if "workerSrc" in line:
|
|
assert "vendor/pdf.worker" in line, (
|
|
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
|
|
)
|
|
# Five templates render PDFs; if that number changes the new one needs the
|
|
# worker set too, and this is where you find out.
|
|
# #65: da quattro a cinque, task_execute.html mostra PDF nelle note e negli
|
|
# allegati dell'esecuzione (#55).
|
|
assert len(setters) == 5,f"template che impostano il worker: {setters}"
|
|
|
|
|
|
def test_the_shipped_libraries_are_the_ones_that_were_checked():
|
|
"""The fingerprints in VERSIONS.md are the record of what was validated.
|
|
|
|
A library swapped in place - a hurried patch, a bad merge, something worse -
|
|
keeps the same filename and the same version in the table. The hash is the
|
|
only part that notices.
|
|
"""
|
|
vendor = STATIC / "vendor"
|
|
recorded = dict(
|
|
(name, digest)
|
|
for digest, name in (
|
|
line.split(maxsplit=1)
|
|
for line in (
|
|
l.strip() for l in
|
|
(vendor / "VERSIONS.md").read_text(encoding="utf-8").splitlines()
|
|
)
|
|
if re.fullmatch(r"[0-9a-f]{64}\s+\S+", line)
|
|
)
|
|
)
|
|
assert recorded, "VERSIONS.md non elenca più le impronte"
|
|
|
|
import hashlib
|
|
wrong = []
|
|
for name, digest in recorded.items():
|
|
path = vendor / name
|
|
if not path.is_file():
|
|
wrong.append(f"{name}: assente")
|
|
continue
|
|
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
if actual != digest:
|
|
wrong.append(f"{name}: {actual} != {digest}")
|
|
assert not wrong, f"librerie diverse da quelle validate: {wrong}"
|
|
|
|
|
|
def test_the_versions_are_pinned():
|
|
"""No `3.x.x`: a library that updates itself changes an audit system without
|
|
anyone validating it. The filenames on disk carry the version."""
|
|
vendor = STATIC / "vendor"
|
|
unpinned = [
|
|
p.name for p in vendor.glob("*.js")
|
|
if not re.search(r"-\d+\.\d+\.\d+(\.min)?\.js$", p.name)
|
|
]
|
|
assert not unpinned, f"librerie senza versione nel nome: {unpinned}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The policy that keeps it that way
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
PAGES = ["/auth/login", "/"]
|
|
|
|
|
|
@pytest.mark.parametrize("path", PAGES)
|
|
def test_every_page_carries_the_policy(client, path):
|
|
resp = client.get(path, follow_redirects=False)
|
|
csp = resp.headers.get("Content-Security-Policy", "")
|
|
assert "default-src 'self'" in csp
|
|
assert "connect-src 'self'" in csp
|
|
# No CDN host may be allowed back in through the policy itself.
|
|
assert "http://" not in csp and "https://" not in csp
|
|
|
|
|
|
def test_the_policy_allows_what_the_app_actually_needs(client):
|
|
"""A policy that breaks the interface gets removed, and then it protects
|
|
nothing. These are the four allowances the libraries genuinely require."""
|
|
csp = client.get("/auth/login").headers["Content-Security-Policy"]
|
|
assert "'unsafe-eval'" in csp # Alpine 3, Plotly
|
|
assert "'unsafe-inline'" in csp # componenti Alpine nei template
|
|
assert "worker-src 'self' blob:" in csp # worker di PDF.js
|
|
assert "img-src 'self' data: blob:" in csp # canvas Fabric, miniature
|