#!/usr/bin/env node
import fs from 'node:fs'
const usage = `
Usage:
node scripts/verify-umbrel-runtime-review-evidence.mjs --evidence umbrel-runtime-review-evidence.json [options]
Options:
--evidence <path> Umbrel runtime review evidence JSON to verify
--release <version> Optional release version that must match, e.g. v0.16.3
--official-pr-url <url> Optional expected upstream getumbrel/umbrel-apps PR URL that must match
`
const REQUIRED_CHECKS = Object.freeze([
'installedThroughUmbrel',
'dashboardProxyLoads',
'liveFeedInBandAuth',
'noWebSocketUrlTokens',
'wizardCompletes',
'setupActionLockObserved',
'addWalletPersists',
'dynamicPayoutControlsObserved',
'walletBusyStateObserved',
'managementActionsPersist',
'serviceActionStateObserved',
'serviceRestartPendingObserved',
'aiModelAddStateObserved',
'reviewModeDefault',
'dataWritableUid999',
'reinstallPreservesPublicKey'
])
const FORBIDDEN_PUBLIC_VALUE_PATTERNS = [
[/-----BEGIN [A-Z ]*(?:PRIVATE|SECRET) KEY-----/, 'private key block'],
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/, 'GitHub token'],
[/\bgithub_pat_[A-Za-z0-9_]{20,}\b/, 'GitHub token'],
[/\bAuthorization\s*:\s*/i, 'authorization header'],
[/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i, 'bearer token'],
[/\bAPP_SEED=[^\s'"]+/i, 'APP_SEED'],
[/\bHIVERELAY_API_KEY=[^\s'"]+/i, 'API key'],
[/\bsk-[A-Za-z0-9_-]{20,}\b/, 'API key']
]
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
const OFFICIAL_UMBREL_PR_URL_PATTERN = /^https:\/\/github\.com\/getumbrel\/umbrel-apps\/pull\/[1-9][0-9]*$/
const LOCAL_ADDRESS_PATTERN = /\b(?:localhost|127(?:\.\d{1,3}){3}|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2[0-9]|3[0-1])(?:\.\d{1,3}){2}|[a-z0-9-]+\.local)\b/i
const MAX_EVIDENCE_JSON_BYTES = 2 * 1024 * 1024
const MAX_GENERATED_AT_FUTURE_SKEW_MS = 5 * 60 * 1000
const args = parseArgs(process.argv.slice(2))
if (args.help || !args.evidence) {
console.log(usage.trim())
process.exit(args.help ? 0 : 1)
}
const body = readJson(args.evidence)
verifyEvidence(body, args)
console.log(`Umbrel runtime review evidence verified: ${body.release.version}`)
function parseArgs (argv) {
const out = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--help' || arg === '-h') {
out.help = true
continue
}
if (arg === '--evidence' || arg === '--release' || arg === '--official-pr-url') {
const value = argv[++i]
if (!value || value.startsWith('--')) die(`Missing value for ${arg}`)
out[camel(arg.slice(2))] = value
continue
}
die(`Unknown argument: ${arg}`)
}
return out
}
function verifyEvidence (body, opts) {
assertPublicSafeValues(body, 'Umbrel runtime review evidence')
assertNoRawIdentityFields(body)
assertRuntimeReviewSchema(body)
requireEqual('schemaVersion', body.schemaVersion, 1)
const generatedAtMs = requireIsoTimestamp('generatedAt', body.generatedAt)
if (generatedAtMs > Date.now() + MAX_GENERATED_AT_FUTURE_SKEW_MS) {
die('generatedAt must not be in the future')
}
requireEqual('kind', body.kind, 'umbrel-runtime-review')
requireEqual('status', body.status, 'passed')
requirePattern('release.version', body.release?.version, /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)
requirePattern('release.semver', body.release?.semver, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)
requireEqual('release semver matches version', body.release.semver, body.release.version.slice(1))
if (opts.release) requireEqual('release.version', body.release.version, opts.release)
requireEqual('platform.name', body.platform?.name, 'umbrel')
requireSafePublicLabel('Umbrel review device', body.platform?.device)
requirePattern('Umbrel version', body.platform?.umbrelVersion, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)
requireSafePublicLabel('Umbrel runtime reviewer', body.review?.testedBy)
verifyIdentityHashes(body.identity)
requirePattern('official Umbrel PR URL', body.officialUmbrelPr?.url, OFFICIAL_UMBREL_PR_URL_PATTERN)
if (opts.officialPrUrl) requireEqual('official Umbrel PR URL', body.officialUmbrelPr.url, opts.officialPrUrl)
verifyChecks(body.checks)
}
function assertRuntimeReviewSchema (body) {
requireOnlyKeys('Umbrel runtime review evidence', body, [
'schemaVersion',
'generatedAt',
'kind',
'status',
'release',
'platform',
'review',
'identity',
'checks',
'officialUmbrelPr'
])
requireOnlyKeys('release', body.release, ['version', 'semver'])
requireOnlyKeys('platform', body.platform, ['name', 'device', 'umbrelVersion'])
requireOnlyKeys('review', body.review, ['testedBy'])
requireOnlyKeys('identity', body.identity, ['publicKeySha256', 'publicKeyBeforeSha256', 'publicKeyAfterSha256'])
requireOnlyKeys('officialUmbrelPr', body.officialUmbrelPr, ['url'])
if (Array.isArray(body.checks)) {
for (const check of body.checks) requireOnlyKeys(`Umbrel runtime check ${check?.name || ''}`.trim(), check, ['name', 'status'])
}
}
function requireOnlyKeys (label, value, allowed) {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
die(`${label} must be an object`)
}
const allowedSet = new Set(allowed)
const extra = Object.keys(value).filter(key => !allowedSet.has(key))
if (extra.length > 0) {
die(`${label} has unsupported fields: ${extra.join(', ')}`)
}
}
function verifyChecks (checks) {
if (!Array.isArray(checks)) die('checks must be an array')
const seen = new Set()
for (const check of checks) {
requirePattern('Umbrel runtime check name', check?.name, /^[A-Za-z][A-Za-z0-9]*$/)
if (!REQUIRED_CHECKS.includes(check.name)) die(`Unknown Umbrel runtime check: ${check.name}`)
if (seen.has(check.name)) die(`Duplicate Umbrel runtime check: ${check.name}`)
seen.add(check.name)
requireEqual(`Umbrel runtime check ${check.name}`, check.status, 'passed')
}
const missing = REQUIRED_CHECKS.filter((check) => !seen.has(check))
if (missing.length > 0) die(`Missing Umbrel runtime checks: ${missing.join(', ')}`)
}
function assertNoRawIdentityFields (body) {
visit(body, '$')
function visit (node, at) {
if (node == null || typeof node !== 'object') return
for (const [key, value] of Object.entries(node)) {
if (/publicKey/i.test(key) && !isAllowedPublicKeyHashField(key)) {
die(`Umbrel runtime review evidence must not expose raw public key fields at ${at}.${key}`)
}
visit(value, `${at}.${key}`)
}
}
}
function verifyIdentityHashes (identity = {}) {
requirePattern('relay public key hash', identity.publicKeySha256, /^[a-f0-9]{64}$/)
requirePattern('relay public key before hash', identity.publicKeyBeforeSha256, /^[a-f0-9]{64}$/)
requirePattern('relay public key after hash', identity.publicKeyAfterSha256, /^[a-f0-9]{64}$/)
requireEqual('relay public key hash matches before hash', identity.publicKeySha256, identity.publicKeyBeforeSha256)
requireEqual('relay public key hash after reinstall', identity.publicKeyAfterSha256, identity.publicKeyBeforeSha256)
}
function isAllowedPublicKeyHashField (key) {
return key === 'publicKeySha256' ||
key === 'publicKeyBeforeSha256' ||
key === 'publicKeyAfterSha256'
}
function readJson (file) {
try {
return JSON.parse(readRegularFile(file, 'Umbrel runtime review evidence', 'utf8', MAX_EVIDENCE_JSON_BYTES))
} catch (err) {
die(`Could not read Umbrel runtime review evidence from ${file}: ${err.message}`)
}
}
function readRegularFile (file, label, encoding, maxBytes = Infinity) {
const stat = fs.lstatSync(file)
if (stat.isSymbolicLink()) die(`${label} file must not be a symlink: ${file}`)
if (!stat.isFile()) die(`${label} file must be a regular file: ${file}`)
if (stat.size > maxBytes) die(`${label} file must be ${maxBytes} bytes or smaller: ${file} is ${stat.size} bytes`)
return fs.readFileSync(file, encoding)
}
function requireEqual (label, actual, expected) {
if (actual === expected) return
die(`${label} must be ${JSON.stringify(expected)}; got ${JSON.stringify(actual)}`)
}
function requirePattern (label, value, pattern) {
if (typeof value === 'string' && pattern.test(value)) return
die(`${label} is required and malformed; got ${JSON.stringify(value)}`)
}
function requireIsoTimestamp (label, value) {
requirePattern(label, value, ISO_TIMESTAMP_PATTERN)
const ms = Date.parse(value)
if (!Number.isFinite(ms)) die(`${label} is required and malformed; got ${JSON.stringify(value)}`)
return ms
}
function requireSafePublicLabel (label, value) {
if (typeof value !== 'string' || value.length === 0 || value.length > 120) {
die(`${label} is required and must be 1-120 public-safe characters; got ${JSON.stringify(value)}`)
}
if (LOCAL_ADDRESS_PATTERN.test(value)) {
die(`${label} must not include local hostnames or LAN addresses; got ${JSON.stringify(value)}`)
}
}
function assertPublicSafeValues (value, label) {
visit(value, '$')
function visit (node, at) {
if (node == null) return
if (typeof node === 'string') {
if (hasControlChars(node)) die(`${label} must not contain control characters at ${at}`)
for (const [pattern, name] of FORBIDDEN_PUBLIC_VALUE_PATTERNS) {
if (pattern.test(node)) die(`${label} must not contain ${name} at ${at}`)
}
try {
const url = new URL(node)
if (url.username || url.password) die(`${label} must not expose URL credentials at ${at}`)
if (!isAllowedPublicUrl(url)) die(`${label} must not contain non-public URL at ${at}`)
} catch (_) {}
return
}
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) visit(node[i], `${at}[${i}]`)
return
}
if (typeof node === 'object') {
for (const [key, child] of Object.entries(node)) visit(child, `${at}.${key}`)
}
}
}
function isAllowedPublicUrl (url) {
if (url.protocol !== 'https:') return false
if (url.search || url.hash) return false
return isPublicHostname(url.hostname)
}
function isPublicHostname (hostname) {
const host = String(hostname || '').toLowerCase()
if (!/^[a-z0-9.-]+$/.test(host)) return false
if (!host.includes('.')) return false
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return false
if (LOCAL_ADDRESS_PATTERN.test(host)) return false
const labels = host.split('.')
if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return false
const tld = labels[labels.length - 1]
if (!/^[a-z]{2,63}$/.test(tld)) return false
const reservedHosts = new Set(['localhost', 'example.com', 'example.net', 'example.org'])
if (reservedHosts.has(host)) return false
const reservedSuffixes = ['.localhost', '.local', '.internal', '.test', '.example', '.invalid', '.example.com', '.example.net', '.example.org']
return !reservedSuffixes.some(suffix => host.endsWith(suffix))
}
function hasControlChars (value) {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i)
if (code < 32 || code === 127) return true
}
return false
}
function camel (value) {
return value.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}
function die (message) {
console.error(message)
process.exit(1)
}