Skip to content

chore(release): guard against credentials in published firmware - #148

Merged
iliabaranov merged 4 commits into
mainfrom
chore/release-guard
Sep 20, 2026
Merged

iliabaranov merged 4 commits into
mainfrom
chore/release-guard

Conversation

@iliabaranov

@iliabaranov iliabaranov commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Why

Every value in the git-ignored sdkconfig.credentials is compiled into both the .bin and the .elf as a plain string. Nothing in the repo prevented such a build from being attached to a GitHub release.

What

  • tools/release_guard.sh <artifact>… — exit 1 if any artifact (a) carries the private-build brand, (b) contains a value of a local credentials file (public Kconfig defaults skipped; Kconfig escapes undone; short values matched as whole C strings), or (c) contains a secret-shaped string (tskey-…, PEM key with body, literal Bearer/Basic tokens). Never prints the value, only which key leaked.
  • Build brand: when sdkconfig.credentials is present, CMake defines ML_BUILD_WITH_CREDENTIALS and ml_app.c embeds ML-BUILD-WITH-CREDENTIALS via a boot log line — so the verdict does not depend on which credentials file the checking machine has.
  • CONTRIBUTING: publishing rule — release binaries are built without a credentials file, versioned <tag>-public, and must pass the guard before upload.

Verification

  • Guard on the credential-free v1.3-public artifacts now attached to the v1.3 release: clean ×6 (bins, full-flash images, ELFs), re-verified on the copies downloaded back from GitHub.
  • Guard on a private build: flags every credential key plus the tskey- pattern.

Review round in 114b5a3 (bash 3.2 empty-array, Kconfig unescape, short values, per-file verdicts, brand). 107+/33− over 5 files.

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.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds tools/release_guard.sh, a scanner that refuses (exit 1) to bless a firmware artifact carrying secrets: it extracts every value from any sdkconfig.credentials found in firmware/ or machn/ and greps each .bin/.elf for it, plus flags secret-shaped strings (Tailscale tskey-… keys, PEM private keys, literal Bearer/Basic tokens) even when no credentials file is present, naming the offending pattern without printing the value. Values matching tracked Kconfig default strings (e.g. the documented admin password) are treated as public and won't trip the guard, and empty/boolean/short values are skipped to avoid false positives. Documents the release process in CONTRIBUTING.md: build without credentials using a -public-suffixed PROJECT_VER, then run the guard on every artifact before gh release upload.

Worth a look

  • Empty values array under set -u causes unbound-variable crashtools/release_guard.sh:46 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Short credential values are silently exempted from the release guardtools/release_guard.sh:37 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • 'clean' printed per-artifact using global fail flagtools/release_guard.sh:61 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 14 functions depend on the 14 functions this change touches.

Health — grade A; no new coupling hotspots.

Verification — 14 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 14 function(s) in the blast radius were not formally verified this run

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread tools/release_guard.sh Outdated
Comment thread tools/release_guard.sh Outdated
Comment thread tools/release_guard.sh Outdated
Comment thread tools/release_guard.sh Outdated
Comment thread tools/release_guard.sh Outdated
… 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

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds tools/release_guard.sh, a pre-upload check that refuses any firmware artifact carrying secrets, scanning each .bin/.elf for the private-build brand, the actual values from any sdkconfig.credentials in the tree (short ones matched only as whole NUL-delimited C strings), and secret-shaped patterns like tskey-…, PEM keys, and Bearer/Basic tokens, while whitelisting public Kconfig defaults; exits 1 on any leak with the pattern named but the value never printed. Brands credentials-bearing builds so the guard works on any machine: both firmware and machn CMake now define ML_BUILD_WITH_CREDENTIALS when sdkconfig.credentials exists, and ml_app_start logs the ML-BUILD-WITH-CREDENTIALS marker (referenced to survive --gc-sections) so the verdict doesn't depend on which credentials file the checking machine holds. Documents the release-publishing workflow in CONTRIBUTING.md: build without credentials using a -public version suffix, run the guard on every artifact, and never attach a credentials-bearing build to a release.

Worth a look

  • Firmware credentials brand definition is set after ESP-IDF processes componentsfirmware/CMakeLists.txt:29 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Short credential detection path uses a malformed sed regextools/release_guard.sh:38 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • PEM detection requires base64 body immediately after header, missing keys with newline/whitespace-only wrapping represented differently in binarytools/release_guard.sh:72 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 60 functions depend on the 46 functions this change touches.

Health — this change adds coupling hotspots:

  • new: dcs_support_init() — 2 callers, 30 callees
  • new: ml_app_start() — 1 callers, 11 callees
  • new: fleet_ota_checkin() — 1 callers, 8 callees
  • new: supervisor_task() — 0 callers, 8 callees

