Skip to content

Commit de7b42f

Browse files
davidslaterGitHub Ace
andauthored
fix(stats): follow artifact redirect so detection verdicts are actually fetched (#893)
* fix(stats): follow artifact redirect so verdicts are actually fetched The verdict fetcher passed `--no-location-trusted` to curl on the artifact-zip download, believing it would only disable credential forwarding on redirects. It also silently disables `--location`, so curl stopped at the 302 the GitHub API returns and every download was recorded as `download_failed`. Yesterday's report on github/gh-aw (555 external-detector runs) reflected this: 377 `download_failed`, 0 runs with a parsed verdict, threat rate reported as 0%. Drop the flag. Plain `--location` already strips the Authorization header on the cross-host hop to the signed blob URL, which is exactly what the blob endpoint needs. Extend the offline stub to answer artifact zips with a 302 → separate blob path so the collector has to follow the redirect (verified: the test now reproduces the production `download_failed` counts when the flag is restored). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * docs(stats): make verdict-availability table self-describing The verdict-availability table dumped raw internal keys (`present`, `not_fetched`, `download_failed`, ...) and left readers to guess what each one meant. In particular: * `not_fetched` was easy to misread as a fetch failure; it actually means the detection job was skipped so no artifact was ever expected. Reviewer of #893 flagged this. * Successful downloads land under `present`, not a fictional `download_succeeded`. That was invisible in yesterday's #892 report only because the redirect bug reclassified every success as `download_failed`; the fix in #893 puts them back where they belong. Render an explicit Meaning column with a fixed row order, so both the healthy state (`present`) and the "skipped, nothing to fetch" state (`not_fetched`) explain themselves in-place. Unknown states (which the aggregator is defensive about but should never emit) still round-trip verbatim. Assertions in the offline test now verify the Meaning column is rendered and that `not_fetched` is described as the skipped-job state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * docs(stats): split verdict-availability `not_fetched` into `skipped` vs `not_fetched` `not_fetched` was overloaded: it counted both detection jobs that were skipped (never eligible for a verdict artifact) and eligible jobs the collector never reached (budget exhausted, in-progress at collection time, etc.). In a healthy run the former is expected and the latter is a truncation signal, so they should not share a bucket. Split them in the aggregator: * `skipped` — `conclusion == "skipped"` or `status != "completed"`. The detection job was never eligible, so no verdict was ever expected. * `not_fetched` — anything else without a verdict record. The collector meant to fetch this run and didn't get to it. Records and notable_runs use the same split, so downstream views stay consistent, and the summary now describes each row explicitly. Tests assert the split (`not_fetched` must be absent when collection is complete) and that both descriptions are rendered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * chore(stats): give the collector ~2h so a full day fits in the budget Yesterday's rerun of the fixed collector against 2026-08-17 hit the `DEADLINE_MINUTES=18` wall-clock ceiling after 147/412 verdict targets (the collector step ran exactly 18 min). Verdict fetching is currently serial, so a full day of ~400 real downloads plus rate-limit pauses needs on the order of an hour, not eighteen minutes. Extend the collector's wall-clock window to ~2h and give the wrapping workflow / job timeouts enough headroom to actually reach it: * workflow timeout: 30 -> 150 (2h collector + ~30 min for the agent job) * collector job timeout: 25 -> 130 (2h + a small setup/upload margin) * DEADLINE_MINUTES: 18 -> 120 * MAX_REQUESTS: 3000 -> 8000 (a full day at ~85 req/min plus rate-limit pauses can reach ~6-7k; leave headroom so the API budget doesn't bind before the wall-clock deadline does) This is a temporary buffer — verdict fetching should be parallelized so the collector finishes in minutes, not hours. Filed as follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> --------- Co-authored-by: GitHub Ace <githubnext@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com>
1 parent 8a70655 commit de7b42f

6 files changed

Lines changed: 379 additions & 324 deletions

File tree

.gitattributes

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
.github/workflows/*.lock.yml linguist-generated=true
1+
.github/workflows/*.lock.yml linguist-generated=true merge=ours
22
.github/workflows/*-container.md linguist-generated=true merge=ours

.github/aw/actions-lock.json

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,10 @@
55
"version": "v9.0.0",
66
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
77
},
8-
"github/gh-aw-actions/setup@v0.86.2": {
8+
"github/gh-aw-actions/setup@v0.81.6": {
99
"repo": "github/gh-aw-actions/setup",
10-
"version": "v0.86.2",
11-
"sha": "6aab9e5b5c91c615506061f09bedd81a23babe3c"
12-
},
13-
"github/gh-aw-actions/setup@v0.87.0": {
14-
"repo": "github/gh-aw-actions/setup",
15-
"version": "v0.87.0",
16-
"sha": "b77e0d501fd2d2243d1f72722617e80d513f674e"
10+
"version": "v0.81.6",
11+
"sha": "ba6380cc6e5be5d21677bebe04d52fb48e3abec7"
1712
}
1813
}
1914
}

.github/workflows/detection-stats-daily.lock.yml

Lines changed: 278 additions & 294 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/detection-stats-daily.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ safe-outputs:
4646
title-prefix: "[detection-stats] "
4747
labels: [automation, detection-stats]
4848
max: 1
49-
timeout-minutes: 30
49+
timeout-minutes: 150
5050
jobs:
5151
# The collector runs in its own job so the token that reads the target
5252
# repository never enters the agent job's environment.
5353
collect_detection_stats:
5454
runs-on: ubuntu-latest
55-
timeout-minutes: 25
55+
# Verdict fetching is currently serial (~7 targets/min after a rate-limit
56+
# pause), so a full day of ~400 external-detector runs needs on the order
57+
# of an hour. Give it ~2h of wall clock; the agent job that renders the
58+
# report only needs a few minutes after that.
59+
timeout-minutes: 130
5660
permissions:
5761
contents: read
5862
steps:
@@ -67,9 +71,12 @@ jobs:
6771
TARGET_REPO: ${{ inputs.target_repo || 'github/gh-aw' }}
6872
TARGET_DATE: ${{ inputs.date }}
6973
FETCH_RESULTS: ${{ inputs.fetch_results || 'true' }}
70-
MAX_REQUESTS: ${{ inputs.max_requests || '3000' }}
74+
# A full day at ~85 req/min plus rate-limit pauses can reach ~6-7k
75+
# requests; leave headroom above that so the API budget doesn't
76+
# bind before the wall-clock deadline does.
77+
MAX_REQUESTS: ${{ inputs.max_requests || '8000' }}
7178
OUTPUT_DIR: /tmp/gh-aw/detection-stats
72-
DEADLINE_MINUTES: "18"
79+
DEADLINE_MINUTES: "120"
7380
run: bash scripts/collect-detection-stats.sh
7481
- name: Upload detection statistics
7582
if: always()

scripts/collect-detection-stats.sh

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,14 @@ throttle_if_low() {
170170
return 0
171171
}
172172

173-
# api_request <url> <body-output-path> [--binary]
173+
# api_request <url> <body-output-path>
174174
#
175175
# Performs one GET against the GitHub API with retry, primary/secondary
176176
# rate-limit backoff and budget accounting. Returns 0 on 2xx, 1 otherwise (the
177177
# caller decides whether a miss is fatal). Never aborts the run on a single
178178
# failed read.
179179
api_request() {
180-
local url="$1" out="$2" binary="${3:-}"
180+
local url="$1" out="$2"
181181
local header_file="${WORK_DIR}/headers.$$"
182182
local attempt=0 max_attempts=5 status backoff
183183

@@ -198,13 +198,13 @@ api_request() {
198198
--header "X-GitHub-Api-Version: 2022-11-28"
199199
--header "User-Agent: gh-aw-threat-detection-stats"
200200
)
201-
# Artifact downloads answer with a 302 to a signed blob URL; --location
202-
# follows it, but the redirect target rejects the Authorization header.
203-
if [ "$binary" = "--binary" ]; then
204-
curl_args+=(--header "Accept: application/vnd.github+json" --no-location-trusted)
205-
else
206-
curl_args+=(--header "Accept: application/vnd.github+json")
207-
fi
201+
# Artifact downloads answer with a 302 to a signed blob URL on a different
202+
# host; --location follows it and curl strips the Authorization header on
203+
# the cross-host hop, which is exactly what the blob URL needs (it carries
204+
# its own signature). Do NOT add --no-location-trusted here: despite the
205+
# name, that flag also disables --location itself, so every artifact fetch
206+
# would stop at the 302 and be recorded as download_failed.
207+
curl_args+=(--header "Accept: application/vnd.github+json")
208208

209209
status="$(curl "${curl_args[@]}" "$url" 2>/dev/null || echo 000)"
210210
sleep "$REQUEST_PAUSE_SECONDS"
@@ -523,7 +523,7 @@ if [ "$FETCH_RESULTS" = "true" ]; then
523523

524524
artifact_id="$(jq -r '.id' <<<"$artifact")"
525525
zip="${WORK_DIR}/detection.zip"
526-
if ! api_request "${API_URL}/repos/${TARGET_REPO}/actions/artifacts/${artifact_id}/zip" "$zip" --binary; then
526+
if ! api_request "${API_URL}/repos/${TARGET_REPO}/actions/artifacts/${artifact_id}/zip" "$zip"; then
527527
jq -nc --argjson id "$run_id" '{run_id: $id, result: "download_failed"}' >>"$VERDICTS_FILE"
528528
continue
529529
fi
@@ -688,7 +688,16 @@ jq -n \
688688
and (.detection.conclusion == "failure" or .detection.conclusion == "timed_out"
689689
or .detection.conclusion == "action_required"))) | length),
690690
verdict_availability: (if $fetch_results != "true" then {not_fetched: $n}
691-
else ($ext | map(.verdict.result // "not_fetched")
691+
else ($ext | map(
692+
if .verdict.result then .verdict.result
693+
# Detection jobs that were skipped or were still in progress at
694+
# collection time were never eligible for verdict fetching — call
695+
# that out explicitly instead of lumping them into not_fetched,
696+
# which is reserved for eligible targets the collector never
697+
# reached (budget exhausted, etc.).
698+
elif .detection.conclusion == "skipped"
699+
or .detection.status != "completed" then "skipped"
700+
else "not_fetched" end)
692701
| group_by(.) | map({key: .[0], value: length}) | from_entries) end),
693702
soft_failures: {
694703
description: "green detection job that published no verdict artifact",
@@ -731,7 +740,10 @@ jq -n \
731740
job_url: .detection.job_url,
732741
conclusion: (.detection.conclusion // .detection.status),
733742
failed_steps: (.detection.failed_steps // []),
734-
verdict: (if .verdict == null then "not_fetched" else .verdict.result end),
743+
verdict: (if .verdict != null then .verdict.result
744+
elif .detection.conclusion == "skipped"
745+
or .detection.status != "completed" then "skipped"
746+
else "not_fetched" end),
735747
threats: (if .verdict.result == "present" then
736748
([if .verdict.prompt_injection then "prompt_injection" else empty end,
737749
if .verdict.secret_leak then "secret_leak" else empty end,
@@ -757,7 +769,10 @@ jq -n \
757769
job_status: .detection.status,
758770
job_conclusion: .detection.conclusion,
759771
failed_steps: (.detection.failed_steps // []),
760-
verdict: (if .verdict == null then "not_fetched" else .verdict.result end),
772+
verdict: (if .verdict != null then .verdict.result
773+
elif .detection.conclusion == "skipped"
774+
or .detection.status != "completed" then "skipped"
775+
else "not_fetched" end),
761776
prompt_injection: (.verdict.prompt_injection // null),
762777
secret_leak: (.verdict.secret_leak // null),
763778
malicious_patch: (.verdict.malicious_patch // null),
@@ -832,10 +847,30 @@ w("")
832847
833848
w("## Verdict availability")
834849
w("")
835-
w("| State | Count |")
836-
w("|---|---|")
837-
for key, count in sorted(s["verdict_availability"].items(), key=lambda kv: -kv[1]):
838-
w(f"| `{key}` | {count} |")
850+
w("| State | Meaning | Count |")
851+
w("|---|---|---|")
852+
# Fixed ordering + human-readable descriptions so readers don't have to guess
853+
# what `not_fetched` or `present` mean. Zero-count rows are omitted; unknown
854+
# states (should never happen, but the aggregator is defensive) are appended
855+
# verbatim at the end.
856+
VERDICT_STATE_DESCRIPTIONS = [
857+
("present", "detection artifact downloaded and parsed"),
858+
("absent", "detection job ran but published no artifact (soft failure)"),
859+
("expired", "detection artifact existed but had already expired"),
860+
("skipped", "detection job was skipped or was still running (nothing to fetch)"),
861+
("not_fetched", "detection job ran but the collector didn't reach it (budget exhausted)"),
862+
("download_failed", "artifact zip download failed (HTTP error)"),
863+
("lookup_failed", "artifact listing failed (HTTP error)"),
864+
("unreadable", "artifact zip could not be unpacked"),
865+
("malformed", "detection_result.json was missing required fields"),
866+
]
867+
availability = dict(s["verdict_availability"])
868+
for key, description in VERDICT_STATE_DESCRIPTIONS:
869+
count = availability.pop(key, 0)
870+
if count:
871+
w(f"| `{key}` | {description} | {count} |")
872+
for key, count in sorted(availability.items(), key=lambda kv: -kv[1]):
873+
w(f"| `{key}` | (unknown state) | {count} |")
839874
w("")
840875
w(f"Green detection jobs that published no verdict: **{s['soft_failures']['count']}** "
841876
"(detection steps are `continue-on-error`, so a missing verdict artifact is the "

scripts/test/collect-detection-stats-test.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,24 @@ class Handler(BaseHTTPRequestHandler):
250250
251251
m = re.fullmatch(r"/repos/[^/]+/[^/]+/actions/artifacts/(\d+)/zip", path)
252252
if m:
253+
# The real API answers with a 302 redirect to a signed blob URL on
254+
# a different host. Mirror that here so the collector's curl config
255+
# has to actually follow the redirect (and drop the Authorization
256+
# header) to succeed — the way production has to.
257+
self.send_response(302)
258+
self.send_header(
259+
"Location", f"/blob/artifacts/{m.group(1)}/download"
260+
)
261+
self.end_headers()
262+
return
263+
264+
m = re.fullmatch(r"/blob/artifacts/(\d+)/download", path)
265+
if m:
266+
# The signed blob URL is unauthenticated in reality; curl strips
267+
# the Authorization header on the cross-host hop. Our stub is
268+
# single-host, so we don't reject on Authorization here — the
269+
# regression we're guarding against is curl not following the
270+
# 302 at all (--no-location-trusted silently disabled --location).
253271
verdict = detection_artifact(int(m.group(1)) - 700000)
254272
buf = io.BytesIO()
255273
with zipfile.ZipFile(buf, "w") as zf:
@@ -412,6 +430,13 @@ av = s["verdict_availability"]
412430
assert av.get("present") == expect_present, (av, expect_present)
413431
assert av.get("expired") == expect_expired, (av, expect_expired)
414432
assert av.get("absent") == expect_absent, (av, expect_absent)
433+
# Skipped detection jobs must be reported under `skipped`, distinct from
434+
# `not_fetched` (which is reserved for eligible targets the collector
435+
# didn't reach — e.g. budget exhausted). In the happy-path fixture we
436+
# reach every target so `not_fetched` must be absent entirely.
437+
expect_skipped = sum(1 for i in ext if outcome(i) in ("skipped", "in_progress"))
438+
assert av.get("skipped") == expect_skipped, (av, expect_skipped)
439+
assert "not_fetched" not in av, av
415440
416441
d = s["detection_results"]
417442
assert d["with_verdict"] == expect_present, d
@@ -460,6 +485,15 @@ for section in "## Totals" "## Detection job outcomes" "## Verdict availability"
460485
"## Detection results" "## Reasons reported by gh-aw" "## By workflow" "## Notable runs"; do
461486
grep -qF "$section" "${out}/summary.md" || fail "summary.md is missing section: ${section}"
462487
done
488+
# The verdict-availability table must explain each state — the raw keys
489+
# (`present`, `not_fetched`) are opaque to a reader. `not_fetched` in particular
490+
# needs to be tied to skipped detection jobs, not to a fetch failure.
491+
grep -qF "| State | Meaning | Count |" "${out}/summary.md" ||
492+
fail "summary.md verdict table is missing its Meaning column"
493+
grep -qF "detection job was skipped" "${out}/summary.md" ||
494+
fail "summary.md must explain that skipped means the detection job was skipped"
495+
grep -qF "detection artifact downloaded and parsed" "${out}/summary.md" ||
496+
fail "summary.md must describe successful (present) downloads"
463497

464498
# --- budget exhaustion degrades gracefully rather than failing ---------------
465499
out2="${work}/out-budget"

0 commit comments

Comments
 (0)