Skip to content

fix(aster-srv): 0.24.1 security fixes N1-N15 from the second review - #191

Merged
calvin-archastro merged 23 commits into
mainfrom
calvin/aster-0.24.1-security
Oct 23, 2026
Merged

calvin-archastro merged 23 commits into
mainfrom
calvin/aster-0.24.1-security

Conversation

@calvin-archastro

@calvin-archastro calvin-archastro commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Review on ArchCode

Merge order: this is the only 0.24.1 PR. After it merges, a version-bump PR, then tag v0.24.1. v0.24.0 will not be deployed.

Problem and intent

The second independent security review of v0.23.1..v0.24.0, plus firstlanding #19856, returned a conditional GO with no critical or high findings. Calvin asked for every finding to be fixed before aster-srv's /v1 channel goes live without IAP. This PR fixes N1 and N2-N15 on top of v0.24.0, one commit per finding (or a small group), so it can be reviewed commit by commit.

The two medium findings that matter most:

  1. N1, pre-auth resource use on writer routes. A CI token that may not write the cache, such as an in-repo pull_request job, still made the server read and parse up to 64 MiB on /v1/cache/commit before require_writer refused it.
  2. N5, lookup fan-out. lookup_hits ran a GCS HEAD and an IAM signBlob one blob at a time for up to 256 keys. Reply time grew with the bucket. This is also the likely server-side cause of the observed remote-cache read misses: the 0.22 client times out after 5s and, before feat(cache): one-line remote cache summary; batch lookup timeout no longer disables the remote #183, silently turned the remote off for the whole run (Stable CI job 120151277785: 0 cached of 127 targets whose keys hadn't changed).

What changed (before → after)

N Before After
N1 The gate admitted any CI token on writer routes; the writer check ran after reading the body The gate applies the full writer policy (repo allowlist, owner-id pin, writer ref, event_name=push, ci.yml@ prefix) before reading any body, using one shared function, policy::cache_writer_allowed. A semaphore bounds concurrent large-body requests.
N2 (tab 7) Uploaded runs trusted run_id, sha and ref as the client claimed them Runs are tied to the uploading job's OIDC claims (gh:<repo>:<run>:<attempt>: prefix, sha must match the token, branch/attempt taken from the claims). Quarantine uses only trusted default-branch evidence. Migration test_results v7.
N3 Unauthenticated bodies had the 60s timeout; 1024 connections per listener, no per-IP cap A short body timeout on unauthenticated routes, a per-IP connection cap, and idle keep-alive bounded by header_read_timeout (tested)
N4 The API listener started with unsafe settings; unpinned repos passed the owner check With ASTER_SRV_API_ADDR set, the server refuses to start on static tokens, the dev blob route, an unpinned allowlisted repo, an unset writer-workflow list, or trusted_proxy_hops=0. The owner check fails closed.
N5 Sequential HEAD and signBlob per blob Blob cap 4096 per lookup, 16 concurrent per request, a 3s time budget (entries over the cap or the budget are misses), positive existence memory for 1h, signed URLs reused while 5 of their 15 min remain, and a global signBlob bound (16 in flight, 200/s, burst 512, fail fast)
N6 CI, upload and aster srv clients accepted http:// to any host Every aster-srv client uses checked_origin: https unless loopback. Agents are https-only, and loopback agents follow no redirects.
N7 One overflow bucket could starve authenticated subjects Sharded overflow per limiter, with a throttled sweep
N8 No way to revoke a departed person An operator runbook: SQL between README markers; a test runs exactly that SQL
N9 Key rotation undocumented; kill switch missing from the README A rotation runbook for ASTER_SRV_CLI_TOKEN_KEYS; ASTER_SRV_PERSON_TOKENS added to the env table
N9b A failed fetch or unknown-kid probe set last and suppressed a real rotation for 60s last is set only on success; failures back off separately (keyfetch::may_fetch, in both auth.rs and iap.rs)
N10 A run_id from another repo revealed which repo owns it Plain 404
N11 The device name on the confirm page looked verified; copy mode had no warning about pasted codes Each detail is labelled by its source; copy mode and the CLI paste prompt warn against codes someone else sent
N12 Repeated X-Forwarded-For lines weren't treated as one list Lines are joined in order (RFC 9110) before choosing the trusted hop; unit and e2e tests
N13 CI pull_request lookups bumped hits and last-used Only writer-eligible CI lookups count; person lookups never count
N15 Every 401 logged a line The first few per (reason, route, client IP) per window are logged and the rest summarised; counters and the success trail stay exact

Unchanged: the CI OIDC write path for main pushes, the person read allowlist, the accepted risks recorded in #190, and the client cache protocol.

Also 12e7198: the ASTER_SRV_CLI_TOKEN_TTL doc comment said 604800s; the code caps it at 43200.

Security checklist

Paths are under crates/aster-srv/ unless they start with src/ (the client crate).

N Commit(s) Code Tests
N1 efdd1ad, f57b378 src/routes.rs writer_refusal; src/cache/policy.rs cache_writer_allowed; src/http.rs large_body_permit tests/routes.rs: a_non_writer_ci_token_is_refused_before_its_body_is_read, the_large_body_semaphore_bounds_concurrent_commits_and_uploads
N2 a002489, 90fe648, 3901bc5 src/auth.rs run_attempt; src/test_results/ingest.rs bind; migration test_results v7; src/test_results/classifier.rs on_default/trusted; src/test_results/quarantine.rs tests/server.rs, tests/incidents.rs, tests/classifier_model.rs, tests/quarantine.rs, tests/cli_auth.rs (tab 7)
N3 2ac7a37, f57b378 src/http.rs unauth_body_timeout; src/limits.rs Concurrency (per-IP cap) tests/hardening.rs: unauthenticated_bodies_time_out_in_seconds_while_authenticated_ones_keep_the_long_timeout, an_idle_keep_alive_connection_is_closed_after_the_header_timeout, one_client_ip_cannot_hold_more_than_its_share_of_requests_open, without_a_proxy_one_peer_cannot_hold_more_connections_than_its_share; unit limits::a_key_holds_at_most_its_cap_and_a_dropped_slot_frees_it
N4 42a1617 src/config.rs API-listener checks; OwnerIds::accepts fails closed unit config::the_api_listener_refuses_to_start_without_each_hardening_setting, every_allowlisted_repository_needs_an_owner_id_pin_when_the_api_listener_is_on, owner_ids_fail_closed_for_an_unpinned_repository_when_the_api_listener_is_on; tests/hardening.rs: the_api_listener_refuses_tokens_without_the_pinned_owner_id
N5 4c6248f, 18fbab7, eba438e src/cache/routes.rs MAX_LOOKUP_BLOBS, per-request concurrency and budget; src/cache/store.rs readable_url, existence memory, digest cap; src/cache/signing.rs signBlob bound tests/lookup_fanout.rs: entries_over_the_blob_cap_are_misses_and_are_not_counted, a_wide_lookup_finishes_in_bounded_time_and_a_repeat_is_free, a_slow_lookup_answers_within_its_budget_and_the_rest_are_misses; store units a_warm_digest_costs_no_head_and_no_signature, a_missing_blob_is_never_cached, a_remembered_blob_is_asked_about_again_after_its_ttl, delete_forgets_a_remembered_blob, a_url_without_enough_lifetime_left_is_signed_again, concurrent_lookups_of_one_digest_share_one_head_and_signature, idle_slots_are_dropped_past_the_cap; signing units concurrent_signatures_never_exceed_the_limit, an_exhausted_bucket_fails_fast_instead_of_queueing, the_bucket_refills_over_time, a_saturated_signer_turns_entries_into_misses_not_errors; V4 conformance matches_googles_v4_signing_vectors still passes
N6 08288ec src/srv_url.rs checked_origin, scheme_policy; src/cache/aster_srv.rs from_env/from_parts/build_agent; src/cache/backend.rs select_remote; src/test_results/{client,read,upload}.rs srv_url::tests::{checked_origin_refuses_cleartext_to_another_host, the_scheme_policy_blocks_downgrades, only_https_or_this_machine_is_secure}; aster_srv::tests::{a_cleartext_url_on_another_host_is_refused_and_https_and_loopback_are_not, a_cleartext_blob_base_is_ignored}; backend::tests::select_remote_refuses_a_cleartext_aster_srv_url_on_another_host; client::tests::a_cleartext_server_on_another_host_is_refused_before_any_request; read::tests::a_cleartext_server_on_another_host_is_refused_for_reads; upload::tests::{upload_refuses_cleartext_to_another_host_before_any_request, an_https_agent_refuses_plain_http, a_loopback_http_agent_does_not_follow_a_redirect_to_another_server}
N7 b1d193c src/limits.rs overflow_key units limits::a_flood_of_minted_keys_does_not_starve_a_real_newcomer, a_full_table_is_swept_at_most_once_per_interval, a_flood_against_the_ip_limiter_leaves_subjects_untouched
N8 cccb944 README.md "Revoking a departed person's tokens" (SQL between revoke-sql markers) tests/operator_revoke.rs: the_documented_revoke_sql_logs_out_a_departed_person_and_nobody_else
N9 199d5eb README.md "Rotating the signing keys"; ASTER_SRV_PERSON_TOKENS env row docs; the kill switch is covered by the existing config units and tests/cli_auth_copy.rs
N9b d5fc8c7, f57b378 src/keyfetch.rs may_fetch, used in src/auth.rs and src/iap.rs tests/jwks_refetch.rs: a_failed_key_fetch_does_not_block_the_next_real_rotation, an_unknown_key_flood_does_not_cause_a_refetch_storm_or_hide_a_rotation, the_iap_verifier_follows_the_same_refetch_rules; keyfetch::tests
N10 67af47a src/test_results/query.rs run failures tests/query.rs reports_a_runs_failures_with_their_state (asserts 404)
N11 e7d5334 src/cli_auth.rs confirm and code pages; src/cache/login.rs PASTE_WARNING tests/cli_auth_copy.rs copy_mode_shows_a_one_time_code_and_never_redirects_or_shows_a_token; login::tests::the_paste_prompt_warns_against_a_code_someone_sent
N12 8ad9bdb src/limits.rs forwarded_for, client_ip; src/http.rs handle units limits::repeated_forwarded_for_lines_are_one_list_and_cannot_move_the_client, limits::the_client_moves_left_one_entry_per_trusted_hop; tests/hardening.rs the_client_ip_comes_from_all_forwarded_for_lines_and_the_trusted_hops
N13 c67c9ae, 0e6434f src/cache/routes.rs (counts = Ci && may_write) tests/cache.rs a_pull_request_lookup_does_not_count_but_a_main_push_does (pull_request, person, main push)
N14 — firstlanding infra: Cloud Armor rule 900 firstlanding #19927
N15 065d531 src/audit.rs Sampler, admit tests/hardening.rs a_401_flood_is_sampled_and_summarised_while_counters_and_the_success_trail_stay_exact; units audit::the_first_few_per_key_are_admitted_and_the_rest_summarised_when_the_window_ends, a_flood_of_distinct_addresses_cannot_grow_the_table