Verification — 60 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 60 function(s) in the blast radius were not formally verified this run

· 4 more finding(s) on lines outside this diff (see the check run).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the new commit (which addresses the five issues from my earlier review) and the automated hunt found no new bugs this run. Because tools/release_guard.sh is the last line of defense against leaking credentials in a public firmware release, a human look is still worthwhile.

What was reviewed: the build-brand plumbing (firmware/CMakeLists.txt, machn/CMakeLists.txt, ml_app.c) now genuinely defines and logs ML_BUILD_WITH_CREDENTIALS, so layer 1 of the guard is no longer dead code; a candidate where a secret matching a different key's Kconfig default silently passes was investigated and judged pre-existing/low-risk; a while read last-line-without-newline gap was also investigated and ruled out. One thing worth a human's eyes: layers 2 (short-value match) and 3 (PEM detection) now rely on grep -P/-z, which stock BSD grep (e.g. macOS's default /usr/bin/grep) doesn't support — worth confirming the guard is actually invoked where GNU grep is available.

Extended reasoning...

Overview

This diff is the fix-up commit (114b5a3) on top of the original release-guard PR (169b6d3). It reworks tools/release_guard.sh to (a) actually define/consume the ML_BUILD_WITH_CREDENTIALS build brand in firmware/CMakeLists.txt, machn/CMakeLists.txt, and components/microlink/src/ml_app.c, (b) switch the credentials-values collection from a bash array to a newline-delimited string to avoid set -u/bash<4.4 unbound-array issues, (c) unescape Kconfig \"/\\ sequences before substring-matching against the artifact, and (d) reset the per-artifact leak flag so batch runs report an accurate clean/LEAK verdict per file instead of latching failure across the whole run.

Security risks

The change is squarely about preventing secret leakage in shipped firmware, so correctness of the detection logic is the whole point. I re-verified, by reading the diff directly, that each of my four prior inline findings (dead build-brand, bash-3.2 unbound array, missing Kconfig unescape, sticky per-file fail flag) is addressed by concrete code changes in this commit, and the fifth (short values silently skipped) is now handled via a NUL-delimited match instead of being dropped. I did not find a way to independently re-break any of those four. The one new observation I have — grep -P and grep -z are GNU extensions not present in BSD/macOS grep — is a portability risk that could silently degrade layers 2/3 of the guard (via the A && B short-circuit not tripping set -e) rather than a logic bug in the matching itself, and I have not confirmed what OS the release process actually runs on.

Level of scrutiny

This remains a security-sensitive, non-trivial bash script that is the sole automated gate before a firmware image reaches a public GitHub release, and a prior review round on this exact PR surfaced multiple real correctness issues. Given that history, a human review of the fix-up commit is warranted even though this run's automated hunt came back clean, rather than approving outright.

Other factors

No CODEOWNERS/ANTOWNERS restrict these paths, and there's no CI wiring that runs release_guard.sh automatically (it's a manual pre-upload step per CONTRIBUTING.md), so there is no automated test coverage forcing platform parity — reinforcing that a human check on the actual release-runner environment is worthwhile.

…ect()

- 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
@iliabaranov

iliabaranov commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 (b24bc22):

  • Portability (BSD grep): no grep -P/-z left. Short credential values are matched as whole NUL-delimited C strings via tr '\0' '\n' | grep -xF; the PEM check flattens newlines/NULs with tr and uses grep -E. Re-verified: a 5-char value is caught when it is a standalone C string and ignored when embedded in other text; synthetic PEM caught.
  • Brand definition placement: idf_build_set_property(COMPILE_DEFINITIONS …) now sits between include(project.cmake) and project(). It already took effect after project() (the brand was present in the private image), but this is the unambiguous spot. Rebuilt both flavours: private image contains ML-BUILD-WITH-CREDENTIALS, -public image does not; guard verdicts unchanged (public clean, private LEAK).
  • re_escape is gone (the portable path needs no regex escaping).

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds tools/release_guard.sh, which refuses (exit 1) any firmware artifact that would leak secrets, checking three independent layers: the ML-BUILD-WITH-CREDENTIALS build brand, every value found in any sdkconfig.credentials in the tree (skipping public Kconfig defaults, matching short values only as whole C strings), and secret-shaped strings like Tailscale keys, PEM private keys, and literal HTTP auth tokens — never printing the offending value. Brands credentials-bearing builds by defining ML_BUILD_WITH_CREDENTIALS in both firmware and machn CMake whenever a credentials file is present, and logging ML-BUILD-WITH-CREDENTIALS from ml_app_start so the marker survives --gc-sections and the guard's verdict doesn't depend on which credentials file the checking machine happens to have. Documents the release workflow in CONTRIBUTING.md: build public artifacts in a clean checkout with a -public version suffix and run the guard on every file before gh release upload.

