Skip to content

Commit 27652df

Browse files
AviOfLagosEllumAIGITclaude
authored
docs: generate the release notes from CHANGELOG.md, and release 1.2.0 (#42)
The release notes were written twice — once in CHANGELOG.md and once as 233 lines of hand-written HTML in docs/src/changelog.html. Nothing kept them in agreement, so the site could tell someone a fix had shipped when it had not, which for this project is the same category of problem as a scanner reporting clean on an infected repository. CHANGELOG.md is the single source now. docs/changelog-gen.py renders it into the page's existing markup: releases become sections, "### Fixed — false clean results" and "### Fixed — destructive behaviour" mark their group and items critical, (#41) becomes a link to the pull request, and @name becomes a profile link. Update the changelog in a pull request and the website follows on the next build — no second edit, and no way to drift. CI fails if SNARE_VERSION has no matching CHANGELOG.md entry, or if the generator produces no section for it. A version bump that forgets the changelog would otherwise publish a page that silently omits the release. 1.2.0 itself is mostly other people's work: the remediation fix from @phoenixdahdev (#39), which stopped `fix` deleting the build config it was supposed to repair, and the new campaign it documents — npm's own lib/cli.js rewritten in place, so every `npm` invocation ran the loader. Co-authored-by: Avioflagos <ellumainc@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 19b1132 commit 27652df

17 files changed

Lines changed: 414 additions & 390 deletions

‎.github/workflows/ci.yml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ jobs:
5353
[ "$missing" = 0 ] && echo "help covers every top-level command"
5454
exit $missing
5555
56+
# The site's release notes are generated from CHANGELOG.md, so a version
57+
# bump with no changelog entry would ship a page that silently omits the
58+
# release. Fail here rather than there.
59+
- name: Version has a changelog entry, and the site regenerates
60+
run: |
61+
v="$(grep -m1 '^SNARE_VERSION=' bin/snare | cut -d'"' -f2)"
62+
grep -q "^## \[$v\]" CHANGELOG.md \
63+
|| { echo "::error::SNARE_VERSION is $v but CHANGELOG.md has no ## [$v] entry"; exit 1; }
64+
echo "changelog has an entry for $v"
65+
python3 docs/changelog-gen.py CHANGELOG.md > /tmp/rel.html
66+
grep -q "id=\"v$(echo "$v" | tr . -)\"" /tmp/rel.html \
67+
|| { echo "::error::changelog-gen produced no section for $v"; exit 1; }
68+
echo "release notes render for $v"
69+
5670
- name: Detection works (selftest)
5771
run: |
5872
chmod +x bin/snare

‎CHANGELOG.md‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,88 @@ Campaign `A8-4893-2`, two stages with two unrelated C2 addresses:
182182
and that a genuine font, a clean source file and an honest build task are left
183183
alone. 24 checks in total.
184184

185+
## [1.2.0] — 2026-09-10
186+
187+
Remediation could delete the file it was meant to clean. If you ran
188+
`snare fix --push` on a release before this one, check the repository: a build
189+
config may have been removed rather than repaired.
190+
191+
### Fixed — destructive behaviour
192+
193+
- **`fix` deleted the build config instead of cleaning it.** The delete pass
194+
removed any file matching an IOC string, and a payload appended to
195+
`postcss.config.mjs` *is* an IOC string — so the file was gone before the
196+
strip pass further down the same function could reach it. It pushed a commit
197+
that removed the malware and the project's build with it. Stripping runs
198+
first now, and a file the project needs is cleaned and kept, never deleted.
199+
Reported and fixed by @phoenixdahdev. (#39)
200+
- **`.vscode/tasks.json` was deleted whole**, losing honest build tasks along
201+
with the injected `folderOpen` one. Only the malicious task goes now. (#39)
202+
- **A payload on a line of its own was never removed.** Both cleaners keyed on
203+
the one-line signature. The new rule is gated narrowly — long, minified in
204+
shape, a hard campaign marker *and* obfuscated code — because dropping a
205+
whole line on a weaker signal is itself destructive. (#39)
206+
207+
### Fixed — detection and coverage
208+
209+
- **Worm artifacts and fake assets are now found by filename and magic bytes**
210+
during remediation, not only during scanning: `scan` reported them and `fix`
211+
walked straight past. (#39)
212+
- **npm's own `lib/cli.js` is checked.** A live host was found with ~1.4MB
213+
appended to it after 200 spaces, so every `npm` invocation ran the loader —
214+
`--ignore-scripts` does not help, because it is the package manager itself
215+
and not a package script. New campaign `A8-4893-2`, with two C2 addresses and
216+
a socket.io RAT held by a crontab `@reboot` line. (#39)
217+
- The doctor fixture path is normalised, so the npm check no longer failed on
218+
macOS while passing on Linux — the suite reported "detection is broken" for a
219+
detector that was working correctly. (#39)
220+
221+
### Fixed — contribution and platform
222+
223+
- **No pull request from a fork could ever pass CI.** `ci.yml` passed
224+
`github.head_ref` to the reusable workflow, which then tried to check that
225+
ref out of this repository — a fork's branch does not exist here, so checkout
226+
failed three times before a single check ran. The first outside contribution
227+
hit it, and the failure looked like the contributor's fault rather than ours.
228+
It uses `refs/pull/N/head` now. (#40)
229+
- **Windows `guard install` failed with no explanation.** `schtasks.exe` needs
230+
a Windows path and was handed an MSYS one, and every error was discarded. It
231+
now converts the path, prints the real error, and falls back to a Startup
232+
entry that needs no administrator rights. (#32)
233+
- snare flagged its own plugin manifest and skill, which quote the malware's
234+
keywords because they describe it. (#38)
235+
236+
### Added
237+
238+
- **`snare respond`** — one guided clean-up instead of nine commands, in the
239+
order that actually works: rotate credentials, clean the machine you push
240+
from, then the repositories, then tell your collaborators. Resumable, asks
241+
before every action, refuses to run unattended. (#33, #34)
242+
- **A passive update notice.** Every command now says, at most once a day and
243+
in one line, when the installation is behind. It never blocks — the check is
244+
cached and refreshed off the command path — sends nothing, and is off with
245+
`SNARE_NO_UPDATE_CHECK=1`. The people who most need the fixes are the ones
246+
who cloned once and never thought about it again. (#41)
247+
- **snare ships as a Claude Code plugin**, so a coding agent can find and run
248+
it. (#38)
249+
- An incident-response walkthrough on the site, and a copy-paste prompt for
250+
people who would rather have an AI assistant do the work — written to forbid
251+
the assistant from running anything destructive without asking. (#36)
252+
253+
### Changed
254+
255+
- Post-scan guidance leads with rotating credentials and cleaning your machine,
256+
not with `snare fix`. Cleaning repositories first is wasted work while the
257+
machine that pushes to them is still infected. (#31)
258+
- The notify templates lead with rotation too, and carry the full list
259+
including the clipboard and the Actions workflow that keeps exfiltrating
260+
after the dropper is gone. (#32)
261+
- `snare version` no longer runs a live fetch to decide whether to nudge; a
262+
one-line command took about five seconds on a slow link. (#41)
263+
- The site is a multi-page field guide with corrected document semantics, a
264+
crawler policy naming 29 search and AI crawlers, `llms.txt`, and structured
265+
data on every page. (#33, #35, #37)
266+
185267
## [1.1.0] — 2026-08-29
186268

187269
Everything since the initial release. If you installed snare before this,

‎bin/snare‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# https://github.com/ • MIT
44
set -uo pipefail
55

6-
SNARE_VERSION="1.1.0"
6+
SNARE_VERSION="1.2.0"
77
# Resolve through symlinks: install.sh links this into ~/.local/bin, so
88
# BASH_SOURCE is the link, not the real file. (readlink -f is not portable.)
99
_src="${BASH_SOURCE[0]}"

‎docs/build.sh‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ cd "$(dirname "$0")"
1717

1818
SITE="https://avioflagos.github.io/snare/"
1919
REPO="https://github.com/AviOfLagos/snare"
20+
tmp_rel="$(mktemp "${TMPDIR:-/tmp}/snarerel.XXXXXX")"
21+
trap 'rm -f "$tmp_rel"' EXIT
2022
VERSION="$(sed -n 's/^## \[\([0-9.]*\)\].*/\1/p' ../CHANGELOG.md | head -1)"
2123
[ -n "$VERSION" ] || { echo "could not read version from ../CHANGELOG.md" >&2; exit 1; }
2224

@@ -201,7 +203,21 @@ NAVTOP
201203
NAVBOT
202204

203205
# ---- body ----
204-
grep -v '^<!--#' "$src"
206+
# <!--#releases--> becomes the release notes rendered from CHANGELOG.md.
207+
# They used to be written twice — there and as hand-written HTML here — so
208+
# the site could claim a fix had shipped when it had not. One source now.
209+
if grep -q '<!--#releases-->' "$src"; then
210+
python3 changelog-gen.py ../CHANGELOG.md > "$tmp_rel" \
211+
|| { echo "changelog-gen failed for $src" >&2; exit 1; }
212+
grep -v '^<!--#' "$src" | while IFS= read -r line; do
213+
case "$line" in
214+
*'<!--#releases-->'*) cat "$tmp_rel" ;;
215+
*) printf '%s\n' "$line" ;;
216+
esac
217+
done
218+
else
219+
grep -v '^<!--#' "$src"
220+
fi
205221

206222
# ---- pager ----
207223
if [ -n "$prev" ] || [ -n "$next" ]; then

‎docs/changelog-gen.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
"""Render CHANGELOG.md into the changelog page body.
3+
4+
The release notes used to be written twice — once in CHANGELOG.md and once as
5+
hand-written HTML in docs/src/changelog.html — which meant they could disagree,
6+
and the website could tell someone a fix had shipped when it had not. This
7+
makes CHANGELOG.md the single source: update it in a pull request and the site
8+
follows on the next build.
9+
10+
Mapping:
11+
## [1.2.0] — 2026-09-10 -> a release section, newest marked "Current"
12+
### Fixed — ... -> a group heading; "false clean" or "destructive"
13+
marks the group and its items critical, because
14+
those are the failures that matter most here
15+
- **Lead.** body (#41) -> a list item, refs linked to the pull request
16+
`code` **bold** *em* -> <code> <strong> <em>
17+
"""
18+
import html
19+
import re
20+
import sys
21+
22+
REPO = "https://github.com/AviOfLagos/snare"
23+
24+
25+
def inline(text: str) -> str:
26+
"""Escape, then re-introduce the small set of inline markup we allow."""
27+
t = html.escape(text, quote=False)
28+
t = re.sub(r"`([^`]+)`", r"<code>\1</code>", t)
29+
t = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", t)
30+
t = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<em>\1</em>", t)
31+
# (#41) and (#33, #34) -> links. @user -> a profile link.
32+
def refs(m):
33+
nums = re.findall(r"#(\d+)", m.group(1))
34+
if not nums:
35+
return m.group(0)
36+
joined = ", ".join(f"#{n}" for n in nums)
37+
return f' <a href="{REPO}/pull/{nums[0]}">{joined}</a>'
38+
t = re.sub(r"\s*\(((?:#\d+(?:,\s*)?)+)\)", refs, t)
39+
t = re.sub(r"@([A-Za-z0-9-]+)", r'<a href="https://github.com/\1">@\1</a>', t)
40+
return t
41+
42+
43+
def slug(v: str) -> str:
44+
return "v" + v.replace(".", "-")
45+
46+
47+
def is_critical(heading: str) -> bool:
48+
h = heading.lower()
49+
return "false clean" in h or "destructive" in h
50+
51+
52+
def parse(md: str):
53+
"""-> [ {version, date, lede, groups:[{title, critical, items:[str]}]} ]"""
54+
releases, cur, group = [], None, None
55+
lede_lines, item = [], None
56+
57+
def flush_item():
58+
nonlocal item
59+
if item is not None and group is not None:
60+
group["items"].append(" ".join(item).strip())
61+
item = None
62+
63+
def flush_lede():
64+
nonlocal lede_lines
65+
if cur is not None and lede_lines and not cur["lede"]:
66+
cur["lede"] = " ".join(lede_lines).strip()
67+
lede_lines = []
68+
69+
for raw in md.split("\n"):
70+
line = raw.rstrip()
71+
m = re.match(r"^## \[([0-9][0-9.]*)\]\s*—\s*(.+)$", line)
72+
if m:
73+
flush_item(); flush_lede()
74+
cur = {"version": m.group(1), "date": m.group(2).strip(),
75+
"lede": "", "groups": []}
76+
releases.append(cur); group = None
77+
continue
78+
if cur is None:
79+
continue
80+
m = re.match(r"^### (.+)$", line)
81+
if m:
82+
flush_item(); flush_lede()
83+
group = {"title": m.group(1).strip(),
84+
"critical": is_critical(m.group(1)), "items": []}
85+
cur["groups"].append(group)
86+
continue
87+
m = re.match(r"^- (.+)$", line)
88+
if m:
89+
flush_item()
90+
item = [m.group(1)]
91+
continue
92+
if item is not None and line.startswith(" ") and line.strip():
93+
item.append(line.strip()) # continuation of a list item
94+
continue
95+
if line.strip():
96+
if group is None:
97+
lede_lines.append(line.strip()) # prose under the release
98+
else:
99+
flush_item()
100+
flush_item(); flush_lede()
101+
return releases
102+
103+
104+
def render(releases) -> str:
105+
out = []
106+
for i, r in enumerate(releases):
107+
tag = '\n <span class="tag now">Current</span>' if i == 0 else ""
108+
out.append(f'<section class="stack" id="{slug(r["version"])}">')
109+
out.append(' <div class="rel">')
110+
out.append(' <div class="rel-head">')
111+
out.append(f' <h2 class="rel-v">{html.escape(r["version"])}</h2>')
112+
out.append(f' <span class="rel-date">{html.escape(r["date"])}</span>{tag}')
113+
out.append(' </div>')
114+
if r["lede"]:
115+
out.append(f' <p class="lede">{inline(r["lede"])}</p>')
116+
for g in r["groups"]:
117+
cls = ' class="crit"' if g["critical"] else ""
118+
out.append(' <div class="rel-group">')
119+
out.append(f' <h3{cls}>{inline(g["title"])}</h3>')
120+
out.append(' <ul class="rel-list">')
121+
for it in g["items"]:
122+
out.append(f' <li{cls}>{inline(it)}</li>')
123+
out.append(' </ul>')
124+
out.append(' </div>')
125+
out.append(' </div>')
126+
out.append('</section>')
127+
return "\n".join(out)
128+
129+
130+
if __name__ == "__main__":
131+
src = sys.argv[1] if len(sys.argv) > 1 else "../CHANGELOG.md"
132+
rels = parse(open(src).read())
133+
if not rels:
134+
sys.exit("changelog-gen: no releases parsed from " + src)
135+
sys.stdout.write(render(rels) + "\n")

0 commit comments

Comments
 (0)