pub const SCRIPT_EXEC_TEMPLATE: &str = "def doPost(request, session):\n\t# ign-cli WebDev route: scriptExec -- SHARED-SECRET-GATED script execution.\n\t#\n\t# SECURITY POSTURE (planner-LOCKED with the user\'s decision: own auth, never\n\t# wide-open). Anyone with HTTP reach to the gateway must not be able to\n\t# invoke arbitrary script execution by mere route presence:\n\t# - The in-code shared-secret gate below is THE auth mechanism. It is\n\t# ported from WHK-Global\'s production webdev_auth module: dual-header\n\t# extraction (case-insensitive), sha256-both-sides constant-time compare\n\t# (Jython 2.7 lacks hmac.compare_digest), fail-closed on unconfigured.\n\t# - config.json is deliberately require-auth FALSE / user-source \"\": API\n\t# tokens do NOT authenticate WebDev require-auth routes (live-proven\n\t# 401), so a Basic layer would lock the CLI\'s own token-authed calls\n\t# out, and user-source \"default\" breaks on renamed IdPs (research Open\n\t# Question 3, resolved to secret-only).\n\t# - Denials ride the body envelope at HTTP 200 (WebDev ignores \'status\').\n\t#\n\t# TEMPLATE CONTRACT (read before editing): `ign webdev deploy` (05-03)\n\t# generates the deployed copy of this file from this template with ONE\n\t# string substitution -- the marker token on the SECRET line below is\n\t# replaced by a generated hex secret. As shipped here, SECRET therefore\n\t# holds the un-substituted placeholder; the gate treats BOTH the None\n\t# default AND any placeholder-shaped value (leading underscore) as\n\t# UNCONFIGURED and rejects EVERY action, version included (fail-closed --\n\t# the WHK _UNCONFIGURED precedent). A deployed secret is hex and can never\n\t# start with an underscore. This template is deliberately NOT part of\n\t# ignition-core\'s ROUTE_FILES bundle, so it cannot be deployed\n\t# unsubstituted by accident.\n\t#\n\t# Action-dispatch contract (doPost only, JSON body): {\"action\": \"<name>\", ...}\n\t# version -- handshake: {routeVersion, minCli} (ALSO secret-gated)\n\t# exec -- {code} -> {stdout, result, elapsedMs}. Single-expression code\n\t# is eval\'d and its value returned as result; statement code is\n\t# exec\'d and an optional `_result` global is surfaced. stdout is\n\t# captured and restored. Every invocation is audit-logged\n\t# (sha256-prefix + elapsedMs) via system.util.logger.\n\t#\n\t# Body envelope (WebDev IGNORES the \'status\' key -- denials ride HTTP 200):\n\t# success: {\"ok\": true, \"data\": {...}}\n\t# failure: {\"ok\": false, \"error\": {\"code\": \"secret_required\"|\"secret_mismatch\"|...,\n\t# \"message\": \"<human>\",\n\t# \"traceback\": <optional>}}\n\t#\n\t# SELF-CONTAINED BY DESIGN: WebDev route folders are independent (no\n\t# cross-resource imports), so the shared core (unicode re-parse, jv()\n\t# walker, envelope) is duplicated across the five cli/* routes\n\t# deliberately. Do not \"fix\" the duplication by importing.\n\n\tROUTE_VERSION = \'1.3.0\' # same constants in every route + ROUTE_BUNDLE_VERSION in ignition-core\n\tMIN_CLI = \'1.0\'\n\n\t# Deploy-time substitution target: the marker inside the string below is\n\t# replaced with the generated hex secret; until then this is the placeholder.\n\tSECRET = None or \'__IGN_CLI_SECRET__\'\n\n\n\tdef _secretUnconfigured():\n\t\t# None default OR placeholder-shaped (leading underscore -- a deployed\n\t\t# secret is hex and can never start with one) = fail closed.\n\t\treturn SECRET is None or str(SECRET).startswith(\'_\')\n\n\n\tdef _sha256Hex(s):\n\t\tfrom java.security import MessageDigest\n\t\tmd = MessageDigest.getInstance(\'SHA-256\')\n\t\tmd.update(str(s).encode(\'utf-8\'))\n\t\tdigest = md.digest()\n\t\treturn \'\'.join([\'%02x\' % (b & 0xFF) for b in digest])\n\n\n\tdef _constantTimeEquals(a, b):\n\t\t# Hash both sides first: the digests are always 64 hex chars, so the\n\t\t# comparison loop is fixed-length regardless of input (no length leak).\n\t\tda = _sha256Hex(a)\n\t\tdb = _sha256Hex(b)\n\t\tif len(da) != len(db):\n\t\t\treturn False\n\t\tresult = 0\n\t\tfor i in range(len(da)):\n\t\t\tresult |= ord(da[i]) ^ ord(db[i])\n\t\treturn result == 0\n\n\n\tdef _extractSecret(request):\n\t\t# Dual-header extract, case-insensitive: WebDev hands back headers with\n\t\t# whatever casing the client sent, so lower-case the dict before lookup.\n\t\theaders = request.get(\'headers\', {}) or {}\n\t\tlowered = {}\n\t\tfor key, value in headers.items():\n\t\t\ttry:\n\t\t\t\tlowered[key.lower()] = value\n\t\t\texcept AttributeError:\n\t\t\t\t# Non-string header key -- ignore rather than blow up the handler.\n\t\t\t\tcontinue\n\t\tpresented = lowered.get(\'x-ignition-cli-secret\', \'\') or \'\'\n\t\tif presented:\n\t\t\treturn presented.strip()\n\t\tauth = lowered.get(\'authorization\', \'\') or \'\'\n\t\tif auth.startswith(\'Bearer \'):\n\t\t\treturn auth[7:].strip()\n\t\treturn \'\'\n\n\timport json, traceback, time, sys\n\tfrom StringIO import StringIO\n\tdata = request[\'data\']\n\tif isinstance(data, (str, unicode)): # Pitfall 3: parsed dict for JSON bodies, str/unicode only when malformed\n\t\tdata = json.loads(data)\n\taction = data.get(\'action\')\n\n\tdef ok(payload):\n\t\treturn {\'json\': {\'ok\': True, \'data\': payload}}\n\n\tdef err(code, message, tb=None):\n\t\te = {\'code\': code, \'message\': message}\n\t\tif tb:\n\t\t\te[\'traceback\'] = tb\n\t\treturn {\'json\': {\'ok\': False, \'error\': e}}\n\n\tdef jv(x, depth=0):\n\t\t# jsonEncode stack-overflows on Java objects; walk manually.\n\t\tif depth > 12:\n\t\t\treturn str(x)\n\t\tif x is None or isinstance(x, (bool, int, long, float)):\n\t\t\treturn x\n\t\tif isinstance(x, (str, unicode)):\n\t\t\treturn str(x)\n\t\tif isinstance(x, (list, tuple)):\n\t\t\treturn [jv(i, depth + 1) for i in x]\n\t\tif isinstance(x, dict):\n\t\t\tout = {}\n\t\t\tfor k in x.keys():\n\t\t\t\tout[str(k)] = jv(x.get(k), depth + 1)\n\t\t\treturn out\n\t\ttry:\n\t\t\tif hasattr(x, \'keySet\'):\n\t\t\t\tout = {}\n\t\t\t\tfor k in x.keySet():\n\t\t\t\t\tout[str(k)] = jv(x.get(k), depth + 1)\n\t\t\t\treturn out\n\t\texcept:\n\t\t\tpass\n\t\treturn str(x)\n\n\t# FAIL-CLOSED GATE -- before ANY action dispatch, version included.\n\tif _secretUnconfigured():\n\t\treturn err(\'secret_required\', \'scriptExec route secret is not configured -- (re)deploy this route via ign webdev deploy\')\n\tpresented = _extractSecret(request)\n\tif not presented:\n\t\treturn err(\'secret_required\', \'missing x-ignition-cli-secret (or Authorization: Bearer) header\')\n\tif not _constantTimeEquals(presented, str(SECRET)):\n\t\treturn err(\'secret_mismatch\', \'scriptExec secret mismatch\')\n\n\tlog = system.util.logger(\'ign-cli-scriptexec\')\n\n\ttry:\n\t\tif action == \'version\':\n\t\t\treturn ok({\'routeVersion\': ROUTE_VERSION, \'minCli\': MIN_CLI})\n\n\t\tif action == \'exec\':\n\t\t\tcode = data[\'code\']\n\t\t\tstarted = time.time()\n\t\t\tg = {}\n\t\t\tcaptured = StringIO()\n\t\t\toldStdout = sys.stdout\n\t\t\tresult = None\n\t\t\ttry:\n\t\t\t\tsys.stdout = captured\n\t\t\t\ttry:\n\t\t\t\t\tresult = eval(code, g) # single-expression code returns its value\n\t\t\t\texcept SyntaxError:\n\t\t\t\t\texec code in g # statement code (STATEMENT form: the exec(...) call form trips this Jython build at depth \u{2014} live-bisected 05-06)\n\t\t\t\t\tresult = g.get(\'_result\')\n\t\t\tfinally:\n\t\t\t\tsys.stdout = oldStdout\n\t\t\t\t# Audit pattern: log code-hash prefix + elapsed for EVERY invocation.\n\t\t\t\tlog.info(\'exec sha256=%s elapsedMs=%d\' % (_sha256Hex(code)[:12], int((time.time() - started) * 1000)))\n\t\t\treturn ok({\n\t\t\t\t\'stdout\': str(captured.getvalue()),\n\t\t\t\t\'result\': jv(result),\n\t\t\t\t\'elapsedMs\': int((time.time() - started) * 1000),\n\t\t\t})\n\n\t\treturn err(\'unknown_action\', \'unknown action: \' + str(action))\n\texcept:\n\t\t# Bare except -- catches Java Throwables too, keeping the body JSON (not a Jetty HTML 500).\n\t\treturn err(\'route_error\', \'scriptExec route error\', traceback.format_exc())\n";Expand description
The scriptExec route TEMPLATE — secret-gated arbitrary script execution.
Kept separate from ROUTE_FILES because deploy (05-03) must
substitute the __IGN_CLI_SECRET__ marker with the deploy-time hex
secret BEFORE packing this member: shipping the template unsubstituted
would arm the gate with a publicly-known placeholder value. The route
itself fail-closes on exactly that state, and this separation is the
structural guarantee it never happens.