Worth a look

  • Lowercase HTTP auth schemes bypass secret scantools/release_guard.sh:80 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Private-build marker can be compiled out with INFO logscomponents/microlink/src/ml_app.c:1105 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 60 functions depend on the 46 functions this change touches.

Health — this change adds coupling hotspots:

  • new: dcs_support_init() — 2 callers, 30 callees
  • new: ml_app_start() — 1 callers, 11 callees
  • new: fleet_ota_checkin() — 1 callers, 8 callees
  • new: supervisor_task() — 0 callers, 8 callees

Verification — 60 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 60 function(s) in the blast radius were not formally verified this run

· 4 more finding(s) on lines outside this diff (see the check run).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread components/microlink/src/ml_app.c
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.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

Adds a release guard that refuses to publish firmware artifacts carrying secrets: tools/release_guard.sh scans each .bin/.elf for a private-build brand, any value from an in-tree sdkconfig.credentials (short values matched only as a whole NUL-terminated C string, Kconfig-unescaped, public Kconfig defaults excluded), and secret-shaped strings (tskey-…, PEM keys, HTTP auth tokens), exiting 1 on any leak without printing the value. Brands every credentials-bearing build so the verdict is machine-independent: the firmware and machn CMakeLists.txt define ML_BUILD_WITH_CREDENTIALS when a credentials file is present, and ml_app_start emits the ML-BUILD-WITH-CREDENTIALS / ML-BUILD-PUBLIC marker into a used static (not just a log format string, so it survives log-level stripping). Documents the publishing workflow in CONTRIBUTING.md — build public releases in a clean checkout with a -public version suffix and run the guard on every artifact before uploading.

No blocking issues surfaced. 15 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 60 functions depend on the 46 functions this change touches.

Health — this change adds coupling hotspots:

  • new: dcs_support_init() — 2 callers, 30 callees
  • new: ml_app_start() — 1 callers, 11 callees
  • new: fleet_ota_checkin() — 1 callers, 8 callees
  • new: supervisor_task() — 0 callers, 8 callees

Verification — 60 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 60 function(s) in the blast radius were not formally verified this run

· 4 more finding(s) on lines outside this diff (see the check run).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest commits and didn't find any new bugs — the human look is still worthwhile given this is security-sensitive release-safety tooling that has already needed several rounds of correctness fixes.

