Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/dependabot-digest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions docs/runbooks/dependabot-digest-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions scripts/dependabot-digest/render.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "---"
Expand Down
54 changes: 49 additions & 5 deletions scripts/dependabot-digest/standards.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Usage: standards.sh <owner>. 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

Expand Down Expand Up @@ -36,17 +37,18 @@ 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),
runUrl: (if $url == "" then null else $url end),
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
Expand Down Expand Up @@ -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 <state> <detail>
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')" \
Expand Down Expand Up @@ -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}"
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"message": "Vulnerability alerts are disabled.",
"status": "403"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "enabled": true }
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 10 additions & 2 deletions scripts/dependabot-digest/tests/test-render.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading