Skip to content

Commit bac091e

Browse files
waleedlatif1claude
andcommitted
fix(supply-chain): fail on an unresolvable tag instead of silently skipping it
`digest_of` treated every inspect failure as "tag absent", so a transient registry error would drop a published digest from the matrix while both jobs still went green — an unsigned image that verification would later reject, with nothing in the run to show for it. It now retries, reports absent only when the registry says the manifest is unknown, and fails the step otherwise. The three sha tags are required outright, and an alias group that is current must resolve all three of its tags. SBOMs no longer attach to index subjects. Syft resolves an index to a single platform, so the SBOM described amd64 while the index also serves arm64. The per-arch subjects each carry an accurate SBOM; the index keeps its signature and provenance. Docs: - The member-directory restriction does not apply to organization owners and admins — the roster routes exempt those roles. - Python's execution does not hinge on NEXT_PUBLIC_SANDBOXES_ENABLED; that flag gates the controls, the server-side provider and image decide whether it runs. - The security checklist demanded sslMode: require on a Compose database that ships without TLS, so it could not be satisfied as written. - CERT_HAS_EXPIRED is usually an expired certificate, an incomplete chain, or a drifted clock. Sending operators straight to NODE_EXTRA_CA_CERTS fixes none of those, and the section also said the same thing twice. - Four endpoints, not "both". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt
1 parent e409f57 commit bac091e

5 files changed

Lines changed: 86 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -665,29 +665,63 @@ jobs:
665665
666666
IMAGES="simstudio migrations realtime pii cron"
667667
668-
# Prints the digest, or nothing when the tag is not published. The
669-
# explicit `if !` matters: under `set -e` a failing inspect inside a
670-
# command substitution aborts the step before the caller can treat an
671-
# absent tag as "skip".
668+
# Prints the digest, or nothing when the tag is genuinely absent.
669+
#
670+
# An absent tag and a registry hiccup both make `inspect` fail, and
671+
# treating them alike is how a published image silently ends up
672+
# unsigned while this job still goes green. So: retry, and only report
673+
# "absent" when the registry actually says the manifest is unknown.
674+
# Anything else fails the step.
672675
digest_of() {
673-
local raw
674-
if ! raw="$(docker buildx imagetools inspect "$1" --format '{{json .Manifest}}' 2>/dev/null)"; then
675-
return 0
676-
fi
677-
printf '%s' "$raw" | jq -r '.digest // empty'
676+
local ref="$1" attempt raw err
677+
for attempt in 1 2 3; do
678+
if raw="$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' 2>/tmp/inspect.err)"; then
679+
printf '%s' "$raw" | jq -r '.digest // empty'
680+
return 0
681+
fi
682+
err="$(cat /tmp/inspect.err)"
683+
case "$err" in
684+
*"not found"*|*MANIFEST_UNKNOWN*|*"no such manifest"*|*"NAME_UNKNOWN"*)
685+
return 0
686+
;;
687+
esac
688+
sleep "$((attempt * 3))"
689+
done
690+
echo "::error::Could not inspect ${ref} after 3 attempts: ${err}" >&2
691+
return 1
692+
}
693+
694+
# Records a subject. `platform` tells the attestation job whether this
695+
# digest is a single-architecture image, which is the only case where
696+
# a Syft SBOM describes what the puller actually gets.
697+
emit() {
698+
jq -nc --arg image "$1" --arg digest "$2" --arg platform "$3" \
699+
'{image: $image, digest: $digest, platform: $platform}' >> /tmp/subjects.jsonl
678700
}
679701
680702
: > /tmp/subjects.jsonl
681703
for name in $IMAGES; do
682704
image="ghcr.io/simstudioai/${name}"
683705
684-
# The sha tags are this run's own output and always exist.
685-
sha_index="$(digest_of "${image}:${SHA}")"
686-
if [ -z "$sha_index" ]; then
687-
echo "::error::No index published for ${image}:${SHA}"
688-
exit 1
689-
fi
690-
tags="${SHA} ${SHA}-amd64 ${SHA}-arm64"
706+
# The sha tags are this run's own output. All three must resolve —
707+
# a missing one means the publish did not complete, not that the tag
708+
# is optional.
709+
seen=""
710+
sha_index=""
711+
for tag in "${SHA}" "${SHA}-amd64" "${SHA}-arm64"; do
712+
digest="$(digest_of "${image}:${tag}")"
713+
if [ -z "$digest" ]; then
714+
echo "::error::${image}:${tag} was not published by this run"
715+
exit 1
716+
fi
717+
case "$tag" in
718+
*-amd64) platform=amd64 ;;
719+
*-arm64) platform=arm64 ;;
720+
*) platform=index; sha_index="$digest" ;;
721+
esac
722+
seen="$seen $digest"
723+
emit "$image" "$digest" "$platform"
724+
done
691725
692726
# A moving alias is only ours if it resolves to the index this run
693727
# published. create-ghcr-manifests holds the latest tags back when
@@ -696,24 +730,31 @@ jobs:
696730
# and provenance on an image it did not produce. The per-arch
697731
# aliases are published in the same guarded block as `latest`, so
698732
# that one comparison gates all three.
699-
if [ "$(digest_of "${image}:latest")" = "$sha_index" ]; then
700-
tags="${tags} latest latest-amd64 latest-arm64"
701-
else
702-
echo "Skipping latest tags for ${image}: they do not point at this run's index."
703-
fi
704-
705-
if [ "${IS_RELEASE}" = "true" ] && [ "$(digest_of "${image}:${VERSION}")" = "$sha_index" ]; then
706-
tags="${tags} ${VERSION} ${VERSION}-amd64 ${VERSION}-arm64"
733+
alias_groups="latest"
734+
if [ "${IS_RELEASE}" = "true" ]; then
735+
alias_groups="${alias_groups} ${VERSION}"
707736
fi
708737
709-
seen=""
710-
for tag in $tags; do
711-
digest="$(digest_of "${image}:${tag}")"
712-
[ -n "$digest" ] || continue
713-
case " $seen " in *" $digest "*) continue ;; esac
714-
seen="$seen $digest"
715-
jq -nc --arg image "$image" --arg digest "$digest" \
716-
'{image: $image, digest: $digest}' >> /tmp/subjects.jsonl
738+
for alias in $alias_groups; do
739+
alias_index="$(digest_of "${image}:${alias}")"
740+
if [ "$alias_index" != "$sha_index" ]; then
741+
echo "Skipping ${alias}* for ${image}: it does not point at this run's index."
742+
continue
743+
fi
744+
for tag in "${alias}" "${alias}-amd64" "${alias}-arm64"; do
745+
digest="$(digest_of "${image}:${tag}")"
746+
if [ -z "$digest" ]; then
747+
echo "::error::${image}:${tag} is missing though ${image}:${alias} is current"
748+
exit 1
749+
fi
750+
case " $seen " in *" $digest "*) continue ;; esac
751+
seen="$seen $digest"
752+
case "$tag" in
753+
*-amd64) emit "$image" "$digest" amd64 ;;
754+
*-arm64) emit "$image" "$digest" arm64 ;;
755+
*) emit "$image" "$digest" index ;;
756+
esac
757+
done
717758
done
718759
done
719760
@@ -756,7 +797,12 @@ jobs:
756797
username: ${{ github.repository_owner }}
757798
password: ${{ secrets.GITHUB_TOKEN }}
758799

