ci: publish firmware to GCS on release tags#14
Conversation
Add a `publish` job to the firmware build workflow so tagged releases upload each variant's merged .bin to the wendyos-images-public bucket and update manifests/<chip>.json + manifests/master.json — the manifests that `wendy os install` reads. This mirrors the OS-image publish flow in wendylabsinc/meta-wendyos-jetson by reusing its tools/publisher CLI. Without this, published firmware drifts from main: the manifest stayed at 1.0.0 (pre-`wendy_conf`), so `wendy os install` downloaded a build whose flash image lacked the wendy_conf partition the CLI injects WiFi/enrollment config into, and aborted. The chip key is the build variant name (esp32c5/esp32c6/esp32p4_*), matching what the CLI queries. Publishing runs sequentially so master.json writes don't race. Requires repo secrets: GCP_WORKLOAD_IDENTITY_PROVIDER, GCP_SERVICE_ACCOUNT (GCS write via workload-identity federation) and PUBLISHER_REPO_TOKEN (read access to the builder repo for the publisher source). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI Security Review
publish job that uploads compiled firmware binaries to a public GCS bucket and updates manifest JSON files on every v* tag. The overall design is sound — it uses workload-identity federation (no long-lived GCP keys), scopes permissions narrowly, and serialises manifest writes to avoid race conditions. However, several meaningful risks exist: the access token is exposed as a CLI argument (visible in process listings and logs), the publisher binary is built from a third-party private repository with no integrity verification (no checksum or pinned commit SHA), the v* tag trigger is insufficiently restricted and could be gamed by any repo member with tag-push rights, and the binary artifacts are uploaded to a bucket explicitly named "public" without any signing or integrity metadata — meaning a compromised runner or MITM could push malicious firmware to devices. These issues range from HIGH to LOW severity and should be addressed before merging. |
| Severity | Standards | File | Line(s) | Title |
|---|---|---|---|---|
| HIGH | SOC2-CC6, ISO27001-A.8, NIST-SC-12 | .github/workflows/build.yml |
~155 | Access token passed as CLI argument (process-listing / log exposure) |
| HIGH | SOC2-CC8, SOC2-CC9, ISO27001-A.8, NIST-SI-7 | .github/workflows/build.yml |
~134–138 | Publisher binary built from unpinned third-party repo — no integrity check |
| HIGH | SOC2-CC6, ISO27001-A.9 | .github/workflows/build.yml |
~109 | Any repo member with tag-push rights can trigger a firmware publish |
| MEDIUM | SOC2-CC8, ISO27001-A.8, NIST-SI-7 | .github/workflows/build.yml |
~157–165 | Published firmware has no cryptographic signature or checksum manifest |
| MEDIUM | SOC2-CC7, ISO27001-A.12 | .github/workflows/build.yml |
~152–166 | No publish-failure alerting; silent drift possible if job fails after partial upload |
| LOW | SOC2-CC9, ISO27001-A.8 | .github/workflows/build.yml |
~120–126 | Third-party GitHub Actions pinned by mutable tag, not commit SHA |
| LOW | SOC2-CC6 | .github/workflows/build.yml |
~109 | permissions block grants id-token: write to the entire job, not just the auth step |
| INFORMATIONAL | SOC2-CC8 | .github/workflows/build.yml |
~163 | --notify-discord=false leaves no release notification path; no audit trail of publishes |
Detailed Findings
FINDING 1 — HIGH: Access token passed as CLI argument
Standards: [SOC2-CC6] [ISO27001-A.8] [NIST-SC-12]
Description:
The GCP access token is retrieved via gcloud auth print-access-token and stored in a shell variable, then passed directly to the publisher binary as --access-token "$TOKEN". In Linux, all arguments to a process are visible in /proc/<pid>/cmdline to any other process running as the same user for the lifetime of the process. GitHub Actions also logs process invocations when run in debug mode (ACTIONS_STEP_DEBUG=true), and many third-party log-shipping integrations capture command lines. The token has a 1-hour lifetime but that is more than sufficient for exploitation on a shared runner.
Relevant snippet:
TOKEN="$(gcloud auth print-access-token)"
...
"$RUNNER_TEMP/publisher" \
--firmware \
--chip "$variant" \
--version "$VERSION" \
--file "$bin" \
--notify-discord=false \
--access-token "$TOKEN" </dev/nullRemediation:
Pass the token via an environment variable or a file in $RUNNER_TEMP with mode 0600, not as an argument. For example:
TOKEN="$(gcloud auth print-access-token)"
export PUBLISHER_ACCESS_TOKEN="$TOKEN"
"$RUNNER_TEMP/publisher" \
--firmware \
--chip "$variant" \
--version "$VERSION" \
--file "$bin" \
--notify-discord=false
# publisher reads PUBLISHER_ACCESS_TOKEN from envAlternatively, the publisher tool should be updated to accept --access-token-file pointing to a file written with install -m 0600.
Controls violated: ISO 27001 A.8.7 (protection against malware / secret leakage), NIST SC-12 (cryptographic key establishment and management), SOC 2 CC6.1 (logical access controls).
FINDING 2 — HIGH: Publisher binary built from unpinned third-party repo — no integrity check
Standards: [SOC2-CC8] [SOC2-CC9] [ISO27001-A.8] [NIST-SI-7]
Description:
The publisher source is checked out from wendylabsinc/meta-wendyos-jetson at the default branch HEAD (no ref: pin in the checkout step). This means a compromise of that repository, or a force-push to its default branch, would cause arbitrary Go code to be compiled and run with GCP credentials capable of writing to the public firmware bucket. Because the bucket is "public" and used by wendy os install on devices, a supply-chain compromise here could result in malicious firmware being served to all users. There is also no checksum verification of the compiled binary before execution.
Relevant snippet:
- name: Check out publisher tool
uses: actions/checkout@v4
with:
repository: wendylabsinc/meta-wendyos-jetson
path: builder
token: ${{ secrets.PUBLISHER_REPO_TOKEN }}Remediation:
- Pin the checkout to a specific, reviewed commit SHA:
ref: 'a1b2c3d4e5f6...' # commit SHA of last reviewed version
- Add a separate reviewed-and-tagged release mechanism for the publisher tool (e.g., publish it as a versioned release artifact with a published SHA256, download it, and verify before execution).
- Consider vendoring the publisher into this repository entirely, removing the cross-repo dependency.
Controls violated: SOC 2 CC8.1 (change management, risky changes), CC9.2 (third-party risk), ISO 27001 A.8.8 (management of technical vulnerabilities), NIST SI-7 (software and information integrity).
FINDING 3 — HIGH: Any repo member with tag-push rights can trigger a firmware publish
Standards: [SOC2-CC6] [ISO27001-A.9]
Description:
The job triggers on any refs/tags/v* push. In most GitHub configurations, any repository collaborator with write access can push a tag. There is no requirement that the tag correspond to a reviewed and merged commit on main, nor that it be a protected tag. This means:
- A contributor could push
v9.9.9pointing at an unreviewed commit and cause that commit's built firmware to be published to production. - The
buildjob's artifacts are from the same tag's code, so the publish is of whatever was just built — no additional gate exists.
Relevant snippet:
if: startsWith(github.ref, 'refs/tags/v')Remediation:
- Enable tag protection rules (or rulesets in newer GitHub) so only admins/specific roles can push
v*tags. - Add a required check that the tagged commit exists on the
mainbranch (can be enforced via a ruleset or a job step that runsgit merge-base --is-ancestor). - Consider requiring a manual approval step (
environment: productionwith required reviewers) before the publish job runs.
Controls violated: SOC 2 CC6.3 (least-privilege, role-based access), ISO 27001 A.9.2 (user access management), A.9.4 (system and application access control).
FINDING 4 — MEDIUM: Published firmware has no cryptographic signature or checksum manifest
Standards: [SOC2-CC8] [ISO27001-A.8] [NIST-SI-7]
Description:
The workflow uploads raw .bin firmware files to a public GCS bucket. The manifest JSON is updated with version and (presumably) a URL, but there is no indication that a SHA256 (or stronger) digest of the binary is written to the manifest, nor that the binary is signed with a firmware signing key. wendy os install downloads and flashes this binary to hardware. If the GCS bucket ACLs are ever misconfigured, the publisher credentials are stolen, or the manifest is tampered with, a device could flash unsigned/unverified firmware. Even without malicious intent, silent corruption during upload would be undetectable at install time.
Relevant snippet:
"$RUNNER_TEMP/publisher" \
--firmware \
--chip "$variant" \
--version "$VERSION" \
--file "$bin" \(No --sha256 or --sign flag visible.)
Remediation:
- Compute and record a SHA256 of each
.binbefore upload; include it in the manifest JSON. - Have
wendy os installverify the digest before flashing. - Evaluate code-signing for firmware images (e.g., with a Hardware Security Module-backed key or at minimum a repository secret that is not the same credential used for GCS write).
Controls violated: SOC 2 CC8.1 (change management integrity), ISO 27001 A.8.20 (networks security) / A.8.21 (security of network services), NIST SI-7 (software and information integrity).
FINDING 5 — MEDIUM: No publish-failure alerting; silent drift possible if job fails after partial upload
Standards: [SOC2-CC7] [ISO27001-A.12]
Description:
The loop publishes four variants sequentially. If it succeeds for esp32c5 and esp32c6 but fails for esp32p4_waveshare_lcd_4b, the manifest may be in a partially-updated state (some chips updated, others not). set -euo pipefail will abort the script but GitHub Actions will only mark the job failed — there is no rollback of the already-published variants, no notification (Discord is explicitly disabled), and no compensating transaction to restore the previous manifest state. Operators may not notice a partially-drifted manifest until a device fails to install.
Remediation:
- Implement a rollback step (on-failure) that restores the previous
master.jsonfrom GCS versioning or a known-good backup. - Enable GCS object versioning on the bucket so previous manifest versions are recoverable.
- Wire at least a failure notification (GitHub Actions
on: failurenotification, or a minimal Slack/Discord webhook on thepublishjob's failure path). - Consider using a two-phase publish (upload binary first, update manifest atomically only after all binaries succeed).
Controls violated: SOC 2 CC7.3 (anomaly detection and monitoring), ISO 27001 A.12.4 (logging and monitoring).
FINDING 6 — LOW: Third-party Actions pinned by mutable tag, not commit SHA
Standards: [SOC2-CC9] [ISO27001-A.8]
Description:
All third-party Actions (google-github-actions/auth@v2, google-github-actions/setup-gcloud@v2, actions/checkout@v4, actions/setup-go@v5, actions/download-artifact@v4) are pinned to mutable version tags. A compromised or malicious update to any of these at the v2/v4/v5 tag would execute in the workflow context, which has id-token: write and access to secrets including PUBLISHER_REPO_TOKEN.
Relevant snippet:
uses: google-github-actions/auth@v2
uses: google-github-actions/setup-gcloud@v2
uses: actions/checkout@v4
uses: actions/setup-go@v5
uses: actions/download-artifact@v4Remediation:
Pin each Action to its full commit SHA, with the tag noted in a comment:
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8Use a tool like pin-github-actions or Dependabot's actions ecosystem to manage updates.
Controls violated: SOC 2 CC9.2 (third-party risk), ISO 27001 A.8.8 (management of technical vulnerabilities).
FINDING 7 — LOW: id-token: write granted at job level, not step level
Standards: [SOC2-CC6]
Description:
GitHub Actions does not currently support step-level permission scoping; however, the id-token: write permission is granted for the entire job lifetime. The Build publisher step compiles and executes Go code from a third-party repository (Finding 2). If that code were malicious, it would execute with the ability to request OIDC tokens from the GitHub token endpoint for the duration of the job. This is a defence-in-depth concern layered on top of Finding 2, not an independent vulnerability, but it reinforces the need to resolve Finding 2.
Remediation:
There is no step-level workaround today. The mitigation is to resolve Finding 2 (pin and vet the publisher source) and to consider splitting the job: build/vet the publisher in a job with no id-token: write permission, then pass the compiled binary as an artifact to a second job that does the authenticated publish.
Controls violated: SOC 2 CC6.3 (least privilege).
FINDING 8 — INFORMATIONAL: No audit trail of publish events beyond CI job logs
Standards: [SOC2-CC8]
Description:
Discord notifications are explicitly disabled and there is no alternative audit record of what was published, when, by which tag, and whether it succeeded. GitHub Actions logs are available but are subject to the repository's log retention policy (default 90 days). For firmware published to production devices, a durable, append-only audit log of publish events would support incident response and compliance review.
Remediation:
- Write a structured publish-event record (chip, version, GCS path, SHA256, timestamp, triggering tag, runner ID) to a separate GCS audit bucket or a Pub/Sub topic consumed by a SIEM.
- Alternatively, enable GCS audit logging (Data Access audit logs) on the firmware bucket so every object write is recorded in Cloud Audit Logs.
Controls violated: SOC 2 CC8.1 (change management audit trail).
Compliance Summary
| Framework | Checked | Violations Found |
|---|---|---|
| SOC 2 (CC6, CC7, CC8, CC9, A1, C1) | ✅ | Yes — CC6 (token exposure, tag access control), CC7 (monitoring gaps), CC8 (unsigned firmware, audit trail), CC9 (unpinned deps) |
| ISO/IEC 27001:2022 (A.8, A.9, A.12) | ✅ | Yes — A.8 (token exposure, unsigned firmware, unpinned deps), A.9 (tag-push access control), A.12 (alerting/logging) |
| PCI DSS v4.0 | ⚪ Not applicable | No payment/card data flows in this diff |
| GDPR / Privacy | ⚪ Not applicable | No personal data flows identified |
| HIPAA | ⚪ Not applicable | No health/medical data flows identified |
| NIST SP 800-53 / CSF 2.0 (AC, AU, IA, SC, SI) | ✅ | Yes — SC-12 (token handling), SI-7 (firmware integrity, unpinned publisher) |
Overall recommendation: Do not merge as-is. Findings 1, 2, and 3 (all HIGH) represent meaningful supply-chain and credential-exposure risks for a workflow that publishes firmware to production devices. Address those before enabling this automation.
What
Adds a
publishjob to the firmware build workflow. On av*tag, it uploads each variant's merged.binto thewendyos-images-publicGCS bucket and updatesmanifests/<chip>.json+ thefirmwaresection ofmanifests/master.json— the manifestswendy os installreads from.It reuses the existing publisher CLI from
wendylabsinc/meta-wendyos-jetson(tools/publisher,--firmwaremode), so OS images and firmware share one publish path.Why
The published firmware had drifted from
main. The manifest stayed at 1.0.0, which predates thewendy_confwork.wendy os installdownloads the merged image and injects WiFi/enrollment config into awendy_confpartition (magicWYC0); 1.0.0's flash image has no such partition, so the CLI aborted withpartition "wendy_conf" not found in flash image. There was no automation keeping GCS in sync with releases.(Interim: esp32c6 1.0.1 was published manually to unblock; this job makes it automatic going forward.)
How it works
v*tags only (mirrors the existingreleasejob). Version = tag minus thevprefix.esp32c5,esp32c6,esp32p4_waveshare_lcd_4b,esp32p4_dfr1172_firebeetle) — matches what the CLI queries.master.jsonread-modify-write doesn't race.id-token: write), no long-lived keys.Required repo secrets
GCP_WORKLOAD_IDENTITY_PROVIDERGCP_SERVICE_ACCOUNTPUBLISHER_REPO_TOKENmeta-wendyos-jetsonfor the publisher sourceNotes / follow-ups
--notify-discord=false); wire a webhook later if release pings are wanted.wendy os install --nightlyworks for the C6) could be added later, mirroring the builder's nightly flow.🤖 Generated with Claude Code