diff --git a/.github/workflows/dependabot-digest.yml b/.github/workflows/dependabot-digest.yml index 17ff969..1352477 100644 --- a/.github/workflows/dependabot-digest.yml +++ b/.github/workflows/dependabot-digest.yml @@ -57,7 +57,7 @@ jobs: permission-contents: read permission-pull-requests: read permission-statuses: read - # Reads CodeQL default setup (dev-env#166). + # Reads CodeQL default setup (dev-env#166) and the Dependabot alerts setting. permission-administration: read - name: Mint a token for nightowlstudiollc @@ -71,7 +71,7 @@ jobs: permission-contents: read permission-pull-requests: read permission-statuses: read - # Reads CodeQL default setup (dev-env#166). + # Reads CodeQL default setup (dev-env#166) and the Dependabot alerts setting. permission-administration: read - name: Mint a token for twistedmelonman @@ -85,7 +85,7 @@ jobs: permission-contents: read permission-pull-requests: read permission-statuses: read - # Reads CodeQL default setup (dev-env#166). + # Reads CodeQL default setup (dev-env#166) and the Dependabot alerts setting. permission-administration: read - name: Build and publish the digest diff --git a/CLAUDE.md b/CLAUDE.md index 6b7548f..290d0a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ This repo is the **dev-env infrastructure repository** — it contains documenta - `docs/token-rotation.md` — Retired 2026-10-12: no repo runs Claude in CI and `CLAUDE_CODE_OAUTH_TOKEN` is deleted; never contains a token - `docs/runbooks/fleet-probe-token-scopes.md` — The two fine-grained-PAT properties a fleet probe needs (`Administration: Read-only` + All-repositories), and why an under-scoped token returns wrong numbers instead of errors - `scripts/org-migration/` — Snapshot/transfer/verify tooling for the 2026-09 org migration; tests in `scripts/org-migration/tests/run-tests.sh` -- `scripts/dependabot-digest/` — Collects open Dependabot PRs across all three owners and upserts one digest issue describing the queue, plus each repo's standards-check warnings, default-branch protection and CodeQL default setup (`standards.sh`); run by `.github/workflows/dependabot-digest.yml`. Credentials (a GitHub App) are installed by hand: see `docs/runbooks/dependabot-digest-credentials.md` +- `scripts/dependabot-digest/` — Collects open Dependabot PRs across all three owners and upserts one digest issue describing the queue, plus each repo's standards-check warnings, default-branch protection, CodeQL default setup and Dependabot security alerts (`standards.sh`); run by `.github/workflows/dependabot-digest.yml`. Credentials (a GitHub App) are installed by hand: see `docs/runbooks/dependabot-digest-credentials.md` - `scripts/site-monitor/` — Every 15 minutes, checks each production Netlify site in `sites.json` and its latest `site-check` run; keeps one `site-down` issue and pushes ntfy.sh alerts. Run by `.github/workflows/site-monitor.yml`, which a launchd job on MIMOLETTE starts through `dispatch-monitor.sh` because GitHub's cron never fired. The `NTFY_TOPIC` secret and the dispatch token are installed by hand: see `docs/runbooks/site-monitor-ntfy.md` - `.project-hooks/pre-commit` and `.project-hooks/pre-push` — Project-specific git hook extensions, run by the global hooks at `~/.config/git/hooks/` when executable - `.claude/` is ignored, as in every fleet repo (dev-env#178). The scaffold the post-checkout hook drops there (`config.sh.template`, `README.md`) comes from `smartwatermelon/dotfiles` `git/template/.claude-template/`; edit it there. diff --git a/docs/runbooks/dependabot-digest-credentials.md b/docs/runbooks/dependabot-digest-credentials.md index 3f43483..da44bfc 100644 --- a/docs/runbooks/dependabot-digest-credentials.md +++ b/docs/runbooks/dependabot-digest-credentials.md @@ -28,10 +28,13 @@ proof that this path works: a published digest means every check was read. `nightowlstudiollc` and `twistedmelonman`, **All repositories** each. - **Repository permissions**, all read-only: Checks, Contents, Metadata, Pull requests, Commit statuses, Administration. Nothing else. Webhook - inactive. Administration is there only for the CodeQL section: + inactive. Administration is there for two sections. CodeQL: `GET repos/{owner}/{repo}/code-scanning/default-setup` needs Administration: read (GitHub's "Permissions required for GitHub Apps" - table; dev-env#166, approved 2026-10-21). The workflow asks for it + table; dev-env#166, approved 2026-10-21). Dependabot alerts: + `GET repos/{owner}/{repo}/vulnerability-alerts` needs the same grant + (`X-Accepted-GitHub-Permissions: administration=read`, seen on + 2026-10-21). Removing it breaks both. The workflow asks for it when it mints each token. GitHub rejects a token request for a permission the installation has not granted, so accept the grant on all three installations before the workflow runs. diff --git a/scripts/dependabot-digest/render.sh b/scripts/dependabot-digest/render.sh index 65386c3..7fb04e9 100755 --- a/scripts/dependabot-digest/render.sh +++ b/scripts/dependabot-digest/render.sh @@ -469,10 +469,80 @@ render_codeql() { fi } +# Dependabot security alerts. As for CodeQL: unreadable is never on or off, +# and an all-unreadable survey says so. No class is exempt. +render_alerts() { + local file="$1" recs surveyed on off unreadable owners rows + echo "## Dependabot alerts" + echo + if [[ ! -r "${file}" ]] || ! jq -e -s 'type == "array"' "${file}" >/dev/null 2>&1; then + echo "**Not checked.** The survey produced no readable result, so no repository's alerts setting was verified." + echo + return 0 + fi + # No alerts object (older or crashed survey) is unreadable, not on. + if ! recs="$(jq -cs ' + [.[] | select(type == "object" and (.repo | type == "string") and .state != "archived") + | .a = (if (.alerts | type) == "object" and (.alerts.state | type) == "string" + then .alerts + else {state: "unreadable", detail: "no alerts result recorded"} end)] + ' "${file}" 2>/dev/null)" || [[ -z "${recs}" ]]; then + echo "**Not checked.** The survey output could not be summarized, so no repository's alerts setting was verified." + echo + return 0 + fi + owners="$(jq -rs '[.[] | select(type == "object" and .repo == null and .state == "unlisted") | .owner] | unique | join(", ")' "${file}" 2>/dev/null)" + surveyed="$(jq 'length' <<<"${recs}")" + on="$(jq '[.[] | select(.a.state == "on")] | length' <<<"${recs}")" + off="$(jq '[.[] | select(.a.state == "off")] | length' <<<"${recs}")" + unreadable=$((surveyed - on - off)) + + echo "Whether each repository has Dependabot security alerts turned on. Off is a gap for every repository; no class is exempt." + echo + if [[ "${surveyed}" -eq 0 ]]; then + echo "**No repository was surveyed**, so no alerts setting was verified." + echo + elif [[ "${unreadable}" -eq "${surveyed}" ]]; then + # Zero gaps out of zero reads is not "all on". + echo "**Alerts could not be verified for any of ${surveyed} repositories.** Every read failed or returned no usable state. The likely cause is a token without \`Administration: Read\`, which the vulnerability-alerts endpoint needs. Nothing here means alerts are on or off anywhere." + echo + else + echo "**${surveyed} repositories surveyed**: ${on} on, **${off} off, ${unreadable} unreadable.**" + echo + if [[ "${off}" -eq 0 && "${unreadable}" -eq 0 ]]; then + echo "No gaps: every surveyed repository has Dependabot security alerts on." + echo + fi + fi + if [[ "${off}" -gt 0 || "${unreadable}" -gt 0 ]]; then + if ! rows="$(jq -r ' + def cell: tostring | gsub("[\r\n]+"; " ") | gsub("\\|"; "\\|"); + def name: if .a.state == "off" then "OFF" else "UNREADABLE" end; + sort_by((if .a.state == "off" then 0 else 1 end), .repo)[] + | select(.a.state != "on") + | "| [" + .repo + "](https://github.com/" + .repo + ") | " + name + + (if .a.detail then ": " + (.a.detail | cell) else "" end) + " |" + ' <<<"${recs}")" || [[ -z "${rows}" ]]; then + echo "**Some repositories need attention, but listing them failed.** See the run log." + echo + else + echo "| Repo | Dependabot alerts |" + echo "| --- | --- |" + echo "${rows}" + echo + fi + fi + if [[ -n "${owners}" ]]; then + echo "**Not listed, so not surveyed:** ${owners}. The alerts setting of their repositories is unknown." + echo + fi +} + if [[ -n "${standards_file}" ]]; then render_standards "${standards_file}" render_protection "${standards_file}" render_codeql "${standards_file}" + render_alerts "${standards_file}" fi echo "---" diff --git a/scripts/dependabot-digest/standards.sh b/scripts/dependabot-digest/standards.sh index 7dc1388..8c08478 100755 --- a/scripts/dependabot-digest/standards.sh +++ b/scripts/dependabot-digest/standards.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Usage: standards.sh . Prints one JSON record per repo: standards-check # annotations, or the reason none were read, plus the default branch's -# protection state (dev-env#211) and CodeQL default setup (dev-env#166). +# protection state (dev-env#211), CodeQL default setup (dev-env#166) and +# whether Dependabot security alerts are on. set -uo pipefail unset CDPATH @@ -36,6 +37,7 @@ emit() { --argjson ann "${6:-[]}" --arg runAt "${7-}" \ --arg rulesAt "${RULES_AT}" --arg rulesErr "${RULES_ERR}" \ --argjson prot "${PROT_JSON:-null}" --argjson cq "${CQ_JSON:-null}" \ + --argjson al "${ALERTS_JSON:-null}" \ '{owner: $owner, repo: (if $repo == "" then null else $repo end), state: $state, detail: $detail, source: (if $source == "" then null else $source end), @@ -43,10 +45,10 @@ emit() { runAt: (if $runAt == "" then null else $runAt end), rulesAt: (if $rulesAt == "" then null else $rulesAt end), rulesErr: (if $rulesErr == "" then null else $rulesErr end), - annotations: $ann, protection: $prot, codeql: $cq}' 2>/dev/null)" - # If jq fails, the repo must still appear. Protection and CodeQL are unknown. + annotations: $ann, protection: $prot, codeql: $cq, alerts: $al}' 2>/dev/null)" + # If jq fails, the repo must still appear. Protection, CodeQL, alerts unknown. if [[ -z "${line}" ]]; then - printf '{"owner":"%s","repo":"%s","state":"unreadable","detail":"could not encode the result","source":null,"runUrl":null,"runAt":null,"rulesAt":null,"rulesErr":null,"annotations":[],"protection":null,"codeql":null}\n' \ + printf '{"owner":"%s","repo":"%s","state":"unreadable","detail":"could not encode the result","source":null,"runUrl":null,"runAt":null,"rulesAt":null,"rulesErr":null,"annotations":[],"protection":null,"codeql":null,"alerts":null}\n' \ "${owner}" "$1" return 0 fi @@ -330,6 +332,45 @@ probe_codeql() { esac } +# Dependabot security alerts: 204 is on; 404 with this exact body message is +# off (live capture 2026-10-21). Both need Administration: read. +AL_OFF_MSG='Vulnerability alerts are disabled.' + +ALERTS_JSON="" +set_alerts() { + # set_alerts + ALERTS_JSON="$(jq -cn --arg s "$1" --arg d "$2" \ + '{state: $s, detail: (if $d == "" then null else $d end)}' 2>/dev/null)" + if [[ -z "${ALERTS_JSON}" ]]; then + ALERTS_JSON='{"state":"unreadable","detail":"could not encode the alerts result"}' + fi +} + +# Sets ALERTS_JSON for one repo; a failed read is a state. +# A bare 404 hides what a token cannot see: never off. +probe_alerts() { + local nwo="$1" body why + if ! body="$(api "repos/${nwo}/vulnerability-alerts")"; then + why="$(last_err "no detail")" + why="${why%"${why##*[![:space:]]}"}" + # Both signals, as for Semgrep: the status line and the exact body message. + if [[ "${why}" == *"(HTTP 404)"* ]] \ + && jq -e --arg m "${AL_OFF_MSG}" 'type == "object" and .status == "404" and .message == $m' \ + >/dev/null 2>&1 <<<"${body}"; then + set_alerts off "Dependabot security alerts are turned off" + else + set_alerts unreadable "cannot read the alerts setting: ${why}" + fi + return 0 + fi + # 204 has no body. Anything else is not the documented answer. + if [[ -n "${body//[[:space:]]/}" ]]; then + set_alerts unreadable "the alerts read returned an unexpected body" + else + set_alerts on "" + fi +} + survey_repo() { local nwo="$1" branch="$2" head pulls pr pr_head run source ann count url if ! head="$(api "repos/${nwo}/commits/${branch}" --jq '.sha')" \ @@ -430,16 +471,19 @@ while IFS=$'\t' read -r nwo archived fork private branch; do [[ -z "${nwo}" ]] && continue PROT_JSON="" CQ_JSON="" + ALERTS_JSON="" if [[ "${archived}" == "true" ]]; then emit "${nwo}" archived "archived; not surveyed" elif [[ -z "${branch}" ]]; then set_prot unreadable "no default branch reported" - # Default setup is per repo, not per branch, so it is still read. + # Default setup and alerts are per repo, not per branch, so both are read. probe_codeql "${nwo}" "${fork}" "${private}" + probe_alerts "${nwo}" emit "${nwo}" unreadable "no default branch reported" else probe_protection "${nwo}" "${branch}" "${fork}" "${private}" probe_codeql "${nwo}" "${fork}" "${private}" + probe_alerts "${nwo}" survey_repo "${nwo}" "${branch}" fi done <<<"${repo_list}" diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_cleanroom_vulnerability-alerts.204 b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_cleanroom_vulnerability-alerts.204 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nos-private_vulnerability-alerts.404.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nos-private_vulnerability-alerts.404.json new file mode 100644 index 0000000..0706e25 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_nos-private_vulnerability-alerts.404.json @@ -0,0 +1,5 @@ +{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/repos/repos#check-if-vulnerability-alerts-are-enabled-for-a-repository", + "status": "404" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_tnjcleaning_vulnerability-alerts.404.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_tnjcleaning_vulnerability-alerts.404.json new file mode 100644 index 0000000..d2ca6ca --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_nightowlstudiollc_tnjcleaning_vulnerability-alerts.404.json @@ -0,0 +1,5 @@ +{ + "message": "Vulnerability alerts are disabled.", + "documentation_url": "https://docs.github.com/rest/repos/repos#check-if-vulnerability-alerts-are-enabled-for-a-repository", + "status": "404" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_dev-env_vulnerability-alerts.204 b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_dev-env_vulnerability-alerts.204 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_nullflag_vulnerability-alerts.403.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_nullflag_vulnerability-alerts.403.json new file mode 100644 index 0000000..4f126ab --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_nullflag_vulnerability-alerts.403.json @@ -0,0 +1,4 @@ +{ + "message": "Vulnerability alerts are disabled.", + "status": "403" +} diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_withheld_vulnerability-alerts.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_withheld_vulnerability-alerts.json new file mode 100644 index 0000000..92e4475 --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_smartwatermelon_withheld_vulnerability-alerts.json @@ -0,0 +1 @@ +{ "enabled": true } diff --git a/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_Instapaper-MCP_vulnerability-alerts.404.json b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_Instapaper-MCP_vulnerability-alerts.404.json new file mode 100644 index 0000000..d2ca6ca --- /dev/null +++ b/scripts/dependabot-digest/tests/fixtures/standards/repos_twistedmelonman_Instapaper-MCP_vulnerability-alerts.404.json @@ -0,0 +1,5 @@ +{ + "message": "Vulnerability alerts are disabled.", + "documentation_url": "https://docs.github.com/rest/repos/repos#check-if-vulnerability-alerts-are-enabled-for-a-repository", + "status": "404" +} diff --git a/scripts/dependabot-digest/tests/test-render.sh b/scripts/dependabot-digest/tests/test-render.sh index 290379a..e91e989 100755 --- a/scripts/dependabot-digest/tests/test-render.sh +++ b/scripts/dependabot-digest/tests/test-render.sh @@ -146,19 +146,27 @@ else fi # dev-env#166: the same for the CodeQL section. _hasnt '## CodeQL' "no survey, no CodeQL section" -cq_section="$(sed -n '/^## CodeQL/,/^---/p' "${WORK}/bad.md")" +cq_section="$(sed -n '/^## CodeQL/,/^## Dependabot alerts/p' "${WORK}/bad.md")" if [[ "${cq_section}" == *"**Not checked.**"* && "${cq_section}" != *"No gaps"* ]]; then _pass "a malformed survey renders CodeQL as not checked" else _fail "a malformed survey did not render CodeQL as not checked: ${cq_section}" fi +# The same for the Dependabot alerts section. +_hasnt '## Dependabot alerts' "no survey, no alerts section" +al_section="$(sed -n '/^## Dependabot alerts/,/^---/p' "${WORK}/bad.md")" +if [[ "${al_section}" == *"**Not checked.**"* && "${al_section}" != *"No gaps"* ]]; then + _pass "a malformed survey renders alerts as not checked" +else + _fail "a malformed survey did not render alerts as not checked: ${al_section}" +fi # Semgrep CE (2026-10-21): no substitute field or an unknown one is unreadable. : | bash "${DIR}/render.sh" --standards "${FIX}/codeql-semgrep.ndjson" >"${WORK}/sg.md" 2>/dev/null _under() { awk -v h="$1" '$0 == h {on = 1; next} on && /^(#|---|\*\*Not listed)/ {exit} on {print}' "${WORK}/sg.md" } -sg_sum="$(sed -n '/^## CodeQL/,/^---/p' "${WORK}/sg.md")" +sg_sum="$(sed -n '/^## CodeQL/,/^## Dependabot alerts/p' "${WORK}/sg.md")" if [[ "${sg_sum}" == *'**6 repositories surveyed**: 1 configured, 0 exempt, 1 covered by Semgrep, **0 not configured, 1 with no code scanning, 3 with Semgrep coverage unreadable, 0 unreadable.**'* ]]; then _pass "the CodeQL summary counts each Semgrep substitute state" else diff --git a/scripts/dependabot-digest/tests/test-standards.sh b/scripts/dependabot-digest/tests/test-standards.sh index 0593f46..b937c0b 100755 --- a/scripts/dependabot-digest/tests/test-standards.sh +++ b/scripts/dependabot-digest/tests/test-standards.sh @@ -44,12 +44,15 @@ if [[ -f "${file%.json}.403.json" ]]; then echo "gh: $(jq -r '.message' "${file%.json}.403.json") (HTTP 403)" >&2 exit 1 fi -# .404.json: a missing file, as real gh reports it (2026-10-21 capture). +# .404.json: a 404 with a body; gh prints its message +# to stderr (2026-10-21 captures). if [[ -f "${file%.json}.404.json" ]]; then cat "${file%.json}.404.json" - echo "gh: Not Found (HTTP 404)" >&2 + echo "gh: $(jq -r '.message' "${file%.json}.404.json") (HTTP 404)" >&2 exit 1 fi +# .204: 204 No Content. Real gh prints nothing and exits 0. +[[ -f "${file%.json}.204" ]] && exit 0 if [[ ! -f "${file}" ]]; then echo "gh: Resource not accessible by integration (HTTP 403)" >&2 exit 1 @@ -265,8 +268,10 @@ _has '## Branch protection' "the protection section is rendered" "${pbody}" _has '**17 repositories surveyed**: 6 require standards-check, 3 exempt, **4 need attention, 4 unreadable.**' \ "the protection summary counts each state" "${pbody}" _has '- fork: 1 — a fork is not ours to govern (decided 2026-10-21, dev-env#211)' "exempt repos are counted by class with the reason" "${pbody}" -_hasnt '[twistedmelonman/Instapaper-MCP]' "exempt repos are not listed" "${pbody}" -_hasnt '[smartwatermelon/dev-env]' "required repos are not listed" "${pbody}" +# The alerts section lists repos too, so look only at the protection section. +sed -n '/^## Branch protection/,/^## CodeQL/p' "${pbody}" >"${WORK}/prot-section.md" +_hasnt '[twistedmelonman/Instapaper-MCP]' "exempt repos are not listed" "${WORK}/prot-section.md" +_hasnt '[smartwatermelon/dev-env]' "required repos are not listed" "${WORK}/prot-section.md" _has '| [nightowlstudiollc/nos-private](https://github.com/nightowlstudiollc/nos-private) | UNPROTECTED: the default branch has no protection |' \ "an unprotected repo is listed with a link" "${pbody}" _has '| [nightowlstudiollc/nostd](https://github.com/nightowlstudiollc/nostd) | standards-check not required: requires only: test |' \ @@ -366,7 +371,7 @@ _has '- fork: 1 — a fork is not ours to scan (decided 2026-10-21, dev-env#166) "CodeQL exemptions are counted by class with the reason" "${pbody}" _hasnt '- no-code-security:' "no-code-security is not an exempt class" "${pbody}" _hasnt '| [nightowlstudiollc/cleanroom](' "a repo without Code Security is not a table row" "${pbody}" -nocs_list="$(sed -n '/^### Without paid Code Security/,/^\(## \|---\|\*\*Not listed\)/p' "${pbody}")" +nocs_list="$(sed -n '/^### Without paid Code Security/,/^## Dependabot alerts/p' "${pbody}")" if [[ "${nocs_list}" == *"not bought, by decision (2026-10-21"* \ && "${nocs_list}" == *"Semgrep CE is the substitute"* \ && "${nocs_list}" != *"open cost decision"* \ @@ -407,14 +412,14 @@ _has '| [nightowlstudiollc/tnjcleaning](https://github.com/nightowlstudiollc/tnj "a not-configured repo is listed with its languages" "${pbody}" _has '| [nightowlstudiollc/nos-private](https://github.com/nightowlstudiollc/nos-private) | UNREADABLE: cannot read default setup: ' \ "a permission 403 is listed as unreadable with its reason" "${pbody}" -cq_body="$(sed -n '/^## CodeQL/,/^---/p' "${pbody}")" +cq_body="$(sed -n '/^## CodeQL/,/^## Dependabot alerts/p' "${pbody}")" for r in smartwatermelon/dev-env twistedmelonman/Instapaper-MCP nightowlstudiollc/nostd; do if [[ "${cq_body}" == *"[${r}]"* ]]; then _fail "configured or exempt repo ${r} is listed"; else _pass "configured or exempt repo ${r} is counted, not listed"; fi done # Every o and p read failed: a token without Administration: Read must not # render as a clean fleet. -cq_all="$(sed -n '/^## CodeQL/,/^---/p' "${body}")" +cq_all="$(sed -n '/^## CodeQL/,/^## Dependabot alerts/p' "${body}")" if [[ "${cq_all}" == *"**CodeQL setup could not be verified for any of 8 repositories.**"* \ && "${cq_all}" == *'Administration: Read'* && "${cq_all}" != *"No gaps"* \ && "${cq_all}" != *"repositories surveyed"* ]]; then @@ -430,13 +435,79 @@ _hasnt '| Repo | CodeQL default setup |' "a fully configured CodeQL survey has n # A repo without Code Security alone is a gap, not "No gaps", and not a table row. jq -c 'select(.repo == "smartwatermelon/dev-env" or .repo == "nightowlstudiollc/cleanroom")' "${prot}" >"${WORK}/cqnocs.ndjson" : | bash "${DIR}/render.sh" --standards "${WORK}/cqnocs.ndjson" >"${WORK}/cqnocs.md" 2>/dev/null -sed -n '/^## CodeQL/,/^---/p' "${WORK}/cqnocs.md" >"${WORK}/cqnocs-section.md" +sed -n '/^## CodeQL/,/^## Dependabot alerts/p' "${WORK}/cqnocs.md" >"${WORK}/cqnocs-section.md" _hasnt 'No gaps' "a repo without Code Security is never reported as no gaps" "${WORK}/cqnocs-section.md" _has '- [nightowlstudiollc/cleanroom]' "a lone repo without Code Security is listed by name" "${WORK}/cqnocs.md" _hasnt '| Repo | CodeQL default setup |' "a repo without Code Security is not a table row" "${WORK}/cqnocs.md" _has '| [o/legacy](https://github.com/o/legacy) | UNREADABLE: no CodeQL result recorded |' \ "a record without a codeql field is unreadable, not clean" "${WORK}/legacy.md" +# Dependabot alerts. 2026-10-21 captures: dev-env 204, +# Instapaper-MCP 404. The 403 is modeled, not captured. +_al() { + local got + got="$(jq -r --arg r "$1" 'select(.repo == $r) | .alerts.state // "none"' "${prot}")" + if [[ "${got}" == "$2" ]]; then _pass "$3"; else _fail "$3 (alerts: '${got}', want '$2')"; fi +} +_al smartwatermelon/dev-env on "a 204 is on" +_al nightowlstudiollc/cleanroom on "a 204 on a private repo is on" +_al twistedmelonman/Instapaper-MCP off "a 404 saying alerts are disabled is off, even on a fork" +_al nightowlstudiollc/tnjcleaning off "a disabled 404 on a public non-fork repo is off" +_al nightowlstudiollc/nos-private unreadable \ + "KNOWN-BAD: a bare 'Not Found' 404 is unreadable, never off" +_al smartwatermelon/refused unreadable \ + "KNOWN-BAD: a 'Resource not accessible by integration' 403 is unreadable, never off" +_al smartwatermelon/nullflag unreadable \ + "KNOWN-BAD: the disabled message on a 403 is unreadable, never off" +_al smartwatermelon/withheld unreadable "a read that returns a body is unreadable, not on" +_al twistedmelonman/tm-old none "an archived repo has no alerts result" +if jq -e 'select(.repo == "smartwatermelon/refused") | .alerts.detail | test("Resource not accessible by integration")' "${prot}" >/dev/null 2>&1; then + _pass "the reason an alerts read failed is kept" +else + _fail "the refused alerts read lost its reason" +fi + +al_body="$(sed -n '/^## Dependabot alerts/,/^---/p' "${pbody}")" +if [[ "${al_body}" == *'**17 repositories surveyed**: 2 on, **2 off, 13 unreadable.**'* ]]; then + _pass "the alerts summary counts each state" +else + _fail "the alerts summary is wrong: ${al_body}" +fi +if [[ "${al_body}" == *'| [twistedmelonman/Instapaper-MCP](https://github.com/twistedmelonman/Instapaper-MCP) | OFF: Dependabot security alerts are turned off |'* \ + && "${al_body}" == *'| [nightowlstudiollc/nos-private](https://github.com/nightowlstudiollc/nos-private) | UNREADABLE: cannot read the alerts setting: gh: Not Found (HTTP 404) |'* \ + && "${al_body}" != *'[smartwatermelon/dev-env]'* && "${al_body}" != *'[nightowlstudiollc/cleanroom]'* \ + && "${al_body}" != *'No gaps'* ]]; then + _pass "off and unreadable repos are listed; on repos are counted, not listed" +else + _fail "the alerts table is wrong: ${al_body}" +fi +# Off rows sort above unreadable rows. +first_row="$(grep -m1 '^| \[' <<<"${al_body}")" +if [[ "${first_row}" == *"| OFF:"* ]]; then + _pass "off repos are listed before unreadable ones" +else + _fail "the first alerts row is not an off repo: ${first_row}" +fi + +# Every o and p read failed: a token without Administration: Read must not +# render as alerts on everywhere. +al_all="$(sed -n '/^## Dependabot alerts/,/^---/p' "${body}")" +if [[ "${al_all}" == *"**Alerts could not be verified for any of 8 repositories.**"* \ + && "${al_all}" == *'Administration: Read'* && "${al_all}" != *"No gaps"* \ + && "${al_all}" != *"repositories surveyed"* && "${al_all}" == *'**Not listed, so not surveyed:** q.'* ]]; then + _pass "an all-unreadable alerts survey says it verified nothing and names the likely permission" +else + _fail "an all-unreadable alerts survey read as a result: ${al_all}" +fi + +jq -c 'select(.repo == "smartwatermelon/dev-env" or .repo == "nightowlstudiollc/cleanroom")' "${prot}" >"${WORK}/alclean.ndjson" +: | bash "${DIR}/render.sh" --standards "${WORK}/alclean.ndjson" >"${WORK}/alclean.md" 2>/dev/null +_has 'No gaps: every surveyed repository has Dependabot security alerts on.' \ + "an all-on alerts survey says so" "${WORK}/alclean.md" +_hasnt '| Repo | Dependabot alerts |' "an all-on alerts survey has no table" "${WORK}/alclean.md" +_has '| [o/legacy](https://github.com/o/legacy) | UNREADABLE: no alerts result recorded |' \ + "a record without an alerts field is unreadable, not on" "${WORK}/legacy.md" + if [[ "${fail}" -eq 0 ]]; then echo "test-standards: all assertions passed" else