From 169b6d32dee9bf22c3774aa187d077db57c40c12 Mon Sep 17 00:00:00 2001 From: Ilia Baranov <90713890+iliabaranov@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:10:01 -0700 Subject: [PATCH 1/4] chore(release): guard against credentials in published firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every sdkconfig.credentials value is compiled into .bin and .elf as a plain string. tools/release_guard.sh refuses artifacts that contain any such value or a secret-shaped string (tskey-…, PEM keys, literal auth tokens); CONTRIBUTING documents that only credential-free `-public` builds may be attached to a release. --- CONTRIBUTING.md | 19 ++++++++++++- tools/release_guard.sh | 64 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100755 tools/release_guard.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 650b121a..166e6340 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,24 @@ network tether once TinyUSB starts and the serial console goes quiet. Use adapter to the UART0 pins. Full first-time walkthrough: `docs/QUICKSTART.md`. `sdkconfig.credentials` holds secrets (Wi-Fi, Tailscale auth key, admin -password) and is gitignored — never commit it. +password) and is gitignored — never commit it. Every value in it is compiled +into the `.bin` **and** the `.elf` as a plain string, so a build made with a +credentials file present is private and must never be attached to a GitHub +release or shared outside the organisation. + +### Publishing release binaries + +Only builds made **without** a credentials file go on a release. Build them in a +clean checkout with `idf.py -DPROJECT_VER=-public build merge-bin` (the +`-public` suffix keeps `fw_ver` distinguishable from private builds of the same +commit), then run the guard on every artifact before `gh release upload`: + +```sh +tools/release_guard.sh firmware/build/pstop_remote.bin firmware/build/pstop_remote.elf ... +``` + +It refuses (exit 1) any file containing a credentials value or a +secret-shaped string (`tskey-…`, PEM keys, literal auth tokens). ### Host runner (robot-side machine) diff --git a/tools/release_guard.sh b/tools/release_guard.sh new file mode 100755 index 00000000..7de480a6 --- /dev/null +++ b/tools/release_guard.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Polymath Robotics +# SPDX-License-Identifier: Apache-2.0 +# +# release_guard.sh — refuse to publish firmware artifacts that carry secrets. +# +# Every value in the git-ignored sdkconfig.credentials (Tailscale auth key, +# WiFi credentials, admin password, OTA URL/API key, management-server IP) is +# compiled into BOTH the .bin and the .elf as a plain string. Only a build made +# WITHOUT a credentials file may ever be attached to a public release. +# +# tools/release_guard.sh ... +# +# Exit 0 = clean. Exit 1 = a secret or secret-shaped string was found (the +# offending pattern is named, the value is never printed). + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +[[ $# -ge 1 ]] || { echo "usage: $0 ..." >&2; exit 2; } + +fail=0 + +# Values that are already public (Kconfig defaults in the tracked tree, e.g. the +# documented default admin password) are not secrets and must not trip the guard. +public_defaults="$(grep -hoE 'default "[^"]+"' "$REPO"/components/microlink/Kconfig \ + "$REPO"/firmware/components/*/Kconfig "$REPO"/machn/main/Kconfig* 2>/dev/null | sed -E 's/^default "//; s/"$//' || true)" + +# 1. Literal values from every credentials file we can find (private build inputs). +declare -a values=() +for f in "$REPO"/firmware/sdkconfig.credentials "$REPO"/machn/sdkconfig.credentials; do + [[ -f "$f" ]] || continue + while IFS= read -r line; do + [[ "$line" =~ ^CONFIG_[A-Z0-9_]+=\"(.*)\"$ ]] || continue + v="${BASH_REMATCH[1]}" + # skip empty / boolean / short values that would match everywhere + [[ -n "$v" && "$v" != "y" && "$v" != "n" && ${#v} -ge 6 ]] || continue + grep -qxF -- "$v" <<< "$public_defaults" && continue + values+=("${line%%=*}=$v") + done < "$f" +done + +for art in "$@"; do + [[ -f "$art" ]] || { echo "MISSING $art" >&2; fail=1; continue; } + for kv in "${values[@]}"; do + k="${kv%%=*}"; v="${kv#*=}" + if grep -qF -- "$v" "$art"; then + echo "LEAK $art: value of $k present"; fail=1 + fi + done + # 2. Secret-shaped strings regardless of any credentials file. + if grep -qE -- 'tskey-(auth|client|api)-[A-Za-z0-9]+' "$art"; then + echo "LEAK $art: Tailscale key pattern present"; fail=1 + fi + if grep -qE -- '-----BEGIN [A-Z ]*PRIVATE KEY' "$art"; then + echo "LEAK $art: PEM private key present"; fail=1 + fi + if grep -qE -- '(^|[^A-Za-z])(Bearer|Basic) [A-Za-z0-9+/=_-]{16,}' "$art"; then + echo "LEAK $art: literal HTTP auth token present"; fail=1 + fi + [[ $fail -eq 0 ]] && echo "clean $art" +done + +exit $fail From 114b5a32fdd5aca7627f51a5028d305323dea783 Mon Sep 17 00:00:00 2001 From: Ilia Baranov <90713890+iliabaranov@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:17:55 -0700 Subject: [PATCH 2/4] release_guard: brand private builds; bash-3.2 safe; Kconfig unescape; per-file verdicts Review round: - images compiled with a credentials file carry ML-BUILD-WITH-CREDENTIALS (CMake compile definition + one boot log line in ml_app.c), so the guard refuses them on any machine, whatever credentials file it has or lacks - no empty-array expansion under set -u (bash 3.2) - Kconfig \" and \\ unescaped before matching; short values matched as whole NUL-terminated C strings instead of being skipped - PEM check requires a base64 body (bare header is an mbedTLS constant) - per-artifact clean/LEAK verdict; values de-duplicated --- CONTRIBUTING.md | 7 ++- components/microlink/src/ml_app.c | 8 ++++ firmware/CMakeLists.txt | 7 +++ machn/CMakeLists.txt | 7 +++ tools/release_guard.sh | 79 +++++++++++++++++++------------ 5 files changed, 76 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 166e6340..1b7c7b49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,8 +66,11 @@ commit), then run the guard on every artifact before `gh release upload`: tools/release_guard.sh firmware/build/pstop_remote.bin firmware/build/pstop_remote.elf ... ``` -It refuses (exit 1) any file containing a credentials value or a -secret-shaped string (`tskey-…`, PEM keys, literal auth tokens). +It refuses (exit 1) any file that carries the private-build brand (every +image compiled while a credentials file was present is branded +`ML-BUILD-WITH-CREDENTIALS`, so the verdict does not depend on which +credentials file the checking machine has), any value of a local credentials +file, or a secret-shaped string (`tskey-…`, PEM keys, literal auth tokens). ### Host runner (robot-side machine) diff --git a/components/microlink/src/ml_app.c b/components/microlink/src/ml_app.c index 7de763b4..61f52b71 100644 --- a/components/microlink/src/ml_app.c +++ b/components/microlink/src/ml_app.c @@ -1099,6 +1099,14 @@ ml_app_t * ml_app_start(const ml_app_config_t * cfg) ESP_ERROR_CHECK(ret); ESP_LOGI(TAG, "MicroLink App Framework starting..."); + /* Brand credentials-bearing images so tools/release_guard.sh can refuse them + * on any machine, whatever sdkconfig.credentials it does or doesn't have. The + * log reference keeps the string out of --gc-sections. */ +#ifdef ML_BUILD_WITH_CREDENTIALS + ESP_LOGI(TAG, "Build flavour: ML-BUILD-WITH-CREDENTIALS (private, not for release)"); +#else + ESP_LOGI(TAG, "Build flavour: public (no credentials compiled in)"); +#endif ESP_LOGI( TAG, "Free heap: %lu bytes (PSRAM: %lu bytes)", diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index a70d004d..8c942011 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -22,6 +22,13 @@ endif() include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(pstop_remote) +# A build made WITH a credentials file is private: brand the image (see +# ml_app.c) so tools/release_guard.sh refuses it even on a machine that has a +# different credentials file, or none. +if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials") + idf_build_set_property(COMPILE_DEFINITIONS "-DML_BUILD_WITH_CREDENTIALS=1" APPEND) +endif() + # Override lwIP's pbuf pool. IDF 5.5 doesn't expose PBUF_POOL_SIZE via # Kconfig (the upstream lwip Kconfig wiring is missing), so the only way # to grow it is to inject the macro into the lwip component's compile diff --git a/machn/CMakeLists.txt b/machn/CMakeLists.txt index 5ae60dcc..0aca4120 100644 --- a/machn/CMakeLists.txt +++ b/machn/CMakeLists.txt @@ -22,6 +22,13 @@ endif() include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(machn_machine) +# A build made WITH a credentials file is private: brand the image (see +# ml_app.c) so tools/release_guard.sh refuses it even on a machine that has a +# different credentials file, or none. +if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials") + idf_build_set_property(COMPILE_DEFINITIONS "-DML_BUILD_WITH_CREDENTIALS=1" APPEND) +endif() + # Identity prefix: machn-01 instead of pstop-01. idf_component_get_property(dcs_lib dcs_support COMPONENT_LIB) if(dcs_lib) diff --git a/tools/release_guard.sh b/tools/release_guard.sh index 7de480a6..41ea0904 100755 --- a/tools/release_guard.sh +++ b/tools/release_guard.sh @@ -11,54 +11,73 @@ # # tools/release_guard.sh ... # -# Exit 0 = clean. Exit 1 = a secret or secret-shaped string was found (the -# offending pattern is named, the value is never printed). +# Three independent layers, so the verdict does not depend on which +# credentials file happens to be on the machine running the guard: +# 1. build brand — any image compiled with a credentials file present carries +# the ML-BUILD-WITH-CREDENTIALS marker (CMakeLists + ml_app.c) +# 2. local values — every value of a credentials file found in the tree +# 3. secret shapes — tskey-…, PEM private keys, literal HTTP auth tokens +# +# Exit 0 = every artifact clean. Exit 1 = a leak (pattern named, value never +# printed). Exit 2 = usage. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" [[ $# -ge 1 ]] || { echo "usage: $0 ..." >&2; exit 2; } -fail=0 - # Values that are already public (Kconfig defaults in the tracked tree, e.g. the # documented default admin password) are not secrets and must not trip the guard. public_defaults="$(grep -hoE 'default "[^"]+"' "$REPO"/components/microlink/Kconfig \ - "$REPO"/firmware/components/*/Kconfig "$REPO"/machn/main/Kconfig* 2>/dev/null | sed -E 's/^default "//; s/"$//' || true)" + "$REPO"/firmware/components/*/Kconfig 2>/dev/null | sed -E 's/^default "//; s/"$//' || true)" + +# Kconfig writes strings with \" and \\ escaped; the compiler embeds the unescaped bytes. +kconfig_unescape() { printf '%s' "$1" | sed -E 's/\\(["\\])/\1/g'; } +# Escape a literal for grep -P. +re_escape() { printf '%s' "$1" | sed -E 's/[][\\.^$*+?(){}|/-]/\\&/g'; } -# 1. Literal values from every credentials file we can find (private build inputs). -declare -a values=() +# Layer 2 inputs: "KEYvalue" lines from every credentials file in the tree. +values="" for f in "$REPO"/firmware/sdkconfig.credentials "$REPO"/machn/sdkconfig.credentials; do [[ -f "$f" ]] || continue while IFS= read -r line; do - [[ "$line" =~ ^CONFIG_[A-Z0-9_]+=\"(.*)\"$ ]] || continue - v="${BASH_REMATCH[1]}" - # skip empty / boolean / short values that would match everywhere - [[ -n "$v" && "$v" != "y" && "$v" != "n" && ${#v} -ge 6 ]] || continue + [[ "$line" =~ ^(CONFIG_[A-Z0-9_]+)=\"(.*)\"$ ]] || continue + k="${BASH_REMATCH[1]}"; v="$(kconfig_unescape "${BASH_REMATCH[2]}")" + [[ -n "$v" && "$v" != "y" && "$v" != "n" ]] || continue grep -qxF -- "$v" <<< "$public_defaults" && continue - values+=("${line%%=*}=$v") + values+="$k"$'\t'"$v"$'\n' done < "$f" done +values="$(printf '%s' "$values" | sort -u)" +overall=0 for art in "$@"; do - [[ -f "$art" ]] || { echo "MISSING $art" >&2; fail=1; continue; } - for kv in "${values[@]}"; do - k="${kv%%=*}"; v="${kv#*=}" - if grep -qF -- "$v" "$art"; then - echo "LEAK $art: value of $k present"; fail=1 - fi - done - # 2. Secret-shaped strings regardless of any credentials file. - if grep -qE -- 'tskey-(auth|client|api)-[A-Za-z0-9]+' "$art"; then - echo "LEAK $art: Tailscale key pattern present"; fail=1 - fi - if grep -qE -- '-----BEGIN [A-Z ]*PRIVATE KEY' "$art"; then - echo "LEAK $art: PEM private key present"; fail=1 - fi - if grep -qE -- '(^|[^A-Za-z])(Bearer|Basic) [A-Za-z0-9+/=_-]{16,}' "$art"; then - echo "LEAK $art: literal HTTP auth token present"; fail=1 + if [[ ! -f "$art" ]]; then echo "MISSING $art" >&2; overall=1; continue; fi + leak=0 + + # 1. build brand + if grep -qF -- 'ML-BUILD-WITH-CREDENTIALS' "$art"; then + echo "LEAK $art: built with a credentials file (private build brand present)"; leak=1 fi - [[ $fail -eq 0 ]] && echo "clean $art" + + # 2. local credential values. Short values would match by accident anywhere, + # so they are required as a whole NUL-terminated C string. + while IFS=$'\t' read -r k v; do + [[ -n "$k" ]] || continue + if [[ ${#v} -ge 6 ]]; then + grep -qF -- "$v" "$art" && { echo "LEAK $art: value of $k present"; leak=1; } + else + grep -qaP -- "\x00$(re_escape "$v")\x00" "$art" && { echo "LEAK $art: value of $k present"; leak=1; } + fi + done <<< "$values" + + # 3. secret-shaped strings regardless of any credentials file + grep -qaE -- 'tskey-(auth|client|api)-[A-Za-z0-9]+' "$art" && { echo "LEAK $art: Tailscale key pattern present"; leak=1; } + # header followed by a base64 body — the bare header is an mbedTLS parser constant + grep -qazP -- '-----BEGIN [A-Z ]*PRIVATE KEY-----\s*[A-Za-z0-9+/=]{40,}' "$art" && { echo "LEAK $art: PEM private key present"; leak=1; } + grep -qaE -- '(^|[^A-Za-z])(Bearer|Basic) [A-Za-z0-9+/=_-]{16,}' "$art" && { echo "LEAK $art: literal HTTP auth token present"; leak=1; } + + if [[ $leak -eq 0 ]]; then echo "clean $art"; else overall=1; fi done -exit $fail +exit $overall From b24bc22ae84644cf7612142aa99c65cb364ee8f4 Mon Sep 17 00:00:00 2001 From: Ilia Baranov <90713890+iliabaranov@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:17:52 -0700 Subject: [PATCH 3/4] release_guard: portable grep (no -P/-z); brand definition before project() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - short values matched as whole NUL-delimited C strings via tr|grep -xF, PEM check via tr|grep -E — works with BSD grep (macOS) - idf_build_set_property(COMPILE_DEFINITIONS) placed between include(project.cmake) and project(); re-verified: private build carries the brand, credential-free build does not --- firmware/CMakeLists.txt | 3 ++- machn/CMakeLists.txt | 3 ++- tools/release_guard.sh | 10 ++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 8c942011..71c48924 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -20,7 +20,6 @@ if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials") endif() include($ENV{IDF_PATH}/tools/cmake/project.cmake) -project(pstop_remote) # A build made WITH a credentials file is private: brand the image (see # ml_app.c) so tools/release_guard.sh refuses it even on a machine that has a @@ -29,6 +28,8 @@ if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials") idf_build_set_property(COMPILE_DEFINITIONS "-DML_BUILD_WITH_CREDENTIALS=1" APPEND) endif() +project(pstop_remote) + # Override lwIP's pbuf pool. IDF 5.5 doesn't expose PBUF_POOL_SIZE via # Kconfig (the upstream lwip Kconfig wiring is missing), so the only way # to grow it is to inject the macro into the lwip component's compile diff --git a/machn/CMakeLists.txt b/machn/CMakeLists.txt index 0aca4120..a45c01ae 100644 --- a/machn/CMakeLists.txt +++ b/machn/CMakeLists.txt @@ -20,7 +20,6 @@ if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials") endif() include($ENV{IDF_PATH}/tools/cmake/project.cmake) -project(machn_machine) # A build made WITH a credentials file is private: brand the image (see # ml_app.c) so tools/release_guard.sh refuses it even on a machine that has a @@ -29,6 +28,8 @@ if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials") idf_build_set_property(COMPILE_DEFINITIONS "-DML_BUILD_WITH_CREDENTIALS=1" APPEND) endif() +project(machn_machine) + # Identity prefix: machn-01 instead of pstop-01. idf_component_get_property(dcs_lib dcs_support COMPONENT_LIB) if(dcs_lib) diff --git a/tools/release_guard.sh b/tools/release_guard.sh index 41ea0904..5be837b5 100755 --- a/tools/release_guard.sh +++ b/tools/release_guard.sh @@ -33,8 +33,10 @@ public_defaults="$(grep -hoE 'default "[^"]+"' "$REPO"/components/microlink/Kcon # Kconfig writes strings with \" and \\ escaped; the compiler embeds the unescaped bytes. kconfig_unescape() { printf '%s' "$1" | sed -E 's/\\(["\\])/\1/g'; } -# Escape a literal for grep -P. -re_escape() { printf '%s' "$1" | sed -E 's/[][\\.^$*+?(){}|/-]/\\&/g'; } +# Whole-C-string view of an artifact: every NUL becomes a newline, so a +# NUL-terminated string literal is exactly one line. Portable — needs no +# grep -P / -z (absent from BSD grep). +cstrings() { tr '\0' '\n' < "$1"; } # Layer 2 inputs: "KEYvalue" lines from every credentials file in the tree. values="" @@ -67,14 +69,14 @@ for art in "$@"; do if [[ ${#v} -ge 6 ]]; then grep -qF -- "$v" "$art" && { echo "LEAK $art: value of $k present"; leak=1; } else - grep -qaP -- "\x00$(re_escape "$v")\x00" "$art" && { echo "LEAK $art: value of $k present"; leak=1; } + cstrings "$art" | grep -qaxF -- "$v" && { echo "LEAK $art: value of $k present"; leak=1; } fi done <<< "$values" # 3. secret-shaped strings regardless of any credentials file grep -qaE -- 'tskey-(auth|client|api)-[A-Za-z0-9]+' "$art" && { echo "LEAK $art: Tailscale key pattern present"; leak=1; } # header followed by a base64 body — the bare header is an mbedTLS parser constant - grep -qazP -- '-----BEGIN [A-Z ]*PRIVATE KEY-----\s*[A-Za-z0-9+/=]{40,}' "$art" && { echo "LEAK $art: PEM private key present"; leak=1; } + tr '\n\r\0' ' ' < "$art" | grep -qaE -- '-----BEGIN [A-Z ]*PRIVATE KEY----- *[A-Za-z0-9+/=]{40,}' && { echo "LEAK $art: PEM private key present"; leak=1; } grep -qaE -- '(^|[^A-Za-z])(Bearer|Basic) [A-Za-z0-9+/=_-]{16,}' "$art" && { echo "LEAK $art: literal HTTP auth token present"; leak=1; } if [[ $leak -eq 0 ]]; then echo "clean $art"; else overall=1; fi From 689546e0ad6b0e920226d4b54fa9e940c54dc2bd Mon Sep 17 00:00:00 2001 From: Ilia Baranov <90713890+iliabaranov@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:15:49 -0700 Subject: [PATCH 4/4] release_guard: brand lives in a used object, not only a log string An ESP_LOGI format string is compiled out below LOG_LOCAL_LEVEL, which would silently remove the private-build brand and defeat the guard's machine-independent layer (review). The brand is now a __attribute__((used)) const object, referenced by an ESP_LOGW. --- components/microlink/src/ml_app.c | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/components/microlink/src/ml_app.c b/components/microlink/src/ml_app.c index 61f52b71..b0f7b2bd 100644 --- a/components/microlink/src/ml_app.c +++ b/components/microlink/src/ml_app.c @@ -1100,12 +1100,16 @@ ml_app_t * ml_app_start(const ml_app_config_t * cfg) ESP_LOGI(TAG, "MicroLink App Framework starting..."); /* Brand credentials-bearing images so tools/release_guard.sh can refuse them - * on any machine, whatever sdkconfig.credentials it does or doesn't have. The - * log reference keeps the string out of --gc-sections. */ + * on any machine, whatever sdkconfig.credentials it does or doesn't have. + * The brand lives in a `used` object, not only in a log format string: an + * ESP_LOGI is compiled out below LOG_LOCAL_LEVEL and would silently defeat + * the guard (review). */ #ifdef ML_BUILD_WITH_CREDENTIALS - ESP_LOGI(TAG, "Build flavour: ML-BUILD-WITH-CREDENTIALS (private, not for release)"); + static const char s_build_flavour[] __attribute__((used)) = "ML-BUILD-WITH-CREDENTIALS"; + ESP_LOGW(TAG, "Build flavour: %s (private, not for release)", s_build_flavour); #else - ESP_LOGI(TAG, "Build flavour: public (no credentials compiled in)"); + static const char s_build_flavour[] __attribute__((used)) = "ML-BUILD-PUBLIC"; + ESP_LOGW(TAG, "Build flavour: %s (no credentials compiled in)", s_build_flavour); #endif ESP_LOGI( TAG,