800+
# Skipped for index subjects: Syft resolves an index to one platform, so
801+
# the SBOM it produces would describe amd64 while the index also serves
802+
# arm64. The per-arch subjects below carry an accurate SBOM each, and the
803+
# index still gets a signature and provenance.
759804
- name: Generate SBOM
805+
if: matrix.platform != 'index'
760806
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
761807
with:
762808
image: ${{ matrix.image }}@${{ matrix.digest }}
@@ -768,6 +814,7 @@ jobs:
768814
upload-release-assets: false
769815

770816
- name: Attest SBOM
817+
if: matrix.platform != 'index'
771818
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
772819
with:
773820
subject-name: ${{ matrix.image }}

apps/docs/content/docs/platform/enterprise/access-control.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ The **Chat Deployment** row also carries an **auth-mode allowlist** — *Auth mo
161161
|---------|---------------------------|
162162
| Invitations | Prevents inviting anyone to a workspace or to the organization. |
163163
| Workspace Creation | Prevents creating new workspaces. A new one is not covered by any workspace-scoped group until you add it, though the organization's default group still governs it. |
164-
| Member Directory | Withholds the member directory. Members cannot see the names or email addresses of other members. |
164+
| Member Directory | Withholds the member directory. Members cannot see the names or email addresses of other members. Organization owners and admins keep access — the roster routes exempt those roles. |
165165

166166
**Credentials & Access**
167167