What was reviewed:

  • The five issues raised in earlier rounds (bash 3.2 unbound-array crash on an empty values, missing Kconfig \"/\\ unescaping, short (<6 char) credential values being skipped, per-artifact "clean" reporting being lost after the first leak, and the private-build brand being emitted only via a log call that's compiled out below a given log level) all appear fixed in tools/release_guard.sh and components/microlink/src/ml_app.c.
  • Checked the public-defaults allowlist (tools/release_guard.sh) matching by bare value rather than by which CONFIG key it came from — this is a pre-existing, already-flagged risk rather than a new one.
  • Checked the short-value NUL-exact-match path against GNU ld string-merging/suffix concerns — the whole-line match against NUL-split strings avoids the accidental-substring case.
Extended reasoning...

Overview

This run's diff (9d3b0e4..HEAD) is unchanged in scope from prior reviews: it adds tools/release_guard.sh, wires ML_BUILD_WITH_CREDENTIALS into firmware/CMakeLists.txt and machn/CMakeLists.txt, embeds a build-brand string in components/microlink/src/ml_app.c, and documents the publishing workflow in CONTRIBUTING.md. Since my last review (which flagged the brand string being emitted only through an ESP_LOGI/ESP_LOGW call that can be compiled out below a configured log level), one new commit (689546e) landed that moves the brand into a static const char[] __attribute__((used)) object referenced by, but independent of, the log call — directly addressing that finding. The two prior commits (114b5a3, b24bc22) had already addressed the bash 3.2 unbound-array issue (values is now a plain string, not an array, so no ${values[@]} expansion under set -u), the Kconfig escape handling (kconfig_unescape), the short-value skip (now matched via NUL-exact cstrings comparison instead of being dropped), and the per-artifact clean/leak reporting (each loop iteration now resets a local leak flag rather than relying on a single sticky fail).

Security risks

This is exactly the kind of code where injection/exposure risks matter: a bash script that greps binaries for secret material and gates a publish step. The residual risk I can see (the public-defaults allowlist matching by bare value across any CONFIG key, not the specific key the secret came from) is a pre-existing, already-raised concern rather than something new, and is a narrow theoretical case requiring a real secret to collide byte-for-byte with an unrelated public default.

Level of scrutiny

Given the security-sensitive purpose (preventing credential leakage into public releases) and the history of needing three rounds of substantive fixes to get the bash correctness right, this warrants continued human attention even though this run found no new issues — subtle shell/quoting/linker-behavior bugs in this class of script are easy to miss and have already surfaced multiple times in this same PR.

Other factors

No CLAUDE.md applies. The PR's own verification notes (guard run against real v1.3 public/private artifacts) provide some confidence, but I have not independently built firmware to confirm the __attribute__((used)) object survives --gc-sections under every log-level configuration, so I'm not treating that as fully proven — hence deferring rather than approving.

iliabaranov added a commit that referenced this pull request Sep 20, 2026
…rd re-establishment, coord deadline (#156)

Consolidates #149, #152, #151, #155 and #153 (closed as superseded;
their review threads and bot findings were addressed there and the fixes
are included here). 12 linear commits, 409+/29−. Every commit was in the
build that passed the 3.7 h soak below.

## What is fixed
| area | commit(s) | defect |
|---|---|---|
| USB tether TX | `de335e8` `1b5fe82` | `tinyusb_net_send_sync(…, 0)`
reclaimed the packet before the equal-priority TinyUSB task ran the
deferred send — ~95 % of device→host frames silently dropped (regression
from #135). Now a 20 ms wait; TIMEOUT-after-send duplicate accepted and
documented. |
| USB tether ring | `3a8a527` | 16-slot ring lost ~1 safety frame/min on
a hub-chain host (burst-induced). 64 slots (96 KB PSRAM), 400 ms
lifetime. |
| Tether link state | `f9ec4cb` | netif never went link-down when the
host detached/suspended, so a dead tether stayed the preferred uplink.
esp_tinyusb events now drive it; device netif gets its own MAC. |
| WiFi failover crash | `9823785` | first time the failover path ever
ran it aborted: `dcs_wifi_set_enabled` reaches flash from the
supervisor's PSRAM stack. Toggle moved to an internal-stack worker. |
| WireGuard re-establishment | `0440ad2` `5b86d21` `e020c86` `cae7e94`
`54149eb` `d7a335f` | **root cause of the multi-minute/indefinite bond
drops**: on direct regain with no keypair left, the live endpoint stayed
0.0.0.0 (relay-only) and a safety peer fell into the bulk-peer
"one-shot, wait for them to initiate" — the machine host's wireguard-go
never initiates without data. Safety peers now connect with paced
retries; initiations get a second leg to the peer's authenticated direct
disco source while DERP-only; disco pings fan out to it. Per-peer WG
diagnostics in `/admin/api/peers`. |
| Coord partial frame | `8cf5104` | `coord_recv` bounded by a retry
count × 2 s socket timeout (≈10 min); now a 10 s wall-clock deadline,
`ETIMEDOUT` so callers reconnect (#127). 88-check host test. |

## Evidence
- Deterministic WG repro (block direct+DERP, reboot remote mid-outage,
unblock direct only, no host help): **v1.3-14 never recovers (>300 s);
this branch bonds in 22 s.**
- Soak 2026-09-19 12:06–15:47 PDT, 3.7 h, this code + #148/#150/#154,
two Ethernet remotes + one USB remote bonded to a ROS 2 machine on the
tether host, DUT E-stop cycled every 5 min (42/42, stop ≤0.5 s, re-arm
≤0.75 s): **zero bond drops / rebonds / resets, 100 % bonded samples on
all three** (12 638 × 1 Hz each); reply age p99.9 361–391 ms; USB ring
566 916 frames, 0 expired, 0 dropped; machine-side 194 207 requests, 194
170 answered (the rest late DERP-mirror copies, correctly rejected). One
1.07 s receive-side excursion on an Ethernet remote (W5500 RX class,
tracked separately).
- Baseline for the same class of USB remote on v1.3: 252 ring drops + 55
expiries per 4 h, and 2–7 min bond outages.

## Not in this PR
#148 (release guard), #150 (name scrub), #154 (lockstep mismatch
diagnostics — SIL comparator, reviewed separately). DERP-mirror latency
(1.3 s median) and the W5500 RX stall are follow-ups.
@iliabaranov
iliabaranov merged commit 1ee034a into main Sep 20, 2026
12 checks passed
@iliabaranov
iliabaranov deleted the chore/release-guard branch September 20, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants