150 lines
6.2 KiB
Python
150 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""release_check.py -- checksum, verify and scan a release directory.
|
|
|
|
python release_check.py sums <release_dir> # (re)write 00_SHA256SUMS
|
|
python release_check.py verify <release_dir> # check every listed file
|
|
python release_check.py scan <release_dir> [--env ~/skills_env.md] [--mac AA:BB:..]...
|
|
[--src-dirs 07_ps_app_src,08_imx6_app_src] [--allow KEY]...
|
|
|
|
scan reports (never prints a secret value, only the file and the key name):
|
|
* values of *PASS* / *PASSWORD* / *TOKEN* / *SECRET* / *KEY* keys in --env found
|
|
in any text file, and "user:password@" URLs; --allow KEY moves that key's hits
|
|
to "acknowledged" (e.g. a vendor factory-default account that ships in the
|
|
firmware and is also the brand name) -- still listed, no failure;
|
|
* tool state files (*.upstate.json) inside --src-dirs;
|
|
* private keys (-----BEGIN ... PRIVATE KEY-----);
|
|
* the given board MACs (--mac, e.g. a test board's) and any other MAC literal;
|
|
* development-machine absolute paths (X:\\..., /home/<user>, /Users/<user>);
|
|
* ELF / object / archive files inside --src-dirs (build output in a source tree);
|
|
* Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) that break copies.
|
|
Exit status 0 = scan finished (read the report), 2 = a secret was found.
|
|
"""
|
|
import hashlib, os, re, sys
|
|
|
|
SUMS = "00_SHA256SUMS"
|
|
RESERVED = re.compile(r"^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$", re.I)
|
|
MAC = re.compile(r"\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}\b")
|
|
WINPATH = re.compile(r"\b[A-Za-z]:[\\/](?:[^\s\"'<>|]+)")
|
|
NIXHOME = re.compile(r"/(?:home|Users)/[A-Za-z0-9_.-]+")
|
|
URLCRED = re.compile(r"[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@", re.I)
|
|
PRIVKEY = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
|
|
|
|
|
|
def files(root):
|
|
for d, _, fs in os.walk(root):
|
|
for f in fs:
|
|
yield os.path.join(d, f)
|
|
|
|
|
|
def relp(root, p):
|
|
return "./" + os.path.relpath(p, root).replace(os.sep, "/")
|
|
|
|
|
|
def sha(p):
|
|
h = hashlib.sha256()
|
|
with open(p, "rb") as f:
|
|
for b in iter(lambda: f.read(1 << 20), b""):
|
|
h.update(b)
|
|
return h.hexdigest()
|
|
|
|
|
|
def is_text(b):
|
|
return b"\0" not in b[:8192]
|
|
|
|
|
|
def cmd_sums(root):
|
|
lines = [f"{sha(p)} {relp(root, p)}" for p in sorted(files(root))
|
|
if os.path.basename(p) != SUMS]
|
|
open(os.path.join(root, SUMS), "w", newline="\n").write("\n".join(lines) + "\n")
|
|
print(f"{len(lines)} files -> {SUMS}")
|
|
|
|
|
|
def cmd_verify(root):
|
|
bad = n = 0
|
|
listed = set()
|
|
for line in open(os.path.join(root, SUMS)):
|
|
h, p = line.rstrip("\n").split(" ", 1)
|
|
listed.add(p); n += 1
|
|
fp = os.path.join(root, p[2:])
|
|
if not os.path.exists(fp) or sha(fp) != h:
|
|
bad += 1; print("BAD", p)
|
|
extra = [relp(root, p) for p in files(root)
|
|
if os.path.basename(p) != SUMS and relp(root, p) not in listed]
|
|
for p in extra:
|
|
print("NOT LISTED", p)
|
|
print(f"{n} listed, {bad} bad, {len(extra)} not listed")
|
|
sys.exit(1 if bad or extra else 0)
|
|
|
|
|
|
def env_secrets(path):
|
|
out = {}
|
|
if not path or not os.path.exists(path):
|
|
return out
|
|
for line in open(path, encoding="utf-8", errors="replace"):
|
|
m = re.match(r"\s*([A-Z0-9_]*(?:PASS|PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*)\s*=\s*(\S+)", line)
|
|
if m and len(m.group(2)) >= 4:
|
|
out[m.group(1)] = m.group(2)
|
|
return out
|
|
|
|
|
|
def cmd_scan(root, args):
|
|
env = os.path.expanduser(args[args.index("--env") + 1]) if "--env" in args else None
|
|
macs = [args[i + 1].lower() for i, a in enumerate(args) if a == "--mac"]
|
|
srcd = args[args.index("--src-dirs") + 1].split(",") if "--src-dirs" in args else []
|
|
allow = {args[i + 1] for i, a in enumerate(args) if a == "--allow"}
|
|
secrets = env_secrets(env)
|
|
found_secret = False
|
|
rep = {k: [] for k in ("secret", "acknowledged", "url_cred", "privkey", "board_mac", "mac", "dev_path",
|
|
"src_binary", "reserved")}
|
|
for p in files(root):
|
|
r = relp(root, p)
|
|
if RESERVED.match(os.path.basename(p)):
|
|
rep["reserved"].append(r)
|
|
b = open(p, "rb").read()
|
|
if any(r.startswith("./" + s.strip("/") + "/") for s in srcd):
|
|
if p.endswith(".upstate.json"):
|
|
rep["src_binary"].append(r + " (tool state file, remove)")
|
|
elif b[:4] == b"\x7fELF" or p.endswith((".o", ".a", ".so", ".ko")):
|
|
rep["src_binary"].append(r)
|
|
if not is_text(b):
|
|
continue
|
|
t = b.decode("utf-8", "replace")
|
|
for k, v in secrets.items():
|
|
if v in t:
|
|
if k in allow:
|
|
rep["acknowledged"].append(f"{r} (value of {k})")
|
|
else:
|
|
rep["secret"].append(f"{r} (value of {k})"); found_secret = True
|
|
if URLCRED.search(t):
|
|
rep["url_cred"].append(r); found_secret = True
|
|
if PRIVKEY.search(t):
|
|
rep["privkey"].append(r)
|
|
ms = {m.lower() for m in MAC.findall(t)}
|
|
for m in ms:
|
|
(rep["board_mac"] if m in macs else rep["mac"]).append(f"{r} {m}")
|
|
dp = sorted(set(WINPATH.findall(t)) | set(NIXHOME.findall(t)))
|
|
if dp:
|
|
rep["dev_path"].append(f"{r} ({len(dp)}: {dp[0][:60]}{' ...' if len(dp) > 1 else ''})")
|
|
titles = {"secret": "SECRET VALUES (must be removed)",
|
|
"acknowledged": "acknowledged values (--allow; say so in the README)",
|
|
"url_cred": "user:password@ URLs",
|
|
"privkey": "private keys (check: product key or leaked?)",
|
|
"board_mac": "given board MACs (must not ship)", "mac": "other MAC literals (review)",
|
|
"dev_path": "development-machine paths (review / document)",
|
|
"src_binary": "binaries inside source dirs", "reserved": "Windows reserved names"}
|
|
for k, title in titles.items():
|
|
print(f"== {title}: {len(rep[k])}")
|
|
for x in rep[k][:40]:
|
|
print(" ", x)
|
|
if len(rep[k]) > 40:
|
|
print(f" ... {len(rep[k]) - 40} more")
|
|
sys.exit(2 if found_secret else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 3:
|
|
sys.exit(__doc__)
|
|
c, root = sys.argv[1], sys.argv[2]
|
|
{"sums": lambda: cmd_sums(root), "verify": lambda: cmd_verify(root),
|
|
"scan": lambda: cmd_scan(root, sys.argv[3:])}[c]()
|