Skip to content

Harden the supply chain, fix the purge tool, add log-based alerting - #5

Merged
Gerrrt merged 8 commits into
mainfrom
chore/post-merge-hardening
Aug 2, 2026
Merged

Harden the supply chain, fix the purge tool, add log-based alerting#5
Gerrrt merged 8 commits into
mainfrom
chore/post-merge-hardening

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Five changes. Every defect below was invisible to inspection and only appeared when something actually ran.

1. The history-purge tool was a copy of the secret it purges

Running make purge-history-dry-run against real history exposed three defects in the script shipped in #1.

It embedded the leaked SNMP community as a literal, and the runbook quoted it three more times — so a successful rewrite left the secret in the working tree, inside the tool written to remove it. The comment above the literal claimed the opposite ("kept here … so the string itself is not re-committed"), which was exactly backwards: the script is tracked.

.gitleaks.toml allowlisted both files by path, configuring the scanner not to report a leak that was genuinely there.

The verification failed open. It detected leaks via the exit status of git rev-list --all | xargs -I{} git grep -q <literal> {}. xargs returns 123 when any invocation exits 1–125, so a literal present in some commits but not others makes the pipeline non-zero and the check reports clean. Measured here: a string present in 17 of 49 commits was reported absent. Under set -o pipefail that same 123 also aborted the script before it printed anything.

Literals now come from a gitignored .purge-secrets.txt; detection is by output presence with the pipeline guarded, uses -F so a rotated community containing regex metacharacters cannot slip through, scans the rewritten worktree as well as history, and names the offending file.

NEGATIVE (string that survives the rewrite) — must FAIL
  FAIL  a redacted literal is still present
        history:  9aceb0d…:scripts/purge-history.sh
        worktree: /tmp/…/repo/scripts/purge-history.sh

POSITIVE (the real community string) — must PASS
  PASS  no redacted literal in any commit or in the worktree

2. Loki had a ruler and no rules

8 rules in loki/rules/security.rules.yaml for conditions that only exist in logs: SSH brute force (warning at 20 failures/5m, critical at 100), SSH accepted from outside VLAN 50/99, sudo failures, user/group creation, kernel OOM kills, read-only remounts, disk I/O errors. Same severity/category labels as the Prometheus rules, so routing and inhibition are shared. 40 rules total.

  • Loki's local ruler reads <directory>/<tenant>/, and with auth_enabled: false the tenant is literally fake. Getting it wrong produces no error — just a ruler that silently evaluates nothing.
  • A "host stopped logging" rule was written and then removed. A silent host produces no series, so rate(...) == 0 never evaluates for it. A rule that cannot fire is worse than no rule.

promtool can't validate LogQL. scripts/check_loki_rules.sh boots the pinned Loki image with the rules mounted, fails on a parse error, then asserts the ruler actually evaluated them. loki -verify-config is deliberately not the check — it never opens the rule files, confirmed by experiment: count_over_time({{{BROKEN passes it.

3. CI was validating the wrong versions

Dependabot's bumps merged to main while this branch was open, exposing a design flaw in the CI added in #1 — image pins were duplicated across four files, and Dependabot only updates compose.yaml.

compose.yaml ci.yml + scripts
Prometheus v3.13.2 v3.1.0
Loki 3.7.4 3.3.2
Alloy v1.18.0 v1.6.1

CI was validating against versions roughly two years older than what deploys — and reporting green while doing it. scripts/image-for.sh now reads the pin from compose.yaml, CI resolves images into $GITHUB_ENV at run time, and a new step fails on any pinned version outside compose.yaml.

4. Supply chain: pinned by digest

A tag is a mutable pointer — whatever the publisher last pushed, movable silently by a compromised or coerced one. A digest is the content hash.

All six images are now repo:tag@sha256:…, keeping both halves: the tag stays readable, the digest is what Docker enforces. Dependabot updates both together. scripts/pin-digests.sh resolves through the registry HTTP API rather than docker pull, so it needs no daemon and downloads no layers; bare it reports drift and exits non-zero, --write applies. CI asserts every image carries a digest.

