feat: vendor and censor the phishing blocklist at build time (closes #219)
This commit was merged in pull request #301.
This commit is contained in:
301
script/check-censored
Executable file
301
script/check-censored
Executable file
@@ -0,0 +1,301 @@
|
||||
#!/bin/sh
|
||||
# script/check-censored: assert that the competitor name RULES.md bars appears
|
||||
# nowhere in this repo, and nowhere in the built extension, except where it is
|
||||
# deliberate. Our own extension to scripts-to-rule-them-all, run from
|
||||
# script/check and from make build.
|
||||
#
|
||||
# Where the name is allowed, and why each one is not negotiable away:
|
||||
#
|
||||
# - script/vendor-blocklist. Build-time tooling, never shipped. A pinned
|
||||
# source reference that does not say what the source is cannot be verified
|
||||
# by anyone, so it names it. Whole-file exemption.
|
||||
# - the two provider-shim identifiers in src/content/inpage.js. Protocol
|
||||
# identifiers dApps feature-detect on; renaming them does not rename them in
|
||||
# their code, it only stops this wallet working on their sites.
|
||||
# - the on-chain name of the MUSD ERC-20 in src/shared/tokenList.js. It is not
|
||||
# what backs symbol-spoof detection — that reads symbol and address — but
|
||||
# the wallet already surfaces the on-chain name of any token the user holds
|
||||
# (src/shared/balances.js), and this contract's on-chain name is that
|
||||
# string, so censoring the repo cannot stop the wallet displaying it.
|
||||
# Dropping the entry instead would cost the user MUSD spoof detection.
|
||||
#
|
||||
# Everything else fails, in the working tree and under dist/. The last two are
|
||||
# literals rather than whole files, so they are enforced by counting, and each
|
||||
# literal is scoped to the path allowed to carry it: a file may contain the name
|
||||
# only as many times as it contains the literals permitted *there*, and zero
|
||||
# times anywhere else. The emitted bundles carry them too, so a plain "the name
|
||||
# must not appear in dist/" could never have passed.
|
||||
#
|
||||
# The name itself is not written in this file. script/vendor-blocklist is the
|
||||
# one place in this repo that defines it, and this reads it back out of there —
|
||||
# so the repo-wide grep this check exists to enforce keeps returning exactly the
|
||||
# files named above, and this file is not one of them.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Absolute path to this script, resolved before anything cd's anywhere: the
|
||||
# scan half runs in a re-invocation through xargs, so that the paths it works on
|
||||
# arrive as arguments and cannot be reshaped by field splitting on the way in.
|
||||
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
|
||||
|
||||
# Internal re-entry flag. Not part of the command-line interface.
|
||||
SCAN_FLAG="--scan-paths"
|
||||
|
||||
VENDOR_SCRIPT="$ROOT/script/vendor-blocklist"
|
||||
|
||||
# Set by extract_name / make_literals_file.
|
||||
NAME=""
|
||||
ALLOWED_LITERALS_FILE=""
|
||||
|
||||
FAILED=0
|
||||
|
||||
cleanup() {
|
||||
[ -z "$ALLOWED_LITERALS_FILE" ] || rm -f "$ALLOWED_LITERALS_FILE"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
fail() {
|
||||
echo "check-censored: FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The name, taken from the single place that defines it. A check scanning for a
|
||||
# pattern it failed to read would pass against anything, so this refuses to
|
||||
# continue unless it got something that looks like the definition.
|
||||
extract_name() {
|
||||
[ -f "$VENDOR_SCRIPT" ] ||
|
||||
fail "$VENDOR_SCRIPT is missing, and it is where the name being
|
||||
checked for is defined. Nothing was scanned."
|
||||
|
||||
NAME="$(grep -m1 '^UPSTREAM_ORG=' "$VENDOR_SCRIPT" | cut -d'"' -f2)" ||
|
||||
fail "could not read UPSTREAM_ORG from $VENDOR_SCRIPT. Nothing was
|
||||
scanned."
|
||||
|
||||
case "$NAME" in
|
||||
"" | *[!A-Za-z0-9]*)
|
||||
fail "UPSTREAM_ORG in $VENDOR_SCRIPT did not yield a plain name
|
||||
(got: '$NAME'). Scanning for that would prove nothing. Nothing was
|
||||
scanned."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
make_literals_file() {
|
||||
ALLOWED_LITERALS_FILE="$(mktemp \
|
||||
"${TMPDIR:-/tmp}/autistmask-censored.XXXXXX")" ||
|
||||
fail "could not create a temporary file, so nothing was scanned."
|
||||
}
|
||||
|
||||
# The literals $1 may carry, and nothing else may. Each contains the name
|
||||
# exactly once, which is what makes counting them sound; each is scoped to its
|
||||
# path, so a file with no business carrying the name fails even when it spells
|
||||
# it the way shipped code has to. Scoping is the point: permitting these
|
||||
# literals in any file is what once let this check pass its own prose.
|
||||
#
|
||||
# The emitted paths are listed next to the sources they come from. If the
|
||||
# bundler moves one, this goes red and the new path gets added deliberately,
|
||||
# rather than a wildcard over dist/ covering whatever lands there.
|
||||
allowed_literals_for() {
|
||||
: >"$ALLOWED_LITERALS_FILE"
|
||||
case "$1" in
|
||||
src/content/inpage.js | dist/*/src/content/inpage.js)
|
||||
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
|
||||
;;
|
||||
src/shared/tokenList.js | dist/*/src/background/index.js | \
|
||||
dist/*/src/popup/index.js)
|
||||
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# How many times does $1 contain the name (TOTAL), and how many of those are one
|
||||
# of the allowed literals (ALLOWED)? Same discipline the rest of this repo's
|
||||
# shell checks apply to grep: exit 0 and 1 are answers about the file, anything
|
||||
# else means the file was not searched and is not an answer at all.
|
||||
count_matches() {
|
||||
_cm_status=0
|
||||
_cm_out="$(grep -a -o -i -F -e "$NAME" -- "$1")" || _cm_status=$?
|
||||
case "$_cm_status" in
|
||||
0) TOTAL="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
|
||||
1) TOTAL=0 ;;
|
||||
*)
|
||||
fail "grep exited $_cm_status reading $1, so the file was never
|
||||
searched and nothing was established about it. That is a permissions or I/O
|
||||
fault, not a clean file. Refusing to report success."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$TOTAL" -eq 0 ]; then
|
||||
ALLOWED=0
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No literal is permitted at this path, so every occurrence is a violation.
|
||||
# Handled here rather than by grep, which is not required to say anything
|
||||
# useful about an empty pattern file.
|
||||
if [ ! -s "$ALLOWED_LITERALS_FILE" ]; then
|
||||
ALLOWED=0
|
||||
return 0
|
||||
fi
|
||||
|
||||
_cm_status=0
|
||||
_cm_out="$(grep -a -o -i -F -f "$ALLOWED_LITERALS_FILE" -- "$1")" ||
|
||||
_cm_status=$?
|
||||
case "$_cm_status" in
|
||||
0) ALLOWED="$(printf '%s\n' "$_cm_out" | grep -c .)" ;;
|
||||
1) ALLOWED=0 ;;
|
||||
*)
|
||||
fail "grep exited $_cm_status matching the allowed literals in $1.
|
||||
Refusing to report success."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The per-path half, run in a re-invocation of this script so it uses the same
|
||||
# counting as everything else rather than a second copy of it.
|
||||
scan_paths() {
|
||||
for _file in "$@"; do
|
||||
# dist/ arrives absolute (find) and the worktree relative (git
|
||||
# ls-files). The allowlist is keyed on repo-relative paths, so both
|
||||
# forms are reduced to one before anything is decided about them.
|
||||
_rel="$_file"
|
||||
case "$_rel" in
|
||||
"$ROOT"/*) _rel="${_rel#"$ROOT"/}" ;;
|
||||
esac
|
||||
|
||||
case "$_rel" in
|
||||
script/vendor-blocklist) continue ;;
|
||||
esac
|
||||
[ -f "$_file" ] || continue
|
||||
|
||||
allowed_literals_for "$_rel"
|
||||
count_matches "$_file"
|
||||
[ "$TOTAL" -gt "$ALLOWED" ] || continue
|
||||
|
||||
FAILED=$((FAILED + 1))
|
||||
echo "check-censored: $_rel: $TOTAL occurrence(s) of the name," \
|
||||
"$ALLOWED of them allowed at this path" >&2
|
||||
grep -a -n -i -F -e "$NAME" -- "$_file" | cut -c1-140 | head -5 >&2
|
||||
done
|
||||
[ "$FAILED" -eq 0 ]
|
||||
}
|
||||
|
||||
# Hand a NUL-delimited listing to the scan half. Returns non-zero if any path
|
||||
# failed, or if the scan could not be run at all.
|
||||
scan_listing() {
|
||||
xargs -0 "$SELF" "$SCAN_FLAG" <"$1"
|
||||
}
|
||||
|
||||
# Every file git tracks, plus everything untracked and not ignored: the working
|
||||
# tree as a reviewer would see it, and never node_modules or dist/ (both are
|
||||
# ignored; dist/ is walked separately below).
|
||||
check_worktree() {
|
||||
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-tree.XXXXXX")" ||
|
||||
fail "could not create a temporary file, so nothing was scanned."
|
||||
_status=0
|
||||
git ls-files -z --cached --others --exclude-standard >"$_list" ||
|
||||
_status=$?
|
||||
[ "$_status" -eq 0 ] || {
|
||||
rm -f "$_list"
|
||||
fail "git ls-files exited $_status, so the working tree was never
|
||||
enumerated and nothing was established about it."
|
||||
}
|
||||
|
||||
# Repo-relative paths. The scan half cd's to the repo root before it opens
|
||||
# anything, so they reach it intact and unjoined.
|
||||
WORKTREE_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
|
||||
|
||||
_status=0
|
||||
scan_listing "$_list" || _status=$?
|
||||
rm -f "$_list"
|
||||
return "$_status"
|
||||
}
|
||||
|
||||
check_dist() {
|
||||
_list="$(mktemp "${TMPDIR:-/tmp}/autistmask-censored-dist.XXXXXX")" ||
|
||||
fail "could not create a temporary file, so dist/ was not scanned."
|
||||
_status=0
|
||||
find "$ROOT/dist" -type f -print0 >"$_list" || _status=$?
|
||||
[ "$_status" -eq 0 ] || {
|
||||
rm -f "$_list"
|
||||
fail "find exited $_status enumerating dist/, so part of the emitted
|
||||
tree was never walked and an unchecked file there went unchecked. Refusing
|
||||
to report success."
|
||||
}
|
||||
|
||||
DIST_COUNT="$(tr -dc '\0' <"$_list" | wc -c | tr -d ' ')"
|
||||
|
||||
_status=0
|
||||
scan_listing "$_list" || _status=$?
|
||||
rm -f "$_list"
|
||||
return "$_status"
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "usage: script/check-censored [--require-dist]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# Internal re-entry from scan_listing's xargs.
|
||||
if [ "${1-}" = "$SCAN_FLAG" ]; then
|
||||
shift
|
||||
extract_name
|
||||
make_literals_file
|
||||
scan_paths "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
require_dist=no
|
||||
case "${1-}" in
|
||||
"") ;;
|
||||
--require-dist) require_dist=yes ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
extract_name
|
||||
make_literals_file
|
||||
|
||||
echo "Checking for censored names..."
|
||||
|
||||
tree_status=0
|
||||
check_worktree || tree_status=$?
|
||||
|
||||
dist_status=0
|
||||
dist_inspected=no
|
||||
DIST_COUNT=0
|
||||
if [ -d "$ROOT/dist" ]; then
|
||||
dist_inspected=yes
|
||||
check_dist || dist_status=$?
|
||||
fi
|
||||
|
||||
if [ "$tree_status" -ne 0 ] || [ "$dist_status" -ne 0 ]; then
|
||||
fail "the name appears outside the deliberate exceptions (reported
|
||||
above). See the header of script/check-censored for what is allowed and
|
||||
why."
|
||||
fi
|
||||
|
||||
if [ "$dist_inspected" = no ]; then
|
||||
if [ "$require_dist" = yes ]; then
|
||||
fail "there is no dist/ to inspect and this run was asked to
|
||||
require one. Run make build."
|
||||
fi
|
||||
cat <<EOF
|
||||
################################################################################
|
||||
## WARNING: dist/ WAS NOT INSPECTED BY THIS RUN AND IS NOT PROVEN CLEAN BY IT.
|
||||
## There is no dist/ in this tree. The working tree is clean, but a build can
|
||||
## carry text no source file does — a dependency's, or a bundler's. Every
|
||||
## make build runs this check again with dist/ required, so a release artifact
|
||||
## is always covered; this run simply had none to look at.
|
||||
################################################################################
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "check-censored: $WORKTREE_COUNT tracked file(s) inspected," \
|
||||
"$DIST_COUNT file(s) under dist/"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user