diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index 3e554b3..0c27d3e 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -396,6 +396,7 @@ rules: - BDR-068 (close-auto-persist) MERGED to develop + pushed. Then cut + pushed **v1.1.0** (minor, that feature). Standard forward bump → sonnet release-executor ran BOTH spans (prep + finish+tag); lineage continued 1.0.0→1.1.0 not 5.x (validates [[BDR-067]]). origin: main=2f8dc6b, develop=21b1e21, tags v1.0.0 + v1.1.0. WATCH-ITEM: a stale local tag `v4.0.0` reappeared during the release — NOT from origin (origin never regained it; `push.followTags` off; its commit unreachable from develop/main). Inert (push targeted main/develop/v1.1.0 explicitly + deleted the local copy; origin verified clean). Mechanism unexplained — if `v4.0.0` resurfaces locally after a `gitflow` op, trace the release lib (gitflow.sh / release-executor) for stray tag re-creation. ## 2026-07-17 +- safe_fetch DNS-rebinding guard shipped by-principle (feature/dns-rebinding-guard): resolve-then-pin in stdlib http.client, closes SSRF+rebinding for the Python egress (4 verbs via sitemap._fetch), better than claude-seo url_safety on 3 axes. Fresh security-auditor VERDICT PASS + surfaced a REAL billion-laughs hole in my own already-merged C1b (prefix-only DTD scan bypassed by >4KB padding, entity expanded — proven, fixed here). LRN-134/135 capitalized. seo-data 210→221. claude-seo question CLOSED: 3 pieces taken (schema_gen/content_quality/safe_fetch), rest killed-at-measure or rejected-on-principle. - content_quality verb shipped via /feat (2nd cherry-pick, stacked on feature/seo-data-cherry-picks): deterministic filler/AI-slop signal (QRG list intact, no LLM), advisory-not-verdict wired into geo STEP 8. GATE 1 CONFORME 10/10 both verbs, seo-data 190→210. Two easy claude-seo picks DONE; url_safety (DNS-rebinding) still deferred pending threat-model. Branch carries 2 feat + 1 journal commit, UNMERGED (human gate). - Gap-revisit claude-seo after the 21-commit build: remaining cherry-pick value narrowed to 2 clean stdlib picks + url_safety (DNS-rebinding, deferred on threat-model). schema_gen verb shipped via /feat (honors [[BDR-070]] adapt-not-copy): generates JSON-LD (Reservation/OrderAction/DiscussionForumPosting/ProfilePage), the system only audited before. GATE 1 CONFORME 10/10, seo-data 167→190 pass. content_quality next (same /feat, stacked — shares fetch.sh/test/README). - seo/geo parity vs github.com/AgriciDaniel/claude-seo (11.5k★, MIT): full 20-point plan built from a 3-subagent inventory, then executed. Verdict cherry-pick-never-install ([[BDR-070]]). 21 commits: Phase 1 (I1-I8 integrity, markdown specs) MERGED to develop (02c7a6f, 8 commits); Phases 2-7 on bugfix/seo-geo-integrity UNMERGED (13 commits, human gate). `fetch.sh` 5→11 verbs (richresults via inspect, sitemap, rendercheck, linkgraph, cannibal, drift, score); seo-data test suite 85→167 pass, 0 fail. Dogfooded on 2 live sites (zenquality Astro + lavageangels356 native PHP) — the second caught 2 bugs Astro hid (image:loc counted as page, flat-URL family heuristic). diff --git a/.claude/memory/learnings.md b/.claude/memory/learnings.md index 628c5b8..66c4b9e 100644 --- a/.claude/memory/learnings.md +++ b/.claude/memory/learnings.md @@ -136,6 +136,8 @@ rules: | LRN-131 | 2026-07-17 | WebSearch is NOT verification for a number — SEO blogs cross-cite into fake consensus; require primary source + `measured:` field | any stat headed for a client report; verifying a metric/claim exists | | LRN-132 | 2026-07-17 | a subagent summary is a CLAIM, not a fact — 7 disproven in one session (incl. 3 I reproduced writing the fixes) | before planning on any relayed finding; verify vs primary source / live test first | | LRN-133 | 2026-07-17 | an omission must stay LEGIBLE, never silent — tool that can't measure says so in its output | designing any audit/measure output; deciding what a cap/refusal/N-A emits | +| LRN-134 | 2026-07-17 | resolve-then-pin in stdlib http.client beats monkeypatching getaddrinfo — dual-stack, thread-safe, no requests; classify the OS-resolved IP not the URL text | closing SSRF/DNS-rebinding on any Python HTTP egress | +| LRN-135 | 2026-07-17 | a prefix-only scan for a dangerous construct is bypassable by padding — scan the WHOLE document | refusing any hostile construct (DTD/directive/marker) before parse | --- @@ -1301,3 +1303,39 @@ rules: - **pattern**: when a tool cannot measure something, it says so IN its output — a caller must never read absence as "fine". - **context**: red thread of 21 commits — NAP with no canonical → finding WITHOUT direction (never pick from source majority); unmeasured backlinks → mandatory §14 line; sample → mandatory COVERAGE ratio; dropped security headers → §14 + "run /harden" pointer; capped crawl → `orphans_withheld` (the cap doesn't degrade the result, it INVALIDATES it — a partial-crawl orphan is a false orphan); SPA → refuse, don't score; N/A ≠ zero in the scorer. - **future**: the system already HAD the invariant (code-ceiling, §14 Annexe) but applied it in spots. Generalised it. A false signal is worse than a declared gap — the 4 features KILLED at measurement (B1/B2/B3/W2) beat 4 false-signal features. See [[LRN-131]]/[[LRN-132]] (same session, the verification discipline that feeds it). + +## LRN-134 — resolve-then-pin in stdlib beats monkeypatching getaddrinfo — 2026-07-17 +- **pattern**: to close SSRF/DNS-rebinding on Python HTTP egress, resolve the + host ONCE, validate every returned IP (`ipaddress`, dual-stack v4+v6), refuse + if ANY is non-public (the multi-A vector), then connect to the exact pinned IP + via an `http.client.HTTPSConnection` subclass whose `connect()` does + `create_connection((pinned_ip, port))` and `wrap_socket(sock, + server_hostname=real_host)` — SNI + cert stay bound to the real host. No + second resolution to poison. `safe_fetch.py`. +- **context**: the load-bearing property — classify the IP the OS RESOLVED + (`sockaddr[0]`), NEVER the URL text. That defeats octal/hex/decimal literals, + IPv4-mapped IPv6, NAT64, 6to4 structurally, not by enumeration (confirmed by + the security review's fuzz). `is_global` is the decisive gate (catches CGNAT + 100.64/10 the per-flags miss); add a small extra-deny for special-use ranges + it passes (192.88.99.0/24 6to4-relay). Redirects: re-validate EACH hop — + urlopen followed them blind. +- **future**: beats claude-seo url_safety.py on 3 axes — dual-stack (theirs + IPv4-only), thread-safe by construction (theirs monkeypatches getaddrinfo + behind a global lock), stdlib-only (theirs `requests`). A name-level guard + (url-guard.sh) cannot see a rebind; this is the layer that can. Shell `curl` + stays unpinnable from here → `curl --resolve`, separate. + +## LRN-135 — a prefix-only scan for a dangerous construct is bypassable by padding — 2026-07-17 +- **pattern**: to refuse a hostile construct (DTD, directive, marker) before + parsing, scan the WHOLE document, never a bounded prefix. +- **context**: `_refuse_dtd` (C1b) scanned only `raw[:4096]` → a sitemap with + >4 KB of leading comment pushed `