Two things this surfaced:

  • The Makefile derives snmp-generator from the snmp-exporter pin, which with digests produced snmp-generator carrying the exporter's digest — unpullable. image-for.sh grew --tag-only.
  • The README badges hardcoded Prometheus-v3.1.0, Grafana-11.5, Loki-3.3 against a stack running v3.13.2 / 13.0.2 / 3.7.4 — the same drift as §3, in the first thing a reader sees. Version numbers removed rather than left to rot; nothing could have kept them honest.

5. SECURITY.md

Private disclosure via GitHub Security Advisories, an explicit request not to probe the live network, the three known historical exposures with current status, what the repository deliberately never publishes, and the controls CI enforces. The exposure is documented rather than quietly carried — a written-down, accepted exposure reads very differently from an overlooked one.

What the CI failures taught

Both were in the harness, not the rules, and the second is the more interesting.

  1. mktemp -d creates a 0700 directory and the Loki image runs as uid 10001, so the container could not read its own config and died in three seconds. Local runs were as root, which hid it.
  2. After that, Loki ran the full 45s and still evaluated nothing. The check was non-deterministic: Loki jitters a group's first evaluation across its interval, and the groups use interval: 1m. Locally the jitter landed early and passed; in CI it landed late and failed — a timing artifact masquerading as a broken tenant path. Throwaway copies now get interval: 5s; committed rules keep 1m, LogQL untouched.

Review feedback

All seven comments were correct and are addressed. The notable one: I had allowlisted .purge-secrets.txt in .gitleaks.toml — the same mistake this PR exists to fix. Removed. But removing it does not make gitleaks catch an accidental commit: the file is gitignored so the scan skips it, and it holds bare literals with no keyword context. Verified by force-adding and rescanning. The real control is an assertion that it is never tracked, in both CI and validate.sh, confirmed to fire.

Verification

CI green on all three jobs. Local sweep green: yamllint, shellcheck (7 scripts), markdownlint, gitleaks (tree + history), docker compose config, dashboards, digest drift, no-duplicate-pins, and the Loki rules check. All 57 relative links across the docs resolve.

Checked against the versions actually deployed, not the ones already downloaded:

  • Loki rules parse and evaluate on 3.7.4 (8/8); loki-config.yaml valid on 3.7.4.
  • config.alloy passes fmt --test on v1.18.0 with an empty diff against canonical formatting — the formatter did not change behaviour across twelve minor versions.
  • Digests resolve for all six images; re-running reports them unchanged.

Not verified: the stack still has not been started end-to-end — no Docker daemon in the authoring environment. Loki and Alloy were exercised as standalone binaries, which is what validates the rules and config, but the composed stack has not run.

Note

None of this unblocks the manual work — rotating the SNMP communities, running the purge, and make secrets-init still need host and device access. It does make the purge safe to actually run.

Gerrrt added 2 commits August 2, 2026 14:29
… purges

Running the dry run against real history exposed three defects in this script,
none of which were visible by reading it.