apps/docs/content/docs/platform/enterprise/self-hosted.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ See [Sandboxes](/platform/self-hosting/sandboxes) for the provider credentials,
6969

7070
## Schedule the background jobs
7171

72-
Two enterprise features do their work from a cron-driven HTTP endpoint rather than from the app process. All four endpoints authenticate with a bearer token equal to `CRON_SECRET`, and both return `401` when `CRON_SECRET` is unset:
72+
Two enterprise features do their work from a cron-driven HTTP endpoint rather than from the app process. All four endpoints authenticate with a bearer token equal to `CRON_SECRET`, and all four return `401` when it is unset:
7373

7474
```bash
7575
openssl rand -hex 32

apps/docs/content/docs/platform/self-hosting/security.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ Two browser-side variables project server state into the UI, and neither is deri
151151

152152
| Variable | Effect when unset |
153153
|---|---|
154-
| `NEXT_PUBLIC_SANDBOXES_ENABLED` | The Function block's Shell language, its Sandbox picker, and **Settings → Sandboxes** stay hidden, even with a working provider. Python stays selectable and fails at execution |
154+
| `NEXT_PUBLIC_SANDBOXES_ENABLED` | The Function block's Shell language, its Sandbox picker, and **Settings → Sandboxes** stay hidden, even with a working provider. Python stays selectable; whether it runs depends on the server-side provider and Function image, not on this flag |
155155
| `NEXT_PUBLIC_E2B_ENABLED` | The E2B-backed Pi block modes stay hidden; `sim-setup doctor` reports it as a mismatch against `E2B_ENABLED`. Revealing them is not enough to make them run — Pi executes on its own image, pinned with `E2B_PI_TEMPLATE_ID` or `DAYTONA_PI_SNAPSHOT_ID`, and fails closed without it |
156156

157157
Set the public values only **after** the server-side configuration above is complete — they are assertions about readiness, not switches, and the server-side check has its own conditions beyond them. See [Sandboxes](/platform/self-hosting/sandboxes) for the base-image build and promotion procedure. `npx sim-setup doctor` reports a mismatch in either direction.
@@ -276,7 +276,8 @@ The service bundles ~2.2 GB of spaCy models, so first start takes around three m
276276
- [ ] NetworkPolicy enabled and `ingressFrom` scoped to the ingress controller
277277
- [ ] Namespace labelled `pod-security.kubernetes.io/enforce=restricted`
278278
- [ ] Object storage buckets private, with CORS limited to your Sim origin
279-
- [ ] Database reachable only from the deployment — on Compose, the `db` service's host `ports:` mapping removed or bound to `127.0.0.1` — with a generated `POSTGRES_PASSWORD` and TLS enforced (`sslMode: require`)
279+
- [ ] Database reachable only from the deployment — on Compose, the `db` service's host `ports:` mapping removed or bound to `127.0.0.1` — with a generated `POSTGRES_PASSWORD`
280+
- [ ] TLS enforced (`sslMode: require`) on an externally managed database, or on the bundled one once you have configured it for TLS — the shipped Compose database does not enable it
280281
- [ ] Backups configured **and a restore rehearsed**
281282
- [ ] Sandbox strategy decided for user code
282283

apps/docs/content/docs/platform/self-hosting/troubleshooting.mdx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,9 @@ image: pgvector/pgvector:pg17 # NOT postgres:17
116116
117117
## Certificate errors (CERT_HAS_EXPIRED)
118118
119-
SSL certificate errors when calling external APIs almost always mean the endpoint presents a certificate signed by a private CA — a TLS-inspecting proxy, or an internal service.
119+
Rule out the ordinary causes first: the endpoint's certificate really has expired, its chain is incomplete, or the host's clock has drifted far enough to put a valid certificate outside its window. Check the certificate and the host date before changing any trust configuration — `NODE_EXTRA_CA_CERTS` does not fix any of them.
120120

121-
The image already ships current CA certificates and runs as a non-root user, so installing packages inside it is not the fix. This almost always means the endpoint presents a certificate signed by a private CA — a corporate TLS-inspecting proxy, or an internal service.
122-
123-
Mount your CA bundle and point Node at it:
121+
If the certificate is valid and current, the endpoint is presenting one signed by a private CA — a corporate TLS-inspecting proxy, or an internal service. The image already ships current CA certificates and runs as a non-root user, so installing packages inside it is not the fix. Mount your CA bundle and point Node at it:
124122

125123
```yaml
126124
# docker-compose.prod.yml

0 commit comments

Comments
 (0)