Wren Linux

wren-linux / tools / test-site.sh

#!/bin/bash
# Checks the website (site/, tools/build-site.sh, tools/site.py): built from a
# commit, it holds no script, no inline style or event handler, nothing loaded
# from elsewhere, and no link that leads nowhere, so it works in Tor Browser's
# Safest mode and under the strict Content-Security-Policy setup-vps.sh sends;
# it is dark for everyone; the fingerprint and git address are pins.sh's; the
# source browser escapes every file (this one holds <script>alert(1)</script>
# in this comment, and the site must still hold no script); a Markdown file is
# a page too (tools/site_md.py), and a hostile one, docs/zz-hostile.md made
# here, gets none of its HTML, scripts or addresses through; the Docs page
# lists the docs; and with --require-signed only a commit the pinned key signed
# is built. Changes nothing outside a temp folder. Run by test.sh, or:
#   bash tools/test-site.sh
# shellcheck disable=SC2016  # the inner shells expand their own variables
HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
T=$(mktemp -d) || { echo "  FAIL  couldn't make a temp folder (is /tmp full?)"; exit 1; }
# shellcheck disable=SC2317,SC2329  # runs from the EXIT trap
cleanup() { gpgconf --homedir "$T/gh" --kill all 2>/dev/null; rm -rf "$T"; }
trap cleanup EXIT
fails=0
check() { # DESCRIPTION CONDITION-COMMAND...
    local what=$1
    shift
    if "$@" >/dev/null 2>&1; then printf '  ok    %s\n' "$what"; else printf '  FAIL  %s\n' "$what"; fails=$((fails + 1)); fi
}
not() { ! "$@"; }
. "$HERE/pins.sh"

# A copy of this checkout, with what isn't committed yet, as one commit
W=$T/w
mkdir -p "$W"
(cd "$HERE" && { git ls-files; git ls-files -o --exclude-standard -- site tools; } | sort -u | while read -r f; do [ -f "$f" ] && printf '%s\n' "$f"; done | tar -cf - -T -) | tar -C "$W" -xf -
# A doc that tries everything a Markdown file could, to get markup or script into the site
cat >"$W/docs/zz-hostile.md" <<'MD'
# Hostile <img src=x onerror=alert(5)> doc" onclick="alert(7)

<script>alert(2)</script> and <iframe src="https://example.org"></iframe> &lt;b&gt;

**<b>bold</b>** [js](javascript:alert(3)) [data](data:text/html,hi) [vb](VBScript:x)
[quote](https://example.org/"onmouseover="alert(4)) [out](../../../etc/passwd) [up](../../pins.sh)
[ok](../pins.sh) [vps](vps.md#the-short-way-one-command-through-tor) [none](not-there.md)
[proto](//example.org/x) <javascript:alert(6)> <https://example.org/a>

| a | <svg onload=alert(8)> |
|---|---|
| `x | y` | [t](javascript:alert(9)) |

- [ ] a box
  - nested **two**

```
</code></pre><script>alert(10)</script>
```
MD
G=(git -C "$W" -c user.name=t -c user.email=t@t -c commit.gpgsign=false)
"${G[@]}" init -q -b main && "${G[@]}" add -A && "${G[@]}" commit -q -m "as it is now"

O=$T/out
out=$(bash "$W/tools/build-site.sh" --out "$O" 2>&1)
check "it builds, from the commit${out:+: $(tail -1 <<<"$out")}" test -f "$O/index.html"
check "...its pages, the source browser, the log and the download" bash -c '
    for f in index.html install.html verify.html style.css source/index.html source/log.html source/README.md.html source/docs/bios.md.html wren-linux.tar.gz wren-linux.tar.gz.sha256; do [ -f "$1/$f" ] || { echo "$f"; exit 1; }; done' _ "$O"

# The whole site, every tag and link: plain HTML only
cat >"$T/lint.py" <<'PY'
import html.parser, os, re, sys
root = sys.argv[1]
ALLOWED = {"html", "head", "meta", "title", "link", "body", "header", "nav", "main", "footer", "a", "h1", "h2",
           "h3", "h4", "p", "ul", "ol", "li", "strong", "em", "code", "pre", "div", "table", "thead", "tbody", "tr",
           "th", "td", "hr", "img", "span"}
VOID = {"meta", "link", "hr", "img"}
bad = []
class P(html.parser.HTMLParser):
    def __init__(self, path):
        super().__init__(convert_charrefs=True)
        self.path, self.stack = path, []
    def handle_starttag(self, tag, attrs):
        where = os.path.relpath(self.path, root)
        if tag not in ALLOWED:
            bad.append("%s: <%s>" % (where, tag))
        for k, v in attrs:
            v = v or ""
            if k.startswith("on") or k == "style":
                bad.append("%s: %s= on <%s>" % (where, k, tag))
            if re.match(r"\s*(javascript|data|vbscript):", v, re.I):
                bad.append("%s: %s=%s" % (where, k, v))
        a = dict(attrs)
        if tag == "link" and (a.get("rel") != "stylesheet" or re.match(r"[a-z]+:", a.get("href", ""))):
            bad.append("%s: a <link> to %s" % (where, a.get("href")))
        if tag == "img":
            src = a.get("src", "")
            if not a.get("alt") or re.match(r"([a-z]+:)?//", src) or not src.endswith(".png") or \
                    not os.path.exists(os.path.normpath(os.path.join(os.path.dirname(self.path), src))):
                bad.append("%s: an image that isn't a PNG of the site's own, with its alt text: %s" % (where, src))
        if tag == "a":
            h = a.get("href", "")
            if not re.match(r"(https?:)?//", h) and not h.startswith("#"):
                target = os.path.normpath(os.path.join(os.path.dirname(self.path), h.split("#")[0]))
                if not os.path.exists(target):
                    bad.append("%s: a link to %s, which isn't there" % (where, h))
        if tag not in VOID:
            self.stack.append(tag)
    def handle_endtag(self, tag):
        if not self.stack or self.stack.pop() != tag:
            bad.append("%s: </%s> doesn't close what is open" % (os.path.relpath(self.path, root), tag))
for top, _, files in os.walk(root):
    for f in files:
        if f.endswith(".html"):
            p = P(os.path.join(top, f))
            p.feed(open(os.path.join(top, f), encoding="utf-8").read())
            if p.stack:
                bad.append("%s: <%s> left open" % (os.path.relpath(p.path, root), p.stack[-1]))
print("\n".join(bad[:5]))
sys.exit(1 if bad else 0)
PY
lint=$(python3 -I "$T/lint.py" "$O")
check "every page: only plain tags, no script, inline style or event handler, every tag closed, every link leads somewhere${lint:+: $(paste -sd'|' <<<"$lint")}" test -z "$lint"
check "...not one <script anywhere (this test's own comment holds one: the source browser escaped it)" bash -c '
    ! grep -rqi "<script" "$1" && grep -q "&lt;script&gt;alert(1)&lt;/script&gt;" "$1/source/tools/test-site.sh.html"' _ "$O"
# Images: only the picture, in what the site serves and in what the download has
printf '%s\n' 'import struct, sys' 'for p in sys.argv[1:]:' \
    '    d = open(p, "rb").read(); i = 8' \
    '    while i < len(d):' \
    '        n = struct.unpack(">I", d[i:i + 4])[0]; t = d[i + 4:i + 8]' \
    '        if t not in (b"IHDR", b"PLTE", b"tRNS", b"IDAT", b"IEND"): print(p, t.decode("latin1")); sys.exit(1)' \
    '        i += 12 + n' >"$T/png.py"
check "the mascot is on the front page" grep -q '<img class="mascot" src="image/penguin.png"' "$O/index.html"
check "...every image the site serves, and every one in site/image, is only a picture: no editing history, dates or EXIF" \
    bash -c 'python3 -I "$1" "$2"/image/*.png "$3"/site/image/*.png' _ "$T/png.py" "$O" "$HERE"
# an image with a drawing program's metadata: it serves the picture only
mkdir -p "$T/pages"
cp -r "$HERE/site/." "$T/pages/"
python3 -I -c 'import struct,sys,zlib
p=sys.argv[1]; d=open(p,"rb").read(); t=b"tEXt"; c=b"Comment\0made in GIMP at -08:00"
ch=struct.pack(">I",len(c))+t+c+struct.pack(">I",zlib.crc32(t+c)&0xffffffff)
open(p,"wb").write(d[:33]+ch+d[33:])' "$T/pages/image/penguin.png"
bash "$HERE/tools/build-site.sh" --out "$T/o5" --pages "$T/pages" >/dev/null 2>&1
check "...one with a drawing program's text in it is served without it" bash -c '
    grep -q "made in GIMP" "$1/pages/image/penguin.png" && ! grep -q "made in GIMP" "$1/o5/image/penguin.png" && python3 -I "$1/png.py" "$1/o5/image/penguin.png"' _ "$T"
check "a page of code is wider than the text pages (about 120 columns), the text pages as narrow as before" python3 -I -c '
import re, sys
s = open(sys.argv[1]).read()
body = re.search(r"\nbody \{[^}]*max-width: 46rem;", s)
code = re.search(r"body:has\(\.file:not\(\.prose\)\) \{ max-width: 70rem; \}", s)
assert body and code' "$O/style.css"
check "the style sheet loads nothing (no url(), @import or web font)" not grep -qE 'url\(|@import|@font-face' "$O/style.css"
check "code keeps its lines whole and scrolls sideways (as programmers expect); prose wraps at the window's edge" \
    python3 -I -c 'import re, sys; s = open(sys.argv[1]).read()
code = re.search(r"\.file pre \{([^}]*)\}", s).group(1); prose = re.search(r"\.file\.prose pre \{([^}]*)\}", s).group(1)
assert "white-space: pre;" in code and "overflow-x: auto" in code and "white-space: pre-wrap" in prose' "$O/style.css"
check "...code shown so, every line numbered, each a link to itself, as many as the file has" bash -c '
    grep -q "<div class=\"file\"><pre><code><span class=\"l\" id=\"L1\"><a class=\"ln\" href=\"#L1\"></a>" "$1/pins.sh.html" &&
    [ "$(grep -o "<span class=\"l\" id=\"L[0-9]*\">" "$1/pins.sh.html" | wc -l)" = "$(wc -l <"$2/pins.sh")" ]' _ "$O/source" "$W"
check "...the numbers drawn by the style sheet, not in the text: a copy of the code is the code alone" bash -c '
    grep -q "<a class=\"ln\" href=\"#L7\"></a>" "$1" && grep -q "content: counter(line)" "$2" && grep -q "user-select: none" "$2"' _ "$O/source/pins.sh.html" "$O/style.css"
check "...coloured as vim colours it, bash and Python: comments, strings, keywords, variables" bash -c '
    grep -q "<span class=\"p\">#!/bin/bash</span>" "$1/tools/vps-client.sh.html" && grep -q "<span class=\"c\"># " "$1/tools/vps-client.sh.html" &&
    grep -q "<span class=\"k\">case</span>" "$1/tools/vps-client.sh.html" && grep -q "<span class=\"v\">\$SSH_KEY</span>" "$1/tools/vps-client.sh.html" &&
    grep -q "<span class=\"k\">def</span>" "$1/tools/site_md.py.html" && grep -q "<span class=\"s\">&quot;" "$1/tools/site_md.py.html"' _ "$O/source"
check "...and the colours take nothing from it nor add to it: without them, each page is its file, every character" \
    python3 -I -c 'import html, re, sys
for f in sys.argv[2:]:
    page = open(sys.argv[1] + "/source/" + f + ".html", encoding="utf-8").read()
    code = re.search(r"<pre><code>(.*)</code></pre>", page, re.S).group(1)
    text = html.unescape(re.sub(r"<span class=\"l\" id=\"L\d+\"><a class=\"ln\" href=\"#L\d+\"></a>|</?span[^>]*>", "", code))
    assert text == open(sys.argv[1] + "/../w/" + f, encoding="utf-8").read(), f' "$O" tools/vps-client.sh tools/site_md.py tools/site.py lib/tor.sh pins.sh
# a script that tries to end its colour early, or start markup, in every place a colour covers
printf '%s\n' '#!/bin/bash' '# </span><script>alert(11)</script>' 'echo "</span><b onclick=x>" '"'"'<i>'"'"' $X' \
    'cat <<EOF' '</code></pre><script>alert(12)</script>' 'EOF' >"$T/hostile.sh"
python3 -I -c 'import importlib.util, sys
s = importlib.util.spec_from_file_location("h", sys.argv[1]); h = importlib.util.module_from_spec(s); s.loader.exec_module(h)
print("\n".join(h.lines("hostile.sh", open(sys.argv[2]).read())))' "$HERE/tools/site_hl.py" "$T/hostile.sh" >"$T/hostile.html"
check "...a script that tries to end its colour or start markup: all of it text, inside the colours" bash -c '
    ! grep -qiE "<(script|b |b>|i>|/code|/pre)" "$1" && grep -qF "&lt;/span&gt;&lt;script&gt;alert(11)" "$1" &&
    grep -qF "&lt;/code&gt;&lt;/pre&gt;&lt;script&gt;alert(12)" "$1" && [ "$(grep -o "<span" "$1" | wc -l)" = "$(grep -o "</span>" "$1" | wc -l)" ]' _ "$T/hostile.html"
check "...prose (a Markdown file as text, the licence) wraps, unnumbered" bash -c '
    grep -q "<div class=\"file prose\"><pre><code>" "$1/README.md.plain.html" && grep -q "<div class=\"file prose\">" "$1/LICENSE.html" &&
    ! grep -q "class=\"ln\"" "$1/README.md.plain.html"' _ "$O/source"

# Markdown files as pages (tools/site_md.py), each beside itself as plain text
check "a Markdown file is a page: a document, its headings with ids, and a link to it as plain text" bash -c '
    grep -q "<div class=\"doc\">" "$1/README.md.html" && grep -q "<h1 id=\"" "$1/README.md.html" &&
    grep -q "(<a href=\"README.md.plain.html\">as plain text</a>)" "$1/README.md.html" &&
    grep -q "<div class=\"file prose\"><pre><code>" "$1/README.md.plain.html" && grep -q "(<a href=\"README.md.html\">as a page</a>)" "$1/README.md.plain.html"' _ "$O/source"
check "...its links to files of the commit lead to their pages, an anchor kept" bash -c '
    grep -q "<a href=\"docs/vps.md.html\"" "$1/README.md.html" || grep -q "<a href=\"docs/first-time.md.html\"" "$1/README.md.html"' _ "$O/source"
check "...tables, lists, code and rules come out as such" bash -c '
    grep -q "<div class=\"table\"><table>" "$1/docs/wifi.md.html" && grep -q "<th>" "$1/docs/wifi.md.html" &&
    grep -q "<li>\u2610\|<li>☐" "$1/docs/opsec.md.html" && grep -q "<hr>" "$1/docs/distro-plan.md.html"' _ "$O/source"
check "the Docs page, in every page's menu, lists the guides, the extras and the project's documents" bash -c '
    grep -q "<a href=\"source/docs/vps.md.html\">" "$1/docs.html" && grep -q "<a href=\"source/docs/extras/tor-browser.md.html\">" "$1/docs.html" &&
    grep -q "<a href=\"source/SECURITY.md.html\">" "$1/docs.html" && ! grep -q "CLAUDE.md" "$1/docs.html" &&
    grep -q "<a href=\"../../docs.html\">Docs</a>" "$1/source/docs/vps.md.html"' _ "$O"
H=$O/source/docs/zz-hostile.md.html
check "a hostile doc: its HTML shown as text, none of it markup (no script, iframe, svg, img, event handler)" bash -c '
    grep -qF "&lt;script&gt;alert(2)&lt;/script&gt;" "$1" && grep -qF "&lt;iframe src=" "$1" && grep -qF "&lt;img src=x onerror=alert(5)&gt;" "$1" &&
    grep -qF "&lt;svg onload=alert(8)&gt;" "$1" && grep -qF "&lt;/code&gt;&lt;/pre&gt;&lt;script&gt;alert(10)" "$1" &&
    ! grep -qiE "<(script|iframe|svg|img)|onerror=\"|onload=\"|onclick=\"|onmouseover=\"" <(sed "s/&lt;[^&]*&gt;//g" "$1")' _ "$H"
check "...an entity is shown as written (&lt;b&gt;), not as the character it names" grep -qF "&amp;lt;b&amp;gt;" "$H"
check "...no address it takes but https/http, its own page's anchors and files of the commit" bash -c '
    ! grep -qiE "href=\"(javascript|data|vbscript):|href=\"//|onmouseover" "$1" && grep -q ">js<\|js \|^js" "$1" &&
    grep -q "<a href=\"../pins.sh.html\">ok</a>" "$1" && grep -q "<a href=\"vps.md.html#the-short-way-one-command-through-tor\">vps</a>" "$1" &&
    grep -q "<a href=\"https://example.org/a\">" "$1"' _ "$H"
check "...nothing out of the commit, and nothing that isn't in it, becomes a link" bash -c '
    ! grep -qE "href=\"[^\"]*(etc/passwd|not-there)" "$1" && ! grep -q ">up</a>" "$1" && ! grep -q ">out</a>" "$1"' _ "$H"
check "...its heading's id is letters, digits and dashes only" bash -c 'grep -qE "<h1 id=\"[a-z0-9_-]+\">" "$1"' _ "$H"
check "...dark for everyone: it doesn't ask the visitor's system" bash -c 'grep -q "color-scheme: dark" "$1" && ! grep -q "prefers-color-scheme" "$1" && ! grep -q "color-scheme: light" "$1"' _ "$O/style.css"
check "...every page says so" bash -c '! grep -rL "<meta name=\"color-scheme\" content=\"dark\">" --include="*.html" "$1" | grep -q .' _ "$O"
check "the front page: one paragraph, and what it doesn't protect against as a small link at the bottom" bash -c '
    [ "$(sed -n "/<div class=\"home\">/,/<\/div>/p" "$1" | grep -c "^<p>")" = 1 ] && grep -q "<p class=\"small\"><a href=\"source/THREAT-MODEL.md.html\">" "$1"' _ "$O/index.html"
spaced=$(sed -E 's/(.{4})/\1 /g; s/ $//' <<<"$WREN_SIGNING_FPR")
check "Verify shows the key pins.sh pins" grep -qF "<code>$spaced</code>" "$O/verify.html"
check "Install's git address is pins.sh's" grep -qF "git clone $WREN_GIT_URL ~/wren" "$O/install.html"
check "a build whose signature wasn't checked says so on every page" bash -c '
    ! grep -rL "a preview: its signature wasn.t checked" --include="*.html" "$1" | grep -q . && ! grep -rq "</code>, signed by the key on" "$1"' _ "$O"
check "the download is the commit, and its SHA-256 matches" bash -c '
    cd "$1" && sha256sum -c --quiet wren-linux.tar.gz.sha256 && tar -tzf wren-linux.tar.gz | grep -qx "wren-linux/pins.sh"' _ "$O"
check "the log lists the commit it was built from" bash -c 'grep -q "$(git -C "$2" rev-parse --short=12 HEAD)" "$1/source/log.html"' _ "$O" "$W"

# What it refuses, and what it leaves alone
mkdir -p "$T/mine"; echo keep >"$T/mine/notes"
check "an --out folder it didn't make is refused, and left as it was" bash -c '
    ! bash "$1/tools/build-site.sh" --out "$2" --pages "$1/site" 2>/dev/null && grep -qx keep "$2/notes"' _ "$HERE" "$T/mine"
check "...one it made is replaced" bash "$HERE/tools/build-site.sh" --out "$O" --pages "$HERE/site"
check "an --onion that isn't an onion address is refused" not bash "$HERE/tools/build-site.sh" --out "$T/o2" --pages "$HERE/site" --onion example.com
ON=$(printf 'a%.0s' {1..56}).onion
bash "$HERE/tools/build-site.sh" --out "$T/o3" --pages "$HERE/site" --onion "$ON" >/dev/null 2>&1
check "...one that is: on every page, and on Verify as a place to compare the key" bash -c '
    grep -q "Also at <code>$2</code>" "$1/index.html" && grep -q "this site.s onion address, <code>$2</code>" "$1/verify.html"' _ "$T/o3" "$ON"
check "the renderer refuses values that aren't what they should be" bash -c '
    ! SITE_FPR="<b>" SITE_GIT_URL=https://x.example/r SITE_COMMIT=$(printf "a%.0s" {1..40}) python3 -I "$1/tools/site.py" "$1/site" "$1" /dev/null "$2/x" 2>/dev/null' _ "$HERE" "$T"
check "--pages (not committed) and --require-signed don't go together" not bash "$HERE/tools/build-site.sh" --out "$T/o4" --pages "$HERE/site" --require-signed
check "the site's source isn't in the package (tools/build-deb.sh leaves it out)" grep -q '"$SHARE/site"' "$HERE/tools/build-deb.sh"

# --require-signed: only a commit the pinned key signed
if command -v gpg >/dev/null; then
    mkdir -m 700 "$T/gh"
    gpg --homedir "$T/gh" --batch --quiet --passphrase '' --quick-gen-key "site test <s@t>" ed25519 sign 1y 2>/dev/null
    F=$(gpg --homedir "$T/gh" --batch --with-colons --list-keys 2>/dev/null | awk -F: '/^fpr/ { print $10; exit }')
    gpg --homedir "$T/gh" --batch --armor --export "$F" >"$W/data/wren-signing-key.asc" 2>/dev/null
    sed -i "s|^WREN_SIGNING_FPR=.*|WREN_SIGNING_FPR=$F|" "$W/pins.sh"
    "${G[@]}" commit -q -am "the test key pinned, unsigned"
    check "--require-signed: an unsigned commit isn't built" bash -c '
        ! bash "$1/tools/build-site.sh" --out "$2" --require-signed 2>/dev/null && [ ! -e "$2" ]' _ "$W" "$T/s1"
    GNUPGHOME=$T/gh git -C "$W" -c user.name=t -c user.email=t@t -c gpg.program=gpg -c user.signingkey="$F" commit -q -S --allow-empty -m signed
    out=$(bash "$W/tools/build-site.sh" --out "$T/s2" --require-signed 2>&1)
    check "...one the pinned key signed is, and its pages say so${out:+: $(tail -1 <<<"$out")}" bash -c '
        grep -q "</code>, signed by the key on <a href=\"verify.html\">Verify</a>" "$1/index.html" && grep -q "</code>, signed by the key on <a href=\"../../verify.html\">" "$1/source/docs/bios.md.html"' _ "$T/s2"
fi

# --preview: into ~/Downloads/wren-site-preview, opened in the sandboxed Firefox
mkdir -p "$T/ph/Downloads"
printf '#!/bin/sh\necho "$@" >"%s/opened"\n' "$T" >"$T/pfx"
chmod +x "$T/pfx"
HOME=$T/ph WREN_FIREFOX=$T/pfx bash "$W/tools/build-site.sh" --preview >/dev/null 2>&1
/bin/sleep 1
check "--preview: the site in ~/Downloads/wren-site-preview, with this copy's pages, opened in the sandboxed Firefox" bash -c '
    [ -f "$1/ph/Downloads/wren-site-preview/index.html" ] && [ "$(cat "$1/opened")" = "file://$1/ph/Downloads/wren-site-preview/index.html" ]' _ "$T"
check "...not with --require-signed" not bash "$W/tools/build-site.sh" --preview --require-signed

[ "$fails" -eq 0 ]