1. The script embedded the leaked SNMP community as a literal, and the runbook
   quoted it three more times. So a *successful* history purge left the secret
   sitting in the working tree — inside the tool written to remove it. The
   comment above the literal even claimed the opposite ("kept here ... so the
   string itself is not re-committed"), which was exactly backwards: the script
   is tracked, so the string was committed.

   Literals now come from a gitignored .purge-secrets.txt written by the
   operator at purge time, via --secrets-file. The repository no longer contains
   the string anywhere.

2. .gitleaks.toml allowlisted both files by path, so the scanner was configured
   not to report a leak that was genuinely there. Those two entries are gone.
   Only the gitignored secrets list is allowlisted now.

3. The verification step failed open. It detected leaks via the exit status of

     git rev-list --all | xargs -I{} git grep -q <literal> {}

   but xargs returns 123 when any invocation exits 1-125, so a literal present
   in some commits and absent from others made the pipeline non-zero and the
   check reported clean. Measured on this repository: a string present in 17 of
   49 commits was reported absent. Under `set -o pipefail` the same 123 also
   aborted the script before the check printed anything.

   Detection is now by output presence with the pipeline guarded, it scans the
   rewritten worktree as well as history, and it names the offending file.

Verified both directions rather than only the happy path: purging a string that
genuinely survives the rewrite now reports FAIL and points at
scripts/purge-history.sh; purging the real community string reports PASS. The
real repository is untouched by the dry run (50 commits before and after).
The Loki ruler was configured and pointed at Alertmanager but had no rules, so
the log pipeline was a search box rather than something that could tell you
anything unprompted.

Adds 8 rules in loki/rules/security.rules.yaml covering conditions that only
exist in logs: SSH brute force (warning at 20 failures in 5m, critical at 100),
SSH accepted from outside VLAN 50/99, repeated sudo failures, user and group
creation, kernel OOM kills, read-only remounts, and disk I/O errors. They carry
the same severity and category labels as the Prometheus rules, so Alertmanager
routing and inhibition are shared with no extra configuration.

Two things this surfaced:

  * Loki's local ruler reads <directory>/<tenant>/ and, with auth_enabled
    false, the tenant is literally "fake". Getting that path wrong produces no
    error at all — just a ruler that silently evaluates nothing. The ruler
    directory moved out of /loki so the read-only rule mount does not collide
    with the data volume.

  * A rule alerting on a host that stops logging was written and then removed.
    When a host goes quiet it produces no series, so `rate(...) == 0` never
    evaluates for it, and LogQL has no way to supply a known-host list. The
    global case is already covered by LokiIngestionStalled on the Prometheus
    side.

Validation: promtool cannot check these — it parses PromQL and rejects every
LogQL stream selector. scripts/check_loki_rules.sh boots the pinned Loki image
with the rules mounted, fails on a parse error, and then asserts the ruler
actually evaluated them, since silence is not evidence when the alternative is
that it never read the files.

`loki -verify-config` is deliberately not used as the check: it validates the
config file and never opens the rule files. Confirmed by experiment — a rule
file containing `count_over_time({{{BROKEN` passes -verify-config and is caught
only by the boot check.

Verified in both directions against the real Loki v3.3.2 binary: all 8 rules
parse and are evaluated by the ruler across both groups, and deliberately
breaking one LogQL expression makes the checker exit 1 and name the rule.
Copilot AI review requested due to automatic review settings August 2, 2026 14:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the repository’s git-history purge workflow to avoid reintroducing secrets during rewrites, and adds Loki ruler-based (LogQL) alerting with CI validation so log-only security/system conditions can page via the existing Alertmanager routing.

Changes:

  • Refactors scripts/purge-history.sh to read purge literals from a gitignored .purge-secrets.txt and improves post-rewrite verification.
  • Adds Loki log-based alert rules and wires the ruler rule directory/tenant mount correctly in Loki config + compose.
  • Introduces scripts/check_loki_rules.sh and runs it from both scripts/validate.sh and CI.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
stacks/observability/loki/rules/security.rules.yaml Adds 8 LogQL alert rules for auth/system/security signals.
stacks/observability/loki/loki-config.yaml Moves local ruler rules directory to /etc/loki/rules with tenant-path commentary.
stacks/observability/compose.yaml Mounts rules into /etc/loki/rules/fake to match the auth_enabled: false tenant.
scripts/validate.sh Runs the new Loki rules validation script during local validation.
scripts/purge-history.sh Removes hardcoded secret literal, reads from .purge-secrets.txt, and improves leak detection/reporting.
scripts/check_loki_rules.sh New script to boot Loki and validate rule parsing/evaluation.
Makefile Adds check-loki-rules target.
docs/runbooks/purge-git-history.md Updates purge runbook to use .purge-secrets.txt-based workflow.
docs/roadmap.md Marks Loki rules work item complete.
docs/observability.md Documents Loki ruler alerting, tenant path behavior, and the validation approach.
.gitleaks.toml Updates allowlist paths (removes prior allowlist entries; adds .purge-secrets.txt).
.gitignore Ignores .purge-secrets.txt.
.github/workflows/ci.yml Adds CI step to validate Loki rules.
Suppressed comments (2)

docs/runbooks/purge-git-history.md:69

  • This git log -S "$(cat .purge-secrets.txt)" check has the same multi-line issue as above: it won’t work if .purge-secrets.txt contains more than one literal. Loop over each literal so the “must be empty” assertion is meaningful.
cd /tmp/<scratch>/repo
git log --oneline | head
git log --all -S "$(cat .purge-secrets.txt)" --oneline   # must be empty
**docs/runbooks/purge-git-history.md:117**
* `git grep -I -e "$(cat .purge-secrets.txt)" ...` will not work with a multi-line secrets file (newlines become part of the pattern), and it also treats the literal as a regex. Loop over the file and use `-F` so secrets with regex metacharacters are checked correctly.
git log --all --oneline -- certificates/            # empty
git grep -I -e "$(cat .purge-secrets.txt)" $(git rev-list --all)   # no matches
gitleaks detect --no-banner --redact -c .gitleaks.toml --log-opts="--all"

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/check_loki_rules.sh Outdated
#
# Usage: scripts/check_loki_rules.sh

set -uo pipefail
Comment on lines +36 to +38
# Rewrite every path in the real config to point inside the scratch dir, so the
# rules are checked against the same settings production uses.
python3 - "$STACK/loki/loki-config.yaml" "${WORK}" > "${WORK}/loki.yaml" <<'PY'
Comment on lines +77 to +83
# Confirm the ruler actually loaded them. A silent "no errors" is not evidence
# if the ruler never read the files at all.
evaluated="$(grep -o 'rule_name=[A-Za-z]*' "${OUT}" | sort -u | wc -l)"
if ((evaluated == 0)); then
printf '\033[0;31m FAIL\033[0m the ruler evaluated no rules — check the tenant path\n'
exit 1
fi
Comment thread .github/workflows/ci.yml
Comment on lines +109 to +114
# promtool cannot check these — it parses PromQL and rejects every LogQL
# stream selector. Loki itself is the only thing that understands them,
# so the checker boots the pinned image with the rules mounted.
- name: Validate Loki rules
run: ./scripts/check_loki_rules.sh

Comment thread scripts/purge-history.sh
Comment on lines +127 to +134
hit="$(git -C "${target}" rev-list --all \
| xargs -r -I{} git -C "${target}" grep -lI -e "${literal}" {} -- 2>/dev/null \
| head -n1 || true)"
[[ -n "${hit}" ]] && { leaked=1; printf ' history: %s\n' "${hit}"; }

hit="$(grep -rlI -e "${literal}" "${target}" \
--exclude-dir=.git --exclude="${ignore_name}" 2>/dev/null | head -n1 || true)"
[[ -n "${hit}" ]] && { leaked=1; printf ' worktree: %s\n' "${hit}"; }
Comment thread docs/runbooks/purge-git-history.md Outdated
```bash
git show 647d90a~1:certificates/Gandalf.Gondor.Lab/ca-key.pem | head -1
git log --all -S '7H3r315N05p00N' --oneline
git log --all -S "$(cat .purge-secrets.txt)" --oneline
Comment thread .gitleaks.toml Outdated
Comment on lines +51 to +53
# The list of literals to purge, written by the operator at purge time.
# Gitignored; never committed.
'''\.purge-secrets\.txt$''',
Gerrrt added 6 commits August 2, 2026 14:47
CI failed with 'the ruler evaluated no rules'. The guard did its job — it
refused to report success when nothing had been validated — but the cause was
the harness, not the rules.

mktemp -d creates a 0700 directory owned by the invoking user, and the Loki
image runs as uid 10001. In CI the container therefore could not traverse its
own working directory, exited after three seconds, and evaluated nothing. The
local run passed because the loki binary ran as root against the same paths.

Fixes:
  * chmod -R a+rwX the throwaway directory, applied again after the generated
    config is written so the container can read that too. Confirmed the barrier:
    the directory is 0700 before and 0777 after.
  * Distinguish the two failure modes in the output. Exit status 124 means loki
    ran the full window and genuinely evaluated nothing (a tenant path problem);
    anything else means it died early. Either way the last 15 log lines are now
    printed, so the next failure explains itself instead of needing a local
    reproduction.
  * Guard the PyYAML import. It is not guaranteed on a clean runner and the
    failure without this is an opaque ModuleNotFoundError inside a heredoc.

Verified: shellcheck clean, the check still passes against the real Loki v3.3.2
binary (8 rules parsed, 8 evaluated), and the short-boot path prints the
diagnostic rather than a bare failure.
All correct, and one of them repeats the exact mistake this PR was opened to fix.

* .gitleaks.toml allowlisted .purge-secrets.txt by path — the same "teach the
  scanner to ignore a real finding" pattern the purge fix removes. Entry gone.

  Worth being precise about what that does and does not buy: removing it does
  not make gitleaks catch an accidental commit of that file. It is gitignored,
  so the filesystem scan skips it, and it holds bare literals with no keyword
  context for any rule to match. Verified by force-adding it and re-scanning —
  still clean. The real control is an assertion that the file is never tracked,
  now in both CI and scripts/validate.sh alongside the existing .env and
  .rendered/ checks, and confirmed to fire.

* Leak detection used grep without -F. A rotated community is a random string
  and may contain regex metacharacters; without -F those are either mis-parsed
  or make grep error out, and a grep that errors reports no match — one more way
  to fail open, which is the failure mode this script already had once. Both the
  history and worktree scans now use -F. Verified with the literal a[b(c).*+?d.

* The runbook used `git log -S "$(cat .purge-secrets.txt)"`, which folds a
  multi-line file into one search string and matches nothing. The file format is
  documented as one literal per line, so all three call sites now loop, and the
  git grep example uses -F.

* check_loki_rules.sh ran under `set -uo pipefail` without -e, so a failed cp or
  config rewrite could still reach a cheerful PASS. Now `set -euo pipefail`,
  with the one command expected to fail — the timeout that kills Loki — guarded
  explicitly.

* The evaluated-rule count matched rule_name=[A-Za-z]*, which can match empty
  and misses digits and underscores in alert names. Now anchored to a valid
  identifier with the optional opening quote stripped.

The PyYAML preflight raised in review was already fixed in ddd4d71.

Re-verified after the changes: shellcheck clean across all five scripts, the
Loki check still passes against the real v3.3.2 binary (8 parsed, 8 evaluated)
and still fails on a deliberately broken expression, and the purge dry run
passes for both a metacharacter-laden literal and the real one.
…erval

The permission fix worked — CI shows Loki running the full 45s and shutting down
cleanly, so it was reading its config. It still reported no rules evaluated.

Cause is timing, not configuration. The rule groups use interval: 1m, and Loki
jitters a group's first evaluation across its interval, so a 45s window can
legitimately see none. Locally the jitter happened to land early, which is why
it passed here and failed there — the check was non-deterministic in a way that
made a real timing issue look like a broken tenant path.

The throwaway copies of the rules now have their group interval rewritten to 5s
before Loki starts. The committed rules keep interval: 1m; only the scratch copy
is touched, and the LogQL is never modified. A 25s window is now sufficient
where 75s was previously marginal, so the 45s default has real headroom.

Also: the container now runs with --user "$(id -u):$(id -g)". Without it Loki
writes its WAL and index as uid 10001, the runner cannot delete the scratch
directory, and cleanup floods the log with "rm: Permission denied" immediately
after the verdict. The cleanup trap is non-fatal regardless, so a cleanup
failure can never mask the result.

Verified: 8 rules parsed and 8 evaluated within a 25s window, the deliberately
broken expression still exits 1 and names the failing alert, and the committed
rules file is unchanged at interval: 1m.
…ing them

Dependabot merged version bumps into main while this branch was open, which
exposed a design flaw in the CI added in #1: the image pins were duplicated
across compose.yaml, ci.yml, validate.sh and check_loki_rules.sh, and Dependabot
only updates compose.yaml.

The result after those merges:

  compose.yaml   prom/prometheus:v3.13.2   grafana/loki:3.7.4   grafana/alloy:v1.18.0
  ci.yml         prom/prometheus:v3.1.0    (n/a)                grafana/alloy:v1.6.1
  scripts        prom/prometheus:v3.1.0    grafana/loki:3.3.2   grafana/alloy:v1.6.1

CI was set to validate configs against versions two years older than the ones
the stack deploys, and would have reported green while doing it. Validating the
wrong version is worse than not validating, because it still produces a passing
check.

scripts/image-for.sh now reads the pin straight out of compose.yaml, and ci.yml
resolves the images into $GITHUB_ENV at run time rather than declaring them.
compose.yaml is the single source of truth, so a Dependabot bump automatically
reaches every check. The Makefile's snmp-generator is derived from the
snmp-exporter pin — the two are released together and the generator is not a
compose service, so it cannot be looked up directly.

A new CI step fails on any pinned image version outside compose.yaml, so the
duplication cannot creep back in. The gitleaks image stays declared in ci.yml
because it is a CI tool rather than part of the stack.

Verified against the versions actually deployed, not the ones I had lying
around: the Loki rules parse and evaluate on 3.7.4 (8/8), loki-config.yaml is
valid on 3.7.4, and config.alloy passes fmt --test on v1.18.0 with an empty
diff against its canonical formatting — so the formatter did not change
behaviour between v1.6.1 and v1.18.0.

Also corrects comments that named specific tool versions now resolved
dynamically.
## Digest pinning

A tag is a mutable pointer. `prom/prometheus:v3.13.2` is whatever the publisher
last pushed under that name, and a tag can be moved — silently, by a compromised
or coerced publisher. A digest is the content hash: it matches or the pull fails.

Every service image is now `repo:tag@sha256:...`, keeping both halves. The tag
stays readable and says which version you are on; the digest is what Docker
enforces. Dependabot understands this form and updates both together.

scripts/pin-digests.sh resolves digests through the registry HTTP API rather
than `docker pull`, so it needs no daemon and downloads no layers. Without
arguments it reports drift and exits non-zero, so it doubles as a check;
--write applies. CI additionally asserts every compose image carries a digest.

Two things this surfaced:

  * The Makefile derives prom/snmp-generator from the snmp-exporter pin, and
    with digests that silently produced snmp-generator tagged with the
    *exporter's* digest — a reference that cannot be pulled. image-for.sh grew
    a --tag-only mode for exactly this case.

  * The README badges hardcoded Prometheus v3.1.0, Grafana 11.5 and Loki 3.3
    while the stack ran v3.13.2, 13.0.2 and 3.7.4 — the same drift class fixed
    in the previous commit, in the first thing a reader sees. Version numbers
    are gone from the badges rather than left to rot again; there is no
    mechanism that could keep them honest.

## SECURITY.md

A security-focused repository with no disclosure policy is a gap. It covers how
to report privately (GitHub Security Advisories), an explicit request not to
probe the live network, the three known historical exposures with their current
status, what the repository deliberately never publishes and why, and the
controls CI enforces.

It documents the known exposure rather than quietly carrying it: a written-down,
accepted exposure reads very differently from an overlooked one.

Verified: digests resolve for all six images and re-running reports them
unchanged; the generator derivation yields prom/snmp-generator:v0.30.1 with no
digest; all 57 relative links across the docs resolve; and the full local sweep
passes including the new digest check.
@Gerrrt Gerrrt changed the title Fix the history-purge tool, and add log-based alerting Harden the supply chain, fix the purge tool, add log-based alerting Aug 2, 2026
@Gerrrt
Gerrrt merged commit e172d6c into main Aug 2, 2026
3 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.

2 participants