Live verification after the ingress flip (N12, multiple X-Forwarded-For headers, client IP taken from the right hop) is added to firstlanding's live-negative-checks.py in #19927.

Scope

Server (aster-srv) and the aster client. Backend only.

Risk: medium

These are auth-boundary and resource-limit changes, so they're the riskiest part of the release, but every finding has a test that fails without its fix (spot-checked per commit by the authors).

  • New startup refusals (N4) apply only when ASTER_SRV_API_ADDR is set. firstlanding #19856 sets every required setting (owner-id pin, writer workflows, trusted hops).
  • N5's budget turns slow lookups into misses rather than errors, so the worst case is a cache miss, never a failed build.
  • Migration test_results v7 is additive.

User impact

Laptop and CI users shouldn't see a change, apart from clearer confirm-page labels and the paste warning. A cold lookup on a large CI build now answers within about 3s, with partial hits instead of timing out.

Testing

Canonical end-to-end proofs:

  • tests/cache_login_e2e.rs: a_laptop_signs_in_through_aster_srv_and_restores_what_ci_wrote, a_remote_shell_signs_in_by_pasting_a_code_and_restores_what_ci_wrote, and a_signed_in_laptop_reads_every_dashboard_dataset_with_aster_srv. These run the real aster binary, server and Postgres.
  • tests/cache_remote_e2e.rs remote_restore_across_checkouts_through_aster_srv.
  • Each finding's own boundary test is listed in the checklist.

Latency (N5), measured on real routes and Postgres, with in-memory GCS at 20ms per HEAD and 10ms per signBlob:

Case v0.24.0 This PR
64 keys × 8 blobs, cold 17.5s 1.11s, 64 hits
same, warm 18.9s 23ms
256 × 16 (4096 blobs), cold 504 after 120s 3.05s, partial hits, the rest misses

Integration matrix:

Entry point Variation Covered by
/v1/cache/commit, /upload writer main push / PR token / wrong workflow / unpinned owner; huge body before auth tests/routes.rs
/v1/cache/lookup cold / warm / over the cap / over the budget / saturated signer; PR vs main vs person counting tests/lookup_fanout.rs, tests/cache.rs
/v1/test-results/runs OIDC run binding, static namespace, trusted default branch tests/server.rs, incidents.rs, quarantine.rs
/v1/auth/token, /cli-auth slow body, per-IP cap, idle keep-alive, 401 flood sampling tests/hardening.rs
JWKS (auth, iap) failed fetch, unknown-kid flood, real rotation tests/jwks_refetch.rs
Startup each API-listener refusal config units
Client https / loopback http / cleartext remote refused / redirect downgrade client units
Operator the documented revoke SQL tests/operator_revoke.rs

Fidelity:

  • Real: Postgres, server code, the aster binary, JWT signing.
  • Faked:
    • IAP is a signed header injected by the test, with no Google key fetch.
    • GCS is the in-memory store, with injected latency for the N5 measurements.
    • GitHub OIDC uses a test RSA key.
    • The signBlob limits are per replica.

Not proven here:

  • Real Google load-balancer X-Forwarded-For shape, Cloud Armor, and cross-replica limits. firstlanding #19927's live checks cover these after the flip.
  • Real IAM signBlob quota behaviour.
  • Rollback of migration v7: it's additive, with no down migration.

Local checks on the assembled head (3db4301): fmt; clippy -D warnings; rustdoc -D warnings; full cargo test -p aster-srv against Postgres 17; cache_login_e2e (3) and cache_remote_e2e (2); root lib and bins (916 + 20). All pass.

3db4301 (test only): the dashboard e2e now gives each simulated CI run its own OIDC token and a matching gh:<repo>:<run>:<attempt>: run id, because N2 rightly refused the old e2e-run-N uploads with 403.

Follow-ups

  1. The firstlanding imageRef bump and applies after the third review (v0.24.0..v0.24.1).
  2. The N15 sampling test counts warnings across the whole test binary, so new bad-bearer tests in hardening.rs can push it over its limit.
  3. Commits efdd1ad through 199d5eb fail CI clippy/rustdoc until f57b378; all of them compile.

🤖 Generated with Claude Code

calvin-archastro and others added 22 commits October 23, 2026 16:28
…ng the body

A non-writer CI token (an in-repo pull_request job) on a CiWriter route
passed the gate, and the handler read and parsed up to 64 MiB before
require_writer refused it. The gate now applies the route's writer rule
(cache: ref, push event, job_workflow_ref prefix; test results: the
results rule) before any body is read, using the same policy functions the
handlers use. A semaphore (ASTER_SRV_MAX_LARGE_BODIES, default 8) bounds
concurrent large bodies (commit, test-results upload) and answers 503.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 0c19614)
…a per-IP cap

POST /v1/auth/token and POST /cli-auth read their 8 KiB body under the
global 60s body timeout, and a listener allowed 1024 connections with no
per-IP cap. They now use ASTER_SRV_UNAUTH_BODY_TIMEOUT_SECS (default 8s).
One client IP, resolved by limits::client_ip, holds at most
ASTER_SRV_MAX_CONNECTIONS_PER_IP (default 128) requests in flight, and with
trusted_proxy_hops=0 also that many open connections. A test confirms
header_read_timeout closes an idle keep-alive connection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit c1e0e82)
…ning settings

With ASTER_SRV_API_ADDR set the server now refuses to start when static
tokens or the dev blob route are configured, when any allowlisted repository
lacks a repository_owner_id pin, when the writer-workflow list is unset, or
when trusted_proxy_hops is 0, and names every missing setting. OwnerIds
also fails closed for an unpinned repository when the listener is on.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit bfefa1a)
…weep

When a limiter's table filled, every newcomer shared one overflow bucket,
so a flood of minted keys starved real clients, and each new key scanned
the whole table. Overflow is now 256 buckets by a per-limiter keyed hash and
the idle sweep runs at most once a second. The subject limiter has its own
table, so unauthenticated floods never spend an authenticated subject's
allowance.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit c7894d2)
… real key rotation

Both verifiers stamped the refetch time before the JWKS request, so one
failed fetch or one unknown-kid probe refused a real rotation for 60
seconds. Refetch rules now live in keyfetch: any fetch spaces the next by
10 seconds, a key a successful fetch did not find is not fetched for again
for 60 seconds, and the remembered set is bounded. The auth.rs change is
confined to the fetch and backoff code in Verifier::key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit d507daf)
GET /v1/test-results/runs/:run_id/failures replied 403 naming the owning
repository when the run belonged to another repository, which confirmed the
id existed and whose it was. It now returns the same 404 as a missing run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 5344495)
A 401 flood wrote one audit line (and one 'token refused' warning) per
request. The first ASTER_SRV_LOG_SAMPLE_FIRST (5) per event, reason, route
and client IP in each window (60s) are logged and the rest become one
audit_summary line per key when the window ends. Counters are bumped before
sampling and stay exact; token_mint, successful exchanges and token_revoke
are never sampled. The sampler table is bounded.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 73e2563)
…and test the trusted hop

A request may carry several X-Forwarded-For lines. `limits::forwarded_for`
joins them in order into one list (RFC 9110), and `limits::client_ip` then
takes the entry ASTER_SRV_TRUSTED_PROXY_HOPS from the right, so a spoofed
prefix or an extra leading line cannot move the client. Unit tests pin the
join and the per-hop walk; an end-to-end test checks the audited client_ip
for one and two trusted hops across single and repeated lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 17acfd4)
… and test its SQL

A person removed from IAP keeps a token that works for up to 12 hours, and
only the person could revoke it (`aster cache logout`). The README now has
the operator path, with no new route: find the stable IAP subject from the
token_mint audit line, then run one psql snippet that upserts
auth.revocations the same way logout does. tests/operator_revoke.rs reads
that snippet from the README between its markers, runs it against Postgres,
and checks the person's tokens get 401 on a /v1 read while another
person's token still gets 200, and that a second run is harmless.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 19490a5)
… the kill switch

The README said only "add a new first pair, wait, drop the old one". It now
has a rotation section: the kid:secret list format (the first signs, all
verify), key requirements and how to generate one, planned rotation (add the
new kid first, wait out the 12-hour token lifetime, drop the old kid),
emergency rotation (replace the list with one new kid, which signs everyone
out), and the ASTER_SRV_PERSON_TOKENS=on|off kill switch, which is also added
to the configuration table. The deploying repository sets the keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 8c502ef)
Earlier commits on this branch failed CI's lint job: a private intra-doc
link from N1 (cache_writer_allowed linked to ci_reader), two format!
calls in N3's hardening tests that newer clippy rejects (format! inside
format! args, a format! with no arguments), and a nonminimal_bool in N9b's
KidRefetch::may_fetch that clippy flags on stable and 1.88. No behaviour
change.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit b26cd2c)
A pull_request CI token could bump hits, last_hit_at and last_used_at on
every lookup and so keep entries alive past retention. Lookups now mutate
the manifest and blob rows only when Reader::may_write is true (the same
policy.rs decision that gates commit). Aggregate lookup_stats still record
every CI lookup as a hit-rate view.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 3e3ff7f)
BlobStore gains readable_url for lookups: a positive HEAD is remembered for
an hour (blobs are content-addressed; misses are never cached), a signed GET
URL is reused while 5 minutes of its 15 remain, and concurrent callers of one
digest share a single HEAD and signature through a per-digest lock. HEADs
share a global semaphore, and every signBlob goes through a semaphore plus
token bucket (SignLimiter) that fails fast with "signing is busy" rather than
queueing. delete drops the remembered digest. Tuning lives in FanoutConfig;
the route is switched over in the next commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit a1b664f)
lookup_hits did a sequential HEAD plus signBlob per blob (256 keys, no cap),
which grew with bucket size and ran past the 0.22 client's 5s timeout. Now:
manifests are read in one SELECT; at most MAX_LOOKUP_BLOBS (4096) distinct
blobs are admitted in request order and an entry that does not fit is a miss;
blobs resolve 16 at a time through BlobStore::readable_url; a blob whose
HEAD or signature fails or is busy makes its entry a miss, not a 500; hits,
last_hit_at and blob last_used_at are updated in two batched statements for
only the entries served. Measured with 64 keys x 8 blobs, 20ms HEAD, 10ms
sign: 17.8s cold and 17.9s repeat before, 1.1s cold and 0.03s repeat after.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 6059702)
The blob cap and per-request concurrency bound the work but not the reply
time: a cold lookup at the cap (4096 blobs, 20ms HEAD, 10ms sign, signBlob
held to 200/s) still ran for many seconds, past the 0.22 client's 5s
request timeout, which then loses every hit in the batch. Blob resolution
now stops at FanoutConfig::lookup_budget (3s); an entry with a blob not
resolved by then is a miss and is not counted, and the in-flight HEADs and
signatures are cancelled. The resolved blobs are remembered, so a repeat
gets further. A blob missing from the resolved set is now always treated
as a failure, never as served.

The cap test now lifts the signing rate and budget so it tests only the
cap. The README documents the cap, budget, remembered answers and the
per-replica signBlob and HEAD limits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 061c044)
The N13 test now also has a signed-in person look the key up and checks
that hits, last_hit_at and last_used_at stay put. The README states that
only a writer-eligible CI token's lookup counts hits and refreshes
last_used_at (the commit writer rules), while every CI lookup still feeds
cache.lookup_stats.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 6b1a27a)
…oopback

The sign-in already refused plain http to another host; the CI cache
backend, test-result uploader, reads, quarantine, balance and summary
did not and built agents with https_only(false). They now share
srv_url::checked_origin, refuse a non-loopback http URL before any
request, use https_only agents, and a loopback http agent follows no
redirects, so a redirect cannot downgrade or leave the machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit fe090aa)
…st sharing codes

The confirm page now says the device name is reported by the requesting
computer and not verified, and labels the address and user agent as the
browser's own. Copy mode warns on the confirm page, on the code page
(never send this code) and in the CLI prompt (never paste a code
someone sent you).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit f1ffa25)
Ingest binds an upload's run id to the job's GitHub run and attempt;
the attempt was not read from the token.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit acab0f9)
Ingest checked only the repository. The run id (the runs table's global
key), sha, branch and pr were the client's to choose, so a job could
take a later run's id first (its real upload then became a "duplicate"),
file results under another repository's id, or claim any commit and
branch.

For OIDC tokens the run id must start gh:<repo>:<run_id>:<run_attempt>:
for the token's own repository, run and attempt (the shape Aster's
client builds); sha must match the token's; branch, pr and attempt come
from the ref and run_attempt claims. A static token's gh: ids must name
its own repository, and its other ids are stored as static:<repo>:<id>,
so no two repositories' ids can collide.

Migration v7 records each run's source (oidc or static) and its ref and
event_name claims for the classifier to trust instead of the record.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit a8d3cc9)
…dence

Quarantine entered on definite flakes from any branch, so a pull request
job (which runs its author's code with a genuine token) could upload two
forged flips and quarantine a test, or upload a pass on the inputs of a
real main failure and make that failure count as a flake.

A run is on the default branch by its credentials: for an OIDC upload,
ref refs/heads/<default> and event push, workflow_dispatch or schedule
(a pull request's record may say main; pull_request_target runs on the
default ref for a pull request); for a static token, its branch. Runs
stored before sources were recorded are never trusted. Likely events and
broken streaks use the same test.

Each flake event records whether both of its runs are trusted, and only
trusted events count toward quarantine and its run cap. Pull request
flakes still score. A trusted run is also compared with the nearest
earlier trusted run of its inputs, so pull request runs in between cannot
hide a flake main saw itself, and a trusted comparison found later
upgrades an untrusted one.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit f3ff547)
…ode enforces

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@calvin-archastro
calvin-archastro requested a review from a team October 23, 2026 23:30
@calvin-archastro
calvin-archastro marked this pull request as draft October 23, 2026 23:37
The dashboard e2e uploaded three runs with ids e2e-run-N under one OIDC
token. N2 now requires an OIDC upload's run id to start with
gh:<repo>:<run_id>:<run_attempt>: and its sha to match the token, so the
uploads were refused with 403. Each run now has its own token (run_id,
run_attempt, sha) and a matching run id; the run-failures read
percent-encodes the id's '/'.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@calvin-archastro
calvin-archastro marked this pull request as ready for review October 23, 2026 23:49
@calvin-archastro
calvin-archastro merged commit 9571846 into main Oct 23, 2026
13 checks passed
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.

1 participant