Releases
Release history and changelogs
All releases up to v0.1 will be in the stable channel.
Release History
Release Notes
A release about reading pages more the way a browser does. The accessibility checks that skip hidden controls now read the page's own <style> blocks, so a control a <style> rule hides, such as a closed modal, is skipped as a browser skips it, and accessibility scores move with it. A local audit detects technologies from more of its response headers, a charset sent only in the Content-Type header counts, a redirected page's canonical is judged at its target, four more schema rules follow @id references, a CAPTCHA widget rendered client-side is recognised, and the link checks never request a link whose fragment carries a sign-in token.
Thanks to @WilliamK112, whose pull requests are behind the content/hidden-text and npm install fixes below, and who also hardened the plan lookup, tidied the crawler's page events, kept the squirrel analyze tests away from a developer's real project databases and made CI check that the compiled CLI carries the rules version it was built from. Thanks also to @deniskern, whose reports are behind the hidden-control, security/form-captcha and security/csp-blocks-own-resources fixes.
Pricing
squirrel keys createnow says how an audit signed in with the new key is billed: no audit or page charge, links checked on the runner, cloud renders 2 credits each (not printed with--json). That billing is not new; the CI guide now spells it out.- A dead-link check called directly through the API, outside an audit (
POST /v1/services/dead-linkswith no run), costs 1 credit for each distinct link it verifies, and nothing for a link it cannot verify or does not answer. Links that differ only in their query string or fragment count as one. It used to be free for any caller until 2026-11-12. That free period now applies only to asquirrelCLI from before v0.0.114 signed in withsquirrel auth login; from 2026-11-12 those calls are refused and the CLI checks its links itself. See Credits and pricing. - A completed cloud audit is refunded for the external links it was charged for that its report lists as not checked because the link check ran out of time.
Changed
- A link whose fragment carries a credential, written as
key=valuepairs such as an OAuth sign-in redirect's#access_token=…&token_type=bearer, is now treated like any other token link. The external and internal link checks never request it, and a signed-in audit never sends it to the cloud's link check. The report lists it as not requested, with the fragment left off. On your own site, the crawl can still load the page such a link points to, since a fragment is never sent with a request. A plain anchor (#pricing), a hash route (#/settings) and ordinary state in the fragment (#q=shoes) are checked as before. See What the internal link check never requests. - The squirrelscan agent skill that ships with the CLI, which
squirrel skills,squirrel setupandsquirrel mcphand out, is now version 1.15. Its access-gateway advice now says that cloud browser renders send a website's custom request headers, so a gated site can keep rendering on. A signed-in CLI audit's cloud renders still never carry-Hheaders. - A published report now carries
linkTargetSets: forlinks/broken-linksandlinks/broken-external-links, hashes of the targets each one reported broken and of those it could not verify or did not request, taken before the report is sampled. Each set holds up to 2,000 hashes and says whether it is complete. A later comparison can then tell a link that broke since the previous audit from one that was already broken, even on a site with more broken links than the report lists. No format renders them, and scores do not change. See Reports.
Fixed
- The accessibility checks that skip hidden controls (
a11y/aria-hidden-focus,a11y/button-name,a11y/form-labels,a11y/aria-labels,a11y/aria-command-name,a11y/aria-dialog-nameanda11y/aria-input-field-name) now read the page's<style>blocks, not only inline styles. A control or dialog that a<style>rule hides withdisplay: noneorvisibility: hidden, such as a closed modal, is skipped as a browser skips it, instead of being reported.- squirrel settles a rule itself only when it sits outside any at-rule, in a
<style>with nomedia(other thanallorscreen) and notitle, its declarations are plainly written, and each of its selectors is one compound of a tag, an id, classes and plain attribute tests (.modal,div#menu.closed,[data-state=closed]). It applies those as a browser does:!importantfirst, then an inline style over a rule, then specificity, then source order. - Any other
<style>rule that setsdisplayorvisibility, whatever the value, can make the unnamed controls it could apply to, and those inside them, unconfirmed: a warning rather than a failure. That covers a selector with a combinator (.nav .item) or a pseudo-class (:hover), a rule inside@media,@supportsor@layer, and a rule whose declarations squirrel cannot read. A rule using:is(),:where()or:has()can reach any element, so it can make any unnamed control on the page unconfirmed. Hiding that comes from a<style>block on a page that also loads an external stylesheet, which may undo it, is unconfirmed too. - On a page whose styles squirrel reads in full (for example, no external stylesheet or
@import), a control inside a container whose inline style setsvisibility: hiddennow counts as hidden instead of unconfirmed. - Some controls that were skipped or unconfirmed are now checked, because a
<style>rule shows them again, as a browser does: adisplayvalue on an element with thehiddenattribute, a closed<dialog>or apopover, an!importantrule that beats an inlinedisplay: none, orvisibility: visibleon a control inside a hidden container. A control hidden only by its inline style is unconfirmed where an undecided!importantrule, or an animation that setsvisibility, may show it. - Accessibility scores change in both directions. Unconfirmed rows now read
may be hidden by a style (unconfirmed)instead ofmay be hidden by an inline style (unconfirmed), and their items saya style on it or an ancestor sets display or visibility in a way squirrel cannot settle. - Known limits, tracked in #938: a
:rootblock of custom properties whose names or values mentiondisplay, such as WordPress global styles with a "Display" typography preset, can make every unnamed control on the page unconfirmed. A<style>rule that shows a control is applied even when an external stylesheet may hide it again. - See a11y/aria-hidden-focus.
- squirrel settles a rule itself only when it sits outside any at-rule, in a
- An audit no longer keeps an earlier audit's accessibility failure open, as a carried finding, on a page where the same check now reports the control as unconfirmed. This affected the seven hidden-control checks above once they began reading
<style>blocks: the page showed both the stale failure and the new unconfirmed warning. An unconfirmed row now counts as the check having run on that page, so the earlier failure is closed unless the page still fails that check, and an earlier unconfirmed warning is closed once the check passes there. A page a rule did not check at all keeps its earlier findings. The fix text of those seven rules now describes how<style>blocks are read. - Technology detection in a local audit sees more of the response headers a signature reads. The crawler kept only a fixed set of headers, so a header-only signature such as
X-CDN: Imperva,X-Sucuri-IDorX-Varnishnever matched. The crawler now stores those headers with each page, each kept value cut to 512 characters, and the CLI passes them to the local scan. A signed-in audit also sends them to the cloud detection, together withX-Powered-By,X-Generator,X-AspNet-Versionand the names of the cookies a page sets (up to 50). A header a signature only checks for presence, such as a request id or a bot protection token, is kept as a marker, not its value, and cookie values are never sent. A page an earlier version stored, and the crawl cache reuses, is read with only the headers it kept until it is fetched again. See Technology Detection. core/charsetnow sees the charset of theContent-Typeresponse header in a real audit. The crawler kept only the media type, so a page declared UTF-8 by its header alone failed withNo charset declaration found, and a header that disagreed with the page's meta about UTF-8 was never reported. It now warns, for exampleConflicting charset declarations: header iso-8859-1, page utf-8. A page an earlier version stored and the crawl cache reuses is read as before until it is fetched again. See core/charset.core/canonicalandcrawl/paginationjudge a URL that answers with an HTTP redirect by the URL it ended on, asi18n/hreflangalready does./en/old-slugredirecting to/en/new-slug, whose canonical ishttps://example.com/en/new-slug, no longer reads "Canonical points to different URL", and a paginated URL that redirects is tested, and its relative canonical resolved, at its target. See core/canonical.schema/breadcrumb,schema/local-business,schema/website-searchandschema/rating-scopefollow JSON-LD node references ({"@id": …}) to the node they name on the same page, asschema/faq,schema/productandschema/reviewalready do. A breadcrumb whose items are references no longer reports items missing their position and name, a business whose address is a reference no longer reads as an incomplete address, a referencedSearchActionis found, and a referenced rating is read for the entity that carries it. A reference to a node the page does not have is judged as written. See schema/breadcrumb.security/form-captcharecognises a CAPTCHA widget rendered client-side.@nuxtjs/turnstileand React reCAPTCHA components mount the widget after hydration, so the static HTML holds only the site key, for example Nuxt'sturnstile:{siteKey:"…"}runtime config. A Turnstile, reCAPTCHA, hCaptcha or Friendly Captcha site key, under the provider's name and in that provider's key format, in an inline or JSON data script now protects the page's single public form, like a provider script does, unless another form on the page has a CAPTCHA of its own. A script that only mentions a provider, or switches it off, still does not. See security/form-captcha.security/csp-blocks-own-resourcesno longer warns about, or fails on, a host that only the page's same-site scripts or preloaded chunks name as a script to load. SDKs ship fallback URLs that load only behind a feature flag, so the page may never request them. That includes a known vendor's host (analytics, ads, payments, CAPTCHA, forms or booking) that only a chunk names, which used to fail the check. Such hosts are now listed in an informationalcsp-chunk-only-hostsnote. A host the page's HTML loads, or that a vendor it uses needs, is reported as before. See security/csp-blocks-own-resources.content/hidden-textreads CSS comments the way a browser does. A comment before a property or its colon (.promo { /* hide */ display: none }) used to stay in the property name, so the declaration was never recognised, in<style>rules and inlinestyleattributes alike. A<style>rule with a comment before or inside its selector, such as a/* Section */comment between rules, was not applied either, here or inmobile/input-zoom. Text and links hidden either way now count, as other hidden text does. See content/hidden-text.- WAF detection stops treating text as quoted at the end of the block an unclosed
<code>,<kbd>or<samp>sits in. Since 0.0.117, an unclosed<code>could hide a block page's wording, such as an Imperva incident line, for the rest of the page, so the block page went unrecognised. A block nested inside a code block, as Expressive Code and Starlight write each line, still reads as quoted. - The console, llm and XML reports say how many items a check left out, as the HTML, markdown and text reports do. The console's
... +N moreafter the first 5 items now counts the items the audit left out too, and a check that lists none reads1,500 items not listed. The llm format no longer drops the count for a rule whose checks list no items (Items (0/1500): none listed), and the XML gives it as anot-listedattribute on the check's<items>element. Scores do not change. See Reports. - A signed-in audit checks on your machine a link the cloud link checker refused to request because its name resolves to a private address, when the checker says so, instead of listing it as status unverifiable. An audit of a site on a private network gets the link's real status, and a public site's audit still never requests it and lists it as refused. The cloud's refusal is never itself reported as a broken link. See External links.
- The npm package's postinstall download follows at most three redirects, refuses a download or redirect URL that is not HTTPS, and resolves a relative
Locationagainst the URL that sent it.
Docs
- The CI guide says what an API-key audit costs, and Credits and pricing gives the price of a dead-link check outside an audit.
- The README and the docs home count 8 Mobile rules.
Downloads
Release Notes
A release about reading pages the way browsers and search engines do. The new mobile/input-zoom rule finds the form fields that make iOS Safari zoom in, so squirrelscan now ships 309 rules. A strict CSP built on a nonce or 'strict-dynamic' is no longer called weak, the rules that ask whether robots.txt blocks a page read it the way Google does, a sitemap with a mismatched tag or a raw & gets caught, and the FAQ, product and review checks follow JSON-LD @id references to the nodes they name. A site missing compression or caching headers is scored for each once instead of twice, squirrel analyze stops stacking a second copy of every result, and a signed-in audit no longer pays for a link the cloud could not verify.
Thanks to @deniskern, whose reports are behind the schema/faq, crawl/pagination and i18n/hreflang fixes below.
Added
- New rule
mobile/input-zoom: on an iPhone, focusing a text field, select or textarea under 16px makes Safari zoom the page in. The rule reads field sizes from inline styles and<style>blocks and lists up to 20 small fields, each with its size and where it came from, such as14px (inline style). A field nothing on the page sizes, on a page with no external stylesheet, gets iOS's own11px (iOS default). A field it cannot read plainly (its size may come from an external stylesheet, a media query, conflicting rules or a relative unit) is counted as not read rather than guessed at, so on many sites built with a CSS framework most fields are not read yet. A page that setsmaximum-scale=1oruser-scalable=noin its viewport tag to stop the zoom still gets the warning, with a note that the workaround blocks zoom for everyone. See mobile/input-zoom. perf/total-byte-weightalso reports two site figures, for information only: the site total (every scored page's HTML plus each measured external resource once, however many pages share it) and the average page weight. They are at the end of the message (Not scored: site total 80KB across 19 pages, shared files counted once; average page 4KB) and in the details assiteTotalKbandpageAverageKb. They never change the pass, warn or fail result, which is still the heaviest page's, and an audit whose page weights are only estimated reports neither. See perf/total-byte-weight.crawl/sitemap-validwarns about the<lastmod>values of sitemap URLs that are outside the W3C Datetime format the sitemap protocol requires, such as2024/09/10,2024-9orTue, 10 Sep 2024 12:00:00 GMT(sitemap-lastmod-format), with a sample of URLs and values. A datetime with no zone (2024-09-10T12:00:00) is named as missing its zone (sitemap-lastmod-zone). A search engine may ignore a lastmod it cannot read. See crawl/sitemap-valid.[rules] time_budget_msinsquirrel.tomlsets the rule time budget forsquirrel audit: how long one rule may spend on one page (its synchronous work) before it is abandoned for that page and the audit moves on (default 1000 ms). It is a backstop against a page built to make a pattern match slowly. Raise it if rules time out on ordinary pages on a slow or busy machine, such as a shared CI runner.0turns the budget off. A value below 1000 is refused for now, withtime_budget_ms must be 0 (off) or at least 1000. Cloud audits keep the default. See Rule time budget.
Pricing
- A signed-in audit, cloud or CLI, is no longer charged for an external link squirrelscan's cloud link checker could not verify: one behind a bot wall, a throttled one, one the run's limit on deeper checks for its site left unverified, or a 404 waiting for its confirming check. The report lists those as unverified, as before. A link the checker did not answer in time is not charged either. Only links that work, redirect or are broken cost a credit (at most 1 per page): each batch of links is charged before it is checked, then settled down to the links the checker verified. The settling is done by squirrelscan's cloud, so it applies to every CLI version that pays for cloud link checks. A link to another of your own domains is external and is charged the same way. See Credits and pricing.
- The CLI's pricing text says the same: 1 credit per external link the cloud verifies, not per link it checks. That covers
squirrel credits, the low-balance and credit wall lines, the one-time link pricing notice, the audit estimate and the billing note of the MCPaudit_websitetool. squirrel creditsno longer listsissue_enrich(3 per issue). AI issue enrichment is switched off in squirrelscan's cloud, so nothing is sold or charged under it today. The keyword and content gap add-ons are listed as before.
Changed
links/broken-external-linkssays why a link could not be verified. A link that answered 404 once and waits for its confirming check reads(unverified: awaiting confirmation)on the unverifiable row: it is likely broken, not blocked. Other items keep(unverified)or(unverified: {vendor}). Each item also carriesmeta.unverifiedReasonwhen the checker knew why:blockedfor a wall or challenge,unreachablefor a timeout or failed connection, orawaiting-confirmation. An unverifiable link squirrel checks from your machine getsblocked, orunreachablewhen its check timed out. The cloud link checker sends all three. The reason is stored with the audit, sosquirrel reportandsquirrel analyzekeep it. See broken external links.squirrel auditandsquirrel reportsay how many items a check left out in their HTML, markdown and text output, not only how many they list. A check that lists 20 of its 26 items now reads20 items, 6 more not listedin HTML and20 item(s), 6 more not listedin markdown, and the text format prints... and 6 more items not listedafter the list. A check that lists no items but left some out says so, such as1,500 items not listed, where it used to say nothing. The count comes from the check'sdetails.additional, which the JSON and LLM formats already reported. A check with nothing left out reads as before.- A report published from the CLI carries the exact number of pages a rule fails or warns on when its issues reach different pages, in a new top-level
rulePageCountsfield of the JSON. Each issue lists a sample of its pages beside its own true count, but the pages two issues of one rule leave out cannot be told apart, so a rule with one issue on 46 pages and another on 4 different pages could only be counted as at least 46. The count is taken before the report is sampled, over this run's checks (findings carried over from an earlier audit are left out, as in the check tallies), and it is listed only for the rules that need it. See Output formats via URL.
Fixed
squirrel analyzenow replaces a crawl's stored rule results. It used to add a second copy of every check beside the ones the audit, or an earlieranalyze, had stored, andsquirrel reportthen showed the earlier run's items, so a rule change looked like it did nothing.crawl/sitemap-validno longer passes a sitemap with a mismatched closing tag (</lox>for</loc>), an element left open or a raw&in a<loc>or other element text. It now fails itssitemap-syntaxcheck with an error that names the problem and where it is, such asXML is not well formed: char '&' is not expected. (line 6, column 49). The URLs squirrel can still read are kept. Whitespace before the XML declaration is tolerated, and a sitemap that declares its own entities in a DOCTYPE is not checked. See crawl/sitemap-valid.crawl/robots-meta-conflictreports a page that robots.txt blocks and that carries noindex only in anX-Robots-Tagheader, and lists the pages on its finding. It decides "blocked by robots.txt" the way Google does: the longest matchingAlloworDisallowwins, anAllowwins a tie, and*and$in a rule are honoured. Before,Disallow: /dir/plusAllow: /dir/okcounted/dir/okas blocked, andDisallow: /*.phpmatched nothing. The same decision servescrawl/indexability-conflicts,crawl/all-noindex-pages,crawl/schema-noindex-conflictand the GraphQL probe's robots.txt check. The crawler's own robots.txt handling, used whenrespect_robotsis on, is unchanged. See crawl/robots-meta-conflict.security/cspno longer reports a strict CSP as weak. In ascript-src(ordefault-src) that also lists a nonce or a hash, or'strict-dynamic','unsafe-inline'no longer warnscsp-unsafe-scripts, and next to'strict-dynamic'a wildcard host such as*no longer warnscsp-wildcard: browsers ignore those sources (CSP Level 3). So a strict policy of the shape Google recommends,script-src 'nonce-…' 'strict-dynamic' https: 'unsafe-inline', gets no warning.'unsafe-eval'still warns. See security/csp.security/srino longer reports the scripts and stylesheets Shopify injects into every store fromcdn.shopify.com/shopifycloud/andcdn.shopify.com/storefront/, which Shopify updates in place so no store can pin a hash. Theme and app assets elsewhere oncdn.shopify.comare still checked. A newexempt_hostsoption exempts other hosts whose files change at the same URL, by exact hostname:[rule_options."security/sri"] exempt_hosts = ["static.klaviyo.com"]. See security/sri.- A site with no compression or no caching headers is no longer scored twice for the same server setting.
perf/compressionandperf/cache-headersscore it page by page, and thebad-caching-compressionandbad-caching-freshnesschecks ofperf/bad-cachingnow report the site's share at info status, which does not move the score. A site missing both now scores a little higher than before: a clean five-page test site served with neither went up 2 points in our smoke test.bad-caching-validatorsis unchanged. See perf/bad-caching. - A page that quotes a WAF's signature is no longer attributed to that WAF or read as its challenge page. A tutorial printing
<script src="https://js.datadome.co/tags.js">in a code block, a support page showingIncapsula incident ID: 1234-5678in one, or a troubleshooting page that mentions/cdn-cgi/challenge-platform/could name the provider in the report's technologies, or be set aside as a challenge page and left out of page-level scoring. Script and markup signatures now count only inside a tag (not a link) or an inline script, and block-page wording only outside<code>,<kbd>and<samp>elements, comments and scripts. Real block and challenge pages are recognised as before. See Bot protection or WAF challenge. content/placeholder-textnow reads the page's<title>too. A title whereundefined,nullorNaNfills a whole segment between spaced separators (undefined | Acme) warns, and one holding[object Object]fails, in a newplaceholder-titlecheck. A page about the word, whose first<h1>or one of its<code>elements is exactly that word, such as MDN'sundefined - JavaScript | MDN, is not reported. See content/placeholder-text.content/mojibakereads the same rendered prose ascontent/placeholder-text. A sequence that appears only inside a<template>or a code-editor or syntax-highlighter container (CodeMirror, Monaco, highlight.js, Shiki, Prism) is no longer reported, and adjacent blocks such as table cells no longer fuse into a false sequence. See content/mojibake.perf/dom-sizecounts elements and depth from<body>, as its widest-element check already did and as Lighthouse's DOM size audit does.<head>metadata such as hreflang alternates and preload hints no longer adds to a page's count or depth, so a small page with a large head reports the body's numbers. The total is the elements inside<body>, and<body>is depth 1, so a page's depth usually reads one level less than before. Thresholds are unchanged. See perf/dom-size.i18n/hreflangno longer reports "No self-referencing hreflang found" on a URL that answers with an HTTP redirect to a page that references itself. The crawl reads the redirect target's tags, so the self reference is now compared with the URL the redirect ended on. A redirected page's other hreflang findings, such as a missingx-default, are about the target's tags and stay. See i18n/hreflang.crawl/paginationno longer warns that/blog?page=1canonicalizes to/blog. Page 1 of a list is the same content as the unpaginated URL, so that canonical is correct. Page 2 and later still warn when their canonical points at a URL with no page marker. See crawl/pagination.schema/faqno longer reports every question of a Yoast-style FAQ as missing its name and answer. WhenmainEntitylists node references ({"@id": "…#q1"}) toQuestionnodes elsewhere in the page's JSON-LD, the rule now reads the nodes they name, and anullentry counts as an invalid question instead of breaking the check.schema/productdoes the same for its first offer, andschema/reviewforreviewentries andaggregateRating, judging a review both referenced and present as its own node once. A reference to a node the page does not have is still reported. See schema/faq.- The sitemap
lastmodchecks (crawl/sitemap-lastmod-drift,crawl/sitemap-lastmod-churn) and the cloaking probe read dates outside the W3C format more carefully. A time range such asSep 10 2024 12:00-14:00used to be read as 12:00 at offset -14:00, and a value with a time range like that is now not read. An overnight range such as22:00-02:00cannot be told from a time and its offset, and still reads as one. A local time that daylight saving skips, such as2024/03/10 02:30, now reads as written on every machine. It used to come out an hour off on a machine in a zone that skips it. - The XML report
squirrel auditwrites now states the scan scope, as the other formats do. Inside<site>,<scan-scope origin="cli" crawled="42" max-pages="100" capped="false"/>says where the audit ran, how many pages it crawled, the page limit in force and whether that limit stopped the crawl.requested-max-pagesis added when the requested limit was lowered. A report from before scan scope existed renders as before. See Reports. - An entity map published from the CLI that has more edges than the publish cap (1,500) now keeps the edges between its best-ranked entities. It used to keep the first 1,500 in alphabetical order, which left late-alphabet entities unconnected. A dangling reference ranks with the entity that makes it, so a problem entity's dangling edges are kept with it.
Docs
- The README and the docs home page carry one feature list, in the same order, each item linked to its page. The rules configuration page lists all 22 rule categories, the content rules page drops the retired content quality score, and the dashboard audit settings page no longer says only owners and admins can change them.
- The cloud firewall, crawler and crawl settings pages say that cloud browser renders send a website's custom request headers, pinned to the rendered page's own origin, so a gated site no longer needs rendering turned off. A signed-in CLI audit's cloud renders still never carry
-Hheaders. - The XML examples in the docs match what the renderer writes.
- The docs check now fails on dead internal links, dead
#anchors, missing anchors that other pages link to, and rule Options rows with anunknowntype orundefineddefault, and the dead anchors it found are fixed.
Release Notes
A release about link results and local sites. Cloudflare email-protection links no longer count as broken links, a public page's leftover link to a local-only name is flagged again, a .test or .local site typed without a scheme is audited over http, and the external link check shows its progress as it runs.
Changed
- The external link check now reports its progress while it runs, so the CLI's "Checking external links" progress counts up as links are answered, where before it showed no count until the check ended. The check can also be given a time budget, which squirrelscan's cloud audits use. Links still unanswered when the budget runs out, or when one request takes too long, are reported as Not checked with reason
external-links-out-of-time, never as broken, and the audit completes. See links/broken-external-links. - The notice a CLI audit prints when some external links could not be verified from your machine now says what gets them checked by squirrelscan's cloud link checker: a signed-in Surface or Full audit, at 1 credit per link it checks (at most 1 per page). Signing in alone is not enough, since a Quick audit checks links from your machine either way. A signed-in Quick audit now gets the notice too, naming
--level surface. The--external-linkshelp no longer suggests that some level skips external links. See Link checking.
Fixed
links/broken-linksno longer reports Cloudflare email-protection links as broken. With Cloudflare's Email Address Obfuscation on, everymailto:link and every email address in a page's text becomes a link to/cdn-cgi/l/email-protection, which answers an error when requested, so a site with an address in its footer had one broken link per address, sourced from every page. These links are email addresses, not pages: the crawl, the internal link check and the external link check never request them, and they are read as themailto:links they stand for. Thebroken-linksrow'sdetails.emailProtectionLinkssays how many were left out. Other/cdn-cgi/links, such as image resizing URLs, are still checked. See links/broken-links.- A public page's link to a local-only name, such as a leftover
http://myapp.test/from a CMS preview, stands out again. Since 0.0.114 the external link check does not request a.test,.exampleor.invalidname, or one under.localdomainor.home, and lists it as info where it used to count as a broken link.content/dev-leakagenow warns about them as a newlocal-namekind: names under.test,.example,.invalid,.local,.lan,.internal,.localdomain,.homeand the other local-only zones, and single-label hosts such asintranet..onionand.altnames are not included. An audit of a local site such asmyapp.testskips the rule, so its links to other local names are not reported.reliability/broken-formslikewise reports a form that posts to one of these names asnon-production-form-action, without sending it, unless the audited site is itself local. See content/dev-leakage. squirrel audit myapp.test, typed without a scheme, now auditshttp://myapp.test/instead ofhttps://myapp.test/. Laravel Valet and Herd serve.testsites over plain http unless the site is secured, so the audit used to fail to connect. Every host the CLI treats as local or private now defaults tohttp://the waylocalhostand private addresses already did:box.local,nas.lan,db.internaland the other local-only names. A public host still defaults tohttps://, and a scheme you type is kept. See Local audits.a11y/aria-dialog-name,a11y/aria-command-name,a11y/aria-input-field-name,a11y/aria-labelsanda11y/form-labelsno longer report controls that are out of the accessibility tree, asa11y/button-namealready does. That covers a control with thehiddenattribute oraria-hidden="true", an inlinedisplay: noneon it or an ancestor, an inlinevisibility: hiddenon it, or a control inside a closed<dialog>or an unopenedpopover. A closed modal markedaria-hidden="true"no longer counts as an unnamed dialog. A control an inline style may hide in a form squirrel does not read exactly is reported as unconfirmed, a warning, not a failure. A closed native<dialog>is still checked, since pages open it with script. Hiding by<style>blocks and stylesheets is not read yet, so a control hidden only by a class rule is still reported. See a11y/aria-dialog-name.
Release Notes
Quick audits in the CLI now check the links that leave your site, as Surface and Full already do. At Quick they are checked from your machine and the link checks cost no credits. squirrel audit --offline stops checking anything on another site, and the inbound-link rules stop flagging the page a crawl starts on. Cloud audit defaults are unchanged in this release.
Changed
- Quick audits in the CLI check external links too. Every audit level now checks the links that leave your site, Quick included.
- A Quick audit checks them from your machine, signed in or not, and the link checks cost no credits.
- A Quick audit now also requests the pages your site links to on other sites, signed out included, so it can take a little longer.
- Skip them with
--no-external-linksor[external_links] enabled = false.--offlinestill skips them. - Cloud audits keep their current link check settings for now. Any change to the cloud defaults will be announced separately.
- See Link checking defaults.
links/broken-external-linksno longer calls every link it could not verify "blocked by WAF". Many are not a firewall: a timeout, a CDN error page or a 404 waiting for its confirming check lands in the same list. The row now reads{count} external link(s) could not be verified - status unverifiable, and each item reads(unverified), with a vendor added when the checker named one. The check name,waf-blocked-external-links, is unchanged. See broken external links.
Fixed
squirrel audit --offlineno longer status-checks anything on another site: the third-party scripts, stylesheets and fonts the pages load, and sitemap URLs on another site, are left alone.reliability/broken-resourcesreports the third-party resources as not measured on an offline run, andcrawl/sitemap-4xxsays how many sitemap URLs on another site it skipped. Requests to the audited site can still end up on another host when the site sends them there: a page or asset redirect, or a sitemap that robots.txt lists on another host. See audit.links/orphan-pages,links/weak-internal-linksandlinks/no-contextual-inboundno longer report the page the crawl started on. When a site's root redirects to a deeper page (/en/, or a sign-in page), the redirect leads there, and often no link does, or only the logo in every header does, so the site's front door read as a page with too few incoming links.integrity/orphan-pagealready exempted it. See links/orphan-pages.
Release Notes
A pricing correction, and a release about trusting your link results. Version 0.0.113 said a signed-in audit's link checks were free. For the links the cloud checks, that was wrong, and this release puts it right: a signed-in audit now pays for its cloud link checks the same way a cloud audit does. Links squirrel checks on your machine stay free, and so do internal links. Beyond pricing, a link that meets a sign-in or bot wall is listed as unverifiable instead of broken, broken internal links say exactly what each audit covered, an audit of a site on your own network keeps what it finds there on your machine, and squirrel analyze fetches what its rules read the same guarded way squirrel audit does.
Pricing
- A signed-in audit pays for its cloud link checks: 1 credit for each distinct external link the cloud's link checker answers, an unverifiable answer included, at most 1 per page of the audit's page limit, and only at the levels that send external links to the cloud (surface and full, or a custom level with cloud checks and external links on). Version 0.0.113 said these checks were free; they are not.
- Still free: a link the cloud can't answer in time, which squirrel checks on your machine; internal links; and every link in a signed-out audit. A quick audit with
--external-linkschecks its links on your machine and pays nothing for them. - Each page of the audit's page limit now reserves its link check, so the same
max_credits_per_auditcovers fewer pages, and starting a signed-in surface or full audit needs 1 more credit than before. The pre-audit estimate and the page cap fitted to your balance include the link checks.squirrel creditsand the low-balance messages state the price, and the first signed-in audit that checks external links after you update prints a one-time notice. Skip the checks with--no-external-links. - An audit that can't register a run, such as one signed in with an organization API key, checks its links on your machine and pays nothing for them.
- Older versions keep free cloud link checks until 2026-11-12. From then, they check their links on your machine until you run
squirrel self update. - See Credits and pricing.
- Still free: a link the cloud can't answer in time, which squirrel checks on your machine; internal links; and every link in a signed-out audit. A quick audit with
Security
- An audit of a site on a private network (
localhost, a private address, a.localname) no longer reads or writes the external link cache that every audit on your machine shares. A later audit of a public site could otherwise be answered from that cache about a public name that resolves to a private address. Results cached by earlier versions are not reused, so each link is checked again once after you update. - No audit sends the cloud's link checker a link whose URL names a private or local host (a private IP address,
localhost, a.localor single-label name). squirrel checks those itself. When the cloud declines a link because it, or a redirect it leads to, is on a private address, that is never reported as a broken link. squirrel analyzefetches the assets its rules read the waysquirrel auditdoes. Its own copy of that step followed a script's redirects without checking where they led, so a script that redirected to a cloud metadata address led it to request that address. Every asset and link check, and every redirect those follow, is now held to the audit's rules.analyzealso checks the internal links the crawl did not fetch, uses the crawl's own scope and page limit rather than thesquirrel.tomlin your current directory, and sends none of that file's custom request headers. It now also fetches fonts, preloaded scripts and web app manifests, status-checks third-party scripts, stylesheets and fonts whilereliability/broken-resourcesis on, and no longer caches scripts between runs. See analyze.
Changed
- A link checked from your own machine that meets a wall is listed as unverifiable, not broken. That covers a sign-in or bot wall (401, 403, 405, 406, or LinkedIn's 999), a challenge page served as a 503, and a site that never answers in time. Most of these used to be counted as broken, so a signed-out audit's broken link list was mostly links that work in a browser. They are now listed under
waf-blocked-external-linksinlinks/broken-external-links, andlinks/dead-linksno longer counts them. A real 404 or 410, a plain 503, a domain that does not exist and a refused connection are still broken, and so are a storage bucket's 403 for a missing file, an HTTP sign-in prompt and a 405 that allows no GET. Walls and timeouts are no longer cached, so the next audit asks again, and one an earlier version cached as broken is checked again. A signed-out audit with unverifiable links says so and suggestssquirrel auth login, since the cloud's link checker can usually get a real answer. See links/broken-external-links. links/broken-linkssays what it covered on every row, the failing one included: how many internal link targets the pages link, how many the internal link check got an answer for, how many nothing fetched, and why (past theinternal_links.max_targetslimit, skipped by design, not trusted, or no answer). Comparing two audits can then tell a link that was fixed from one the later audit did not check.squirrel report --diffleaves these counts out of each broken link, so a change in coverage alone no longer marks every broken link as changed. A link that only a rate-limited page carries is no longer requested, or counted as skipped or past the limit. See links/broken-links.- Names under the reserved
.test,.exampleand.invalidzones, and under.localdomain,.home,.onionand.alt, count as local hosts, like.local. An audit of a Laravel Valet or Herd site such asmyapp.testis treated as an audit of your own machine: no publish, no cloud render, the delivery rules (such as HTTPS and caching) skipped, and its links to other local names checked from your machine. On a public site's audit, a link to one of these names is not requested and is listed asrefused-external-linksinstead of broken. See Local audits.
Fixed
links/broken-linkskeeps the crawl's answer for a linked page the crawl fetched but does not grade as a page. A PDF that loaded no longer counts as an unchecked target, so a site that links its own PDFs can read as fully checked, and one that answered 404 is still broken. A linked page that answered the crawl with rate limiting is listed inrate-limited-links, and one that answered with a bot-wall challenge inrefused-links. See links/broken-links.integrity/orphan-pageno longer reports the page the crawl started on as a hidden page. A site whose root redirects to a deeper page, such as/en/or a sign-in page, had that landing page read as hidden, and as a likely injected page when it also carried a page-level signal. The opt-in cloaking probe no longer picks it either. See integrity/orphan-page.crawl/sitemap-validreads a sitemap whose root uses a prefix bound to the sitemap namespace, such as thens0:urlsetthat Python's ElementTree writes by default. It used to report an unknown sitemap format and read none of its URLs. A prefixed sitemap index reads too. See crawl/sitemap-valid.- The sitemap date checks read every date as the period it names, the same way on every machine. A date with no time is the whole day, a datetime with no zone is read in UTC, and a date that is not a real date is left out instead of guessed at.
crawl/sitemap-lastmod-driftno longer warns that alastmodof2024-09-10is 253 days ahead of a page dated2024,crawl/sitemap-lastmod-churncounts the day alastmodwas written in its own zone and says when most of a sitemap's lastmods can't be read, and the cloaking probe stops treating pages dated only with this year as old two weeks into the year. See crawl/sitemap-lastmod-drift.
Release Notes
A safety and accuracy release. The external link check no longer requests links that are actions or carry a credential, such as one-click unsubscribe links, magic sign-in links and signed downloads, and every request it sends, redirect hops included, goes only to a public address (or a private one when the audited site is private), never to a link-local or cloud metadata address. squirrel audit's asset checks can no longer be pointed at a cloud metadata service, the GraphQL probes no longer let a compressed answer inflate past their read limit, and an audit now closes each connection once it has read what it needs, so an endless body no longer keeps downloading. Sitemap dates, bot-protection names and a dozen rules report more accurately too.
Security
- The external link check no longer requests third-party links that look like actions or carry a token. Examples are a newsletter's one-click unsubscribe link, a magic sign-in link or a signed download URL, where the URL itself is the credential and a request can use it up.
- It uses the internal link check's rules (action words, token-shaped values, keys such as
tokenorsig) and skips links whose text is plainly an action ("Log out", "Unsubscribe"). - A signed-in audit does not send these links to the cloud's dead-link check either.
links/broken-external-linkslists them in a new info check,not-requested-external-links, with tokens masked. They are never reported as working or broken, and they do not count againstmax_links.- When squirrel checks a link itself it now follows redirects by hand, so a short link that redirects into one stops at the redirect.
- Every request and redirect hop goes only to a public address (private ones too for an audit of a private site), never to a link-local or cloud metadata address, and connects to the address it checked.
- See What the external link check never requests.
- It uses the internal link check's rules (action words, token-shaped values, keys such as
- The internal and external link checks also skip three kinds of link they used to request.
- A server-script extension (such as
.aspx,.php,.jspor.do) no longer counts as a word, so/MyAccountLogOut.aspxreads as the four-word sign-out it is. Any other action word is read the same way (/delete-your-account.php, like/delete-your-account). - Log out, log off and sign out now count in a path segment of five words (
/my-account-log-out-now). A longer slug that only mentions signing out (/blog/how-to-log-out-of-every-device) is still checked. - A link with a credential in its query string is never requested: a key such as
api_key,x-api-key,pwd,session_id,accesstoken,authorization,jwt,x-amz-credentialorx-goog-credential, in any case, with-,_and.ignored, or in brackets (config[api_key],pwd[0]). - A not-requested link listed in
links/broken-external-linksnow has a path segment holding an email address masked, where/unsubscribe/user@example.com/…used to show the address. An asset name such aslogo@2x.pngis kept.
- A server-script extension (such as
- External links refused for their address are now listed in
links/broken-external-linksasrefused-external-linksinstead of silently left out, and never counted as working or broken. That covers a link-local or cloud metadata address on any audit, and a private or loopback one on a public site's audit.- A link refused on its URL alone is not counted as checked: it takes no slot under
max_links, is not answered from the link cache (where an audit of a private site may have stored a verdict for the same URL), and is not sent to the cloud's dead-link check. - A re-audit no longer reports an old verdict for an external link this audit did not check.
- A link refused on its URL alone is not counted as checked: it takes no slot under
squirrel audit's asset checks can no longer be pointed at a cloud metadata service.- A stylesheet, script, image, font, PDF or sitemap URL on the audited site is refused at the hop, before anything is sent, when it redirects to a link-local address (
169.254.0.0/16,fe80::/10) or a metadata hostname, or sits on a subdomain outside the hosts the crawl itself requests whose name points at one. - The hosts the crawl itself requests are requested as before, so an audit of
localhost, a LAN address or a.localname still loads its own assets. - A sitemap URL on another host is checked without your custom request headers. They used to go to whatever host a sitemap listed, Web Bot Auth signatures and
Authorizationincluded.
- A stylesheet, script, image, font, PDF or sitemap URL on the audited site is refused at the hop, before anything is sent, when it redirects to a link-local address (
- The GraphQL probes behind
security/graphql-introspectionandsecurity/graphql-get-mutationsno longer let a site answer with a compression bomb.- Their requests went out with the runtime's own decompression on, and Bun inflates a gzip or brotli body in full as it arrives, before the probe's read limit applied. A probed endpoint could cost an audit gigabytes of memory.
- They now fetch with that decompression off, like every other request an audit sends, and decompress the answer no further than the probe's limit (1 MB for introspection, 64 KB for the mutation probe).
- Each probe also closes its connection once its answer is read, where an endless answer used to keep downloading until the 10-second timeout.
- Findings are unchanged. See security/graphql-introspection.
integrity/brand-impersonationandintegrity/fake-auth-overlayno longer lose a signal to how a page's markup is spaced.- An off-brand "Sign in with Google" link right after
<h1>Welcome</h1>used to read aswelcomesign in with google, so the sign-in cue was missed and a correlated overlay dropped from fail to info. - The cues are now also read from element-separated text, with invisible characters removed and whitespace collapsed, so a newline,
, a<br>or a zero-width space inside a link's label of up to 300 characters no longer hides it either. A longer label is read as before, lowercased only. - For
integrity/brand-impersonation, a link whose own label says sign in or log in now counts as a credential affordance, like a password field. - See integrity/brand-impersonation.
- An off-brand "Sign in with Google" link right after
Changed
- A signed-in audit's link checks are free, a firewall-blocked link included: Credits and pricing no longer lists a 2-credit charge for one. At the surface and full levels, a signed-in audit's external links go to the cloud's dead-link check, and any link it leaves unanswered is checked on your machine.
- A signed-in audit no longer waits out a busy dead-link service batch by batch. squirrel sends the service up to 200 external links at a time, and any link left out of a reply is checked locally. When a reply answers none of its links, squirrel now stops asking and checks the rest locally straight away. Before, it sent every remaining batch and waited on each, about 100 seconds on a site with 1,000 external links. See links/dead-links.
Fixed
- An audit no longer goes on downloading a response it is done with.
- Bun keeps receiving the rest of a body it has stopped reading until the request is aborted, and most of squirrel's requests only cleared their timeout. A site answering with an endless body kept the connection open, and kept sending, after squirrel had moved on, in most cases until the audit ended.
- That covered the reads with a size limit (robots.txt, sitemaps,
llms.txtand the other root files, scripts, the external link check's look at a 403 page, the soft-404 and cloaking re-fetches, plain-HTTP page fetches in an audit that uses cloud rendering) and the status checks that drop a body unread (external link checks, asset checks, link target checks, thereliability/broken-formsprobes, the reachability check of a localhost site). - squirrel now closes each connection as soon as it is done with it. What an audit reads is unchanged.
- A sitemap with a bare-year
<lastmod>2024</lastmod>, which the W3C Datetime format allows, is now read.crawl/sitemap-validused to report the whole file as an error startingParse error: entry.lastmod?.trim is not a function, and none of its URLs were read.- Sitemap values are now kept as the text the file wrote, and a bad entry costs only itself, except that a raw
<reads as an unclosed tag and can hide the entries after it. - An entry whose
<loc>holds markup, a raw<, or appears twice is skipped and counted (N <url> entries skipped: <loc> is not a single text value), and the text around markup is never read as the URL. - A
<lastmod>,<changefreq>or<priority>like that is dropped and its URL kept (N <lastmod> values ignored: not a single text value). - See crawl/sitemap-valid.
- Sitemap values are now kept as the text the file wrote, and a bad entry costs only itself, except that a raw
crawl/sitemap-lastmod-driftandcrawl/sitemap-lastmod-churnread alastmodof just a year or a month (2024,2024-09, both valid W3C Datetime values) as that whole year or month, not its first day.- A year-stamped sitemap used to look months behind every page dated later that year (
lastmod 2024 vs dateModified 2024-09-10 (253 day(s))), and churn reported a day the sitemap never wrote. - Drift is now measured from the nearer end of the period, and churn leaves such values out of its sample.
- Churn's "too small to judge" message counts them: a sitemap with only a few lastmods, all years or months, used to read
Only 0 sitemap URL(s) carry a lastmod. - See crawl/sitemap-lastmod-drift.
- A year-stamped sitemap used to look months behind every page dated later that year (
links/broken-linksnames the real reason in its Not checked line.- When the internal link check answered some targets and skipped others as actions, token links, out of the crawl scope or disallowed by robots.txt, the line said it
status-checked only the N most-linked targetsand told you to raiseinternal_links.max_targets, even with nothing over the limit. - The line now names the cause that applies, gives a count for each when there are several (past the limit, skipped, not trusted, no answer), and suggests raising
internal_links.max_targetsonly when the limit left targets out. The check'sdetails.notFetchedcarries the counts. - See links/broken-links.
- When the internal link check answered some targets and skipped others as actions, token links, out of the crawl scope or disallowed by robots.txt, the line said it
- With cloud link checking (signed in), a link the checker could not verify is listed with the firewall-blocked links as
status unverifiable, never as broken. That covers a timeout or failed connection from the checker's network, a firewall refusal, and a 404 waiting for its confirming second check. Before, a timeout was counted as a broken link and a refused link was left out of the report. See links/broken-external-links. - Bot protection is named more accurately.
- The vendor's own cookie names now count (Cloudflare's
__cf_bm, DataDome'sdatadome, HUMAN's_px3, Kasada'sKP_UIDz, Akamai Bot Manager's_abck, Imperva'sincap_ses_*, AWS WAF'saws-waf-token), names only, never values. - A vendor behind a CDN is named over the CDN, so a DataDome block served through Cloudflare reads as DataDome.
- A site on CloudFront, API Gateway or Lambda is no longer reported as behind AWS WAF from those services' own headers: only AWS WAF's action header, cookie or challenge script, or CloudFront's "Request blocked" error page, count.
- The vendor's own cookie names now count (Cloudflare's
eeat/privacy-policy,eeat/about-pageandeeat/contact-pageno longer tell you to create a page your pages already link to.- When the crawl did not fetch the policy, because its path is excluded (
/policies/**on a Shopify storefront) or the page limit came first,eeat/privacy-policynow reportsPrivacy Policy linked but not crawledas info. It warnsPrivacy Policy link returns Nwhen the internal link probe saw that link answer a 4xx or 5xx other than 401, 403 or a rate limit, and it still warnsNo Privacy Policy page foundwhen nothing links to a policy. - The About and Contact rules report
About page linked but not crawledandContact page linked but not crawled(orAbout page link returns N,Contact page link returns N), recognising the link by its path only. - All three count only links to the same host, so a footer link to another site's
/about, or to reCAPTCHA'spolicies.google.com/privacy, never stands in for a missing page. - No extra requests are made. See eeat/privacy-policy.
- When the crawl did not fetch the policy, because its path is excluded (
perf/critical-request-chainsandperf/font-deliverycount only the CSS@imports a browser fetches. An@importinside a comment (/* @import url(a.css); */), a string or aurl()no longer counts as a network request, and neither does one after another rule or inside@media, which a browser ignores.content/placeholder-mediano longer reports a 1x1 inlinesrcas a blank pixel when the image sits in a<picture>whose<source srcset>names a real image: the browser shows the source, so thesrcis a stub.local/nap-consistencyno longer reads a button label as part of a phone number's format. Atel:link showing+49 (0)6061 630 980 0withJetzt anrufenon German pages andCall nowon English ones was reported as one number written two ways. Only the number's own characters (digits,+, parentheses,.,/,-and spaces) are compared now, and a genuinely different rendering such as060616309800still warns. See local/nap-consistency.schema/rating-scopeno longer warns that a rating is not visible when the page prints it in elements with no whitespace between them.<h3>Customer rating</h3><p>5/5</p>used to read asCustomer rating5/5, where no rating phrase matched. The rule now also searches the page text with a space at every element boundary, so glued and spaced markup get the same verdict. See schema/rating-scope.
Release Notes
Seven new rules, and a new category to hold two of them. squirrelscan now ships 308 rules across 22 categories. The new Reliability category catches the things that look fine until a visitor needs them: a deleted script bundle behind a page that still answers 200, or a form that quietly loses every submission. Also new: broken in-page anchors, security.txt and version-leaking headers, the web app manifest, and a misspelling check with its own MIT-licensed word list. The new checks that request other hosts go through a new egress guard, so a page cannot point squirrel at your own network.
Added
- New Reliability category, in the Performance score group, with its first rule,
reliability/broken-resources. A page can answer 200 while the script or stylesheet it depends on returns 404 or 500, usually because a deploy deleted the hashed bundle that cached HTML still references.- A first-party script or stylesheet that answers 4xx or 5xx is an error. First-party means the same registrable domain as the site, so
cdn.example.comcounts forwww.example.com. - A third-party file, a font or a network failure is a warning.
- A 429, 430, 503 with
Retry-Afteror a WAF challenge is recorded as unverified, never as broken. - One finding per URL lists every page that references it.
- A fetch the time budget skipped or a cap dropped is noted as a partial check, and the pass says how many resources it checked.
- Third-party files get a status-only check each:
HEADfirst, aGETonly when theHEADfails or answers 400 or above, and no body read. That is at most 50 per kind per audit, and only while the rule is enabled. These requests carry none of your custom request headers and go through the egress guard (see Security below). - See reliability/broken-resources.
- A first-party script or stylesheet that answers 4xx or 5xx is an error. First-party means the same registrable domain as the site, so
- New rule
reliability/broken-forms: a form can look perfect and still lose every submission.- A field with no
nameis never sent (warning). - A first-party action the crawl found 404 or 410 is an error.
- An action on a staging, preview or localhost host, or a
mailto:action, warns. On a localhost or private-network audit those hosts are the site's own and are not reported; on a preview deployment only localhost and private-IP actions are. - An action the crawl did not fetch is probed only at the
activeoraggressiveprobe level, at most once per endpoint (25 per audit, 10 per site, the site's own first). The probe sendsHEAD, and aGETonly when theHEADis refused (405 or 501) or fails, neverPOST. It carries no cookies or custom headers and follows no redirects. A third-party action is probed only when the audit checks external links and only on public addresses; a same-site action only on the site's own hosts. - A 404 to that probe warns that the endpoint may accept
POSTonly. - See reliability/broken-forms.
- A field with no
- New rule
links/broken-anchors: a link to#pricing, or to/docs/setup#installon another crawled page, whose target id is gone still loads, and the reader lands at the top of the page instead of the section.- The rule warns once per broken target and fragment, lists every page that links to it, and labels a broken skip link.
- It matches the way the browser does: percent-decoded, case-sensitive,
idor<a name>. - It leaves out
#,#top, purely numeric fragments such as#0, hash routes and text fragments. - A link into an unrendered client-side page is recorded as unconfirmed rather than broken.
- See links/broken-anchors.
- Two new security rules.
security/security-txtchecks for an RFC 9116security.txtat/.well-known/security.txt, and the legacy/security.txt. It warns when the file is missing (404 or 410), when the path answers with the site's HTML catch-all page, whenContactorExpiresis missing, and whenExpiresis not an RFC 3339 date-time, has passed, or appears twice. A 401, 403, 429 or 5xx is recorded as not checked, never as missing.security/version-disclosurewarns whenServer,X-Powered-By,X-AspNet-Version,X-AspNetMvc-VersionorX-Generatornames a software version (Apache/2.4.41,PHP/7.4.3), one finding per value listing every page it was seen on. A product name alone (Server: nginx) is fine. On pages stored by an earlier version onlyServeris checked; re-run with--refreshfor the rest.- See security/security-txt and security/version-disclosure.
- New rule
mobile/web-app-manifest: the manifest a page links (<link rel="manifest">) sets the home-screen icon, name and splash colours, and nothing shows on a desktop browser when it rots.- The rule fetches each same-site manifest once, however many pages link it, and its same-origin icons.
- A manifest that answers 4xx or 5xx, or is not valid JSON, is an error.
- These are warnings: a wrong content type; a missing
name(andshort_name),start_urlordisplay; a missing 192px or 512px PNG icon; a broken icon, or one whose real size differs from its declaredsizes; a hextheme_colorthat differs from the pages' hextheme-color. - Redirects off the site, rate limits and WAF challenges are recorded as not checked.
- See mobile/web-app-manifest.
- New rule
content/common-misspellings: every word on English pages (<html lang="en">) is looked up in a bundled list of known misspellings (recieveforreceive), and doubled function words such as "the the" are flagged.- A misspelling in a title, meta description or heading is a warning. One in body copy, and a doubled word, is info-level advice, which reports do not list yet.
- Each finding names the correction, the page count and example pages with context.
- Code, URLs, emails, ALL CAPS, other languages, British or American variants, and names (a word the page capitalises mid-sentence) are left alone.
ignore_wordstakes your brand spellings. - The list of about 520 misspellings was written for squirrelscan and is MIT licensed.
- See content/common-misspellings.
Security
- The new checks that send requests beyond the crawl go through an egress guard: the third-party status checks of
reliability/broken-resources, the form action probes ofreliability/broken-forms, and the manifest and icon fetches ofmobile/web-app-manifest, which are held to the site's host and its subdomains on every redirect hop.- The guard checks the address before every hop, so a public URL that redirects to a private one is refused before the private hop is sent.
- On a public site only public addresses are allowed, except the audited origin itself, which the crawl already requests. A private, loopback, link-local, CGNAT or reserved address, a name that is not public by itself (
localhost,*.internal, a dotless host), and a name that resolves to such an address are all refused. - A site addressed by a private IP or a local name (
localhost:3000,192.168.1.10,nas.local) may reach private hosts, never link-local or cloud metadata addresses. - Each guarded request connects to the address that was vetted, so DNS rebinding cannot swap it between the check and the request.
- The third-party status checks and the form probes carry no cookies and none of your custom request headers or Web Bot Auth headers. Manifest and icon fetches stay on the site's host and its subdomains and carry your custom request headers, like the other same-site asset checks.
- Known limit: your custom request headers still go to subdomains of the audited host, not only the audited host itself (auditing
example.comsends them tocdn.example.comtoo). Limiting them to the audited origin is planned.
Docs
- The docs and README count 22 categories, and their per-category rule counts add up to 308. The dashboard crawl settings and the MCP pages describe the internal link checks setting and the
internal_linksandexternal_linksswitches.
Release Notes
Broken links have nowhere left to hide. Every audit, Quick included, now status-checks the internal link targets its crawl did not fetch, so a linked 404 shows up even when it is not in your sitemap or sits past the page budget. External links stay opt-in at Quick, and there are new flags for both.
Behaviour changes:
- squirrel sends requests it did not send before. After the crawl, every audit sends a status-only
HEADto each internal link target the crawl did not fetch: up to 200 per audit, the links on the most pages first. Before those, it sends oneHEADto the audited URL, to see whether the server answersHEADproperly. These requests send no cookies, credentials or custom request headers, and never go to a link that looks like an action (log out, unsubscribe, delete, add to cart) or carries a token. Switch them off with--no-internal-linksor[internal_links] enabled = false. - Expect new
links/broken-linksfindings, and Links scores that move with them: Quick audits now find linked 404s their sitemap does not list, and Surface and Full audits find broken pages past their page budget. - "Not checked" is narrower. In 0.0.110 a Quick audit reported every internal link target it did not fetch as Not checked. Now that only covers the targets the status check did not answer: over the limit, outside the crawl scope or robots.txt, an action or token link, no answer, or a site whose
HEADanswers cannot be trusted. - External links: which levels check them by default does not change. Surface and Full do, and Quick does not unless you pass
--external-links. An audit that checks them now requests at most its page budget or 200 distinct external links, whichever is more.
Changed
- Internal link checks run at every audit level, Quick included. Each internal link target the crawl did not fetch gets a status-only
HEADrequest, or a one-byteGETwhen the server answersHEADwith 405 or 501.- The check covers up to 200 targets per audit, the links on the most pages first, inside the crawl scope and, when the crawl respects it, robots.txt. Change the limit with
[internal_links] max_targets. - It is free: no page is added, rendered or charged.
- A target that answers with 401 or 403 is recorded as unverifiable in an info-level
refused-linkscheck, never as broken. Reports do not list info checks yet. - See Link checking defaults.
- The check covers up to 200 targets per audit, the links on the most pages first, inside the crawl scope and, when the crawl respects it, robots.txt. Change the limit with
- The internal link check is built not to change anything on the site.
- It never requests a link that looks like an action (log out, unsubscribe, delete, confirm, add to cart) or carries a token. It re-checks every redirect hop against the same rules, and it sends no cookies, credentials or custom request headers.
- On a site that answers
HEADwith an error on the audited URL, the targets' errors are not trusted, so they are never reported as broken. - These rules are a careful default, not a guarantee. A site that changes state on
GETrequests to links that read like pages should switch the check off with--no-internal-links. - See What the internal link check never requests.
- An audit that checks external links now requests at most its page budget or 200 distinct external links, whichever is more, the links on the most pages first. The rest are listed as Not checked (reason
external-links-over-cap). Only sites that link out to more than that see a difference. Change the limit with[external_links] max_links.
Added
--external-linkschecks external links for one run at any level, Quick included, and--no-external-linksskips them.--no-internal-linksswitches the internal link check off.--external-linkscannot be combined with--offlineor--no-external-links, and it overrides[external_links] enabled = false.- At Quick it makes the audit custom,
custom (quick + external_links on). - All three are in the shell completions.
- The local MCP server's
audit_websitetool takesexternalLinksandinternalLinksswitches.offline: truewithexternalLinks: trueis refused, as on the command line.
Release Notes
A small release with two fixes worth having. A site that answers with compression bombs no longer eats gigabytes of your memory while squirrel crawls and checks it: squirrel now decodes compressed responses itself, as it reads them, and stops at each read's size limit. And a Quick audit stops telling you your links are fine when it never checked them: those checks now say Not checked, with what to run instead.
Behaviour changes:
links/broken-external-linksandlinks/broken-linksreport Not checked instead of a pass when they did not run. A Not checked result is left out of the score, and the JSON report gains anotCheckedlist when there is one. A Quick audit's Links score can drop a few points where the empty passes used to pad it.- squirrel now decodes compressed pages and the other responses listed below itself (sitemaps already had their own reader). A response sent as
x-gzipor an upper-caseGZIPis now decoded, where it used to be read as raw bytes. A gzip body made of several members is read to the end of its first member, as browsers read it, where the previous release read every member. A page body that does not decode is still recorded as a decode failure, and the message now gives the decoder's reason.
Fixed
- A compressed page, root file or checked resource that inflates to gigabytes no longer exhausts memory. The runtime could inflate a
Content-Encodingbody in full before squirrel read it, even when squirrel read it under its size limit, cancelled it unread or discarded it with a redirect. In our test, an audit of a four-page site whose page, redirect, robots.txt and llms.txt were each a 1 GiB bomb (sent as about 1 MB of compressed data) peaked at 1.8 to 2.8 GB of memory on the previous releases.- These requests now turn the runtime's decoding off, and squirrel decodes the body as it reads it, stopping at that read's limit: pages and redirects, robots.txt, llms.txt, the well-known and agent files, RSL licences, Markdown versions of pages, the reachability check, scripts, external links, and the soft-404 and cloaking re-checks. An asset's size is counted as its body streams by, without holding it. Sitemaps already turned the runtime's decoding off and capped what they read.
- The same audit, run with this release's build, now peaks at about 275 MB, against about 150 MB for the same site without the bombs, and reports the same findings. The bomb page is cut at 10 MB, as before, which is where an uncompressed page over the limit is cut too.
- These requests send the same
Accept-Encodingas before, squirrel's decoder handlesgzip,deflate,brandzstd, and bytes a server appends after a complete compressed body are still ignored. The decoding changes are listed under Behaviour changes above. - The CLI's script check now stops reading one byte past its size limit instead of downloading the whole script first, and an external link's 403 page is read up to 1 MB for bot-wall detection.
- Per-request figures are in
benchmarks/2026-10-content-encoding-bombs.md. See Compressed responses.
- A Quick audit no longer says your links are fine when it never checked them. Quick,
--offlineand[external_links] enabled = falsedo not request external links. Quick also does not follow internal links, unless the sitemaps give it nothing to crawl. Yetlinks/broken-external-linkspassed with "No external links found to check", andlinks/broken-linkspassed with "No broken internal links detected" when it had seen only the pages the crawl fetched.links/broken-external-linksnow reports Not checked when external links were not checked.links/broken-linksreports Not checked when the audit did not follow links and left internal link targets unfetched. Each comes with the reason and what to run instead.- The result appears in the console, text, Markdown and HTML reports, as
<not-checked>in the LLM and XML reports, and asnotCheckedin the JSON report. - Not checked results are not scored. A broken target a Quick crawl does fetch, such as a 404 listed in the sitemap, is still reported. A Quick audit that fetched every internal link target and found none broken still passes
links/broken-links. - See Which audits can find a broken link.
Release Notes
Published reports went on a diet. Whatever the size of the crawl, the report squirrel publishes is now a summary capped at 1 MiB: issues carry a sample of their pages and items plus the true counts, the score still comes from every check the audit ran, and the full detail stays on your machine. Heads up if a script or CI job runs it: squirrel config validate now checks [rule_options] and exits 1 on mistakes it used to wave through. Also in this release: squirrel mcp hands your agent the squirrelscan skills directly, Cloudflare pages with JavaScript Detections are no longer mistaken for bot challenges, gzip sitemaps get read, and a pile of rule fixes.
Thanks to @deniskern, whose reports are behind the gzip sitemap, core/meta-title, core/charset, links/tel-mailto, a11y/aria-hidden-focus and a11y/button-name fixes below.
Added
squirrel mcpserves the squirrelscan agent skills (squirrelscan1.13 andaudit-website2.5) over MCP through the Skills extension (io.modelcontextprotocol/skills):skills/list,skills/getand every skill file throughresources/read, each listed with its sha256 digest and size. A client that supports the extension gets the skills that match the server it connected to, with no separate install. See Skills.
Changed
- Behaviour change:
squirrel config validatenow checks[rule_options]and exits 1 when it finds a problem. It reports a table whose rule ID matches no rule, an option the rule does not declare, a value of the wrong type and anenabledthat is nottrueorfalse, oneInvalid config:line each, naming the table and the option. It used to printConfig validand exit 0 for all of these, so asquirrel config validate && ...step can now fail on a config that used to pass.squirrel auditruns the same check before it crawls: a wrong-typed option on a rule the audit runs stops it withError: [rule_options."core/meta-title"] min_length: ...instead of failing after the crawl with a bare validation error, and the problems that change nothing are printed as warnings. Theconfigsubcommands now read and write the file--config-filenames, the file an audit uses, where they always used the nearestsquirrel.toml;config setstill never creates a missing file. See rule options. - A published report is now a summary capped at 1 MiB, however many pages the audit crawled. Each issue lists a sample of its affected pages and items (usually 10 pages and 5 items) with the true counts, and the score still comes from every check the audit ran, not just the sampled ones. On the largest reports the samples shrink, and lower-priority issues can be cut down to their counts or left out. The report says it is a summary and where the full list is: the full per-page detail stays in the local project, and
squirrel report --format jsonexports it. Large audits used to publish bodies of several MB.squirrel auditprints a warning when a report needs more than the usual sampling to fit, and a report that cannot fit at all is refused withPAYLOAD_TOO_LARGEbefore it is sent.squirrel report --input <file> --publishnow refuses a file that has page checks but no crawled page list (the--format jsonexport, or a published report) withPUBLISH_PREPARE_FAILED, where it used to publish a score built from its failures alone: publish the audit from its project withsquirrel report <audit-id> --publish. See What a published report holds. squirrel channelnow runs on the same MCP SDK (v2) assquirrel mcp, and the CLI no longer ships the v1 SDK or the HTTP server packages it pulled in. The channel still connects with the 2025-erainitializehandshake, which Claude Code needs to register it as a channel, and the docs now say so.core/charsetreports a UTF-8 page whose charset meta is not first in<head>as placement advice at info status instead of the warning it shared with real encoding problems. Info checks do not count toward the score and are not listed in the report yet. On a page fetched without browser rendering, a charset<meta>that does not end within the first 1,024 bytes of the HTML is now a warning. The rule also warns when theContent-Typeheader and the page disagree about UTF-8, and reads a quoted header value such ascharset="utf-8"without its quotes, but audits do not pass the header's charset to rules yet, so those two take effect once they do. See core/charset.
Fixed
- Gzip-compressed sitemap files, such as a
sitemap.xml.gzchild served asapplication/gzipwithoutContent-Encoding, are now decompressed and parsed.crawl/sitemap-validused to report each one asUnknown sitemap formatand the crawl got none of its URLs. Sitemap requests now ask for gzip (Accept-Encoding: gzip, unless custom headers set another value) and squirrel decodes the response itself, because the runtime's own decoding inflated a whole response before it could be stopped: in our test, a 1 MB gzip bomb sent asContent-Encoding: gzipcost about 1.4 GB of memory. Reading and decompressing now stop at the sitemap protocol's 50 MB uncompressed limit, so a gzip bomb, or a response that keeps sending past 50 MB, is reported asSitemap exceeds the 50 MB uncompressed size limit, and the same bomb now costs under 70 MB. The limit applies to every sitemap body, so an uncompressed sitemap over 50 MB, which search engines also reject, is now reported the same way instead of parsed. A body that does not decompress is reported asCorrupt gzip body: <reason>(Corrupt br bodyorCorrupt deflate bodyfor those codings), and a coding squirrel does not decode asUnsupported Content-Encoding: <coding>. - On a server that answers every path with a 200 page, the common sitemap locations squirrel guesses (
/sitemap.xml,/sitemap_index.xmland six more) are no longer reported as eight invalid sitemaps. A guessed location that answers with an HTML page, or with a body that is not XML, now means no sitemap is there, socrawl/sitemap-existsreportsNo XML sitemap foundwhere it used to pass on those pages. A sitemap that robots.txt or a sitemap index names and that answers with a page is still acrawl/sitemap-validfailure, now labelledNot a sitemap: the URL returned an HTML page. - An ordinary page on a Cloudflare site with JavaScript Detections (which Bot Fight Mode turns on) is no longer classified as a bot-challenge page and set aside from scoring, and
ax/agent-blockingno longer reports GPTBot and Claude-User as blocked on such a homepage. Cloudflare's JavaScript Detections script loads from/cdn-cgi/challenge-platform/, which was read as a challenge whenever it fell in the first 10 KB of the page, as it does on a small one. A challenge is now recognised by the challenge's own script (/cdn-cgi/challenge-platform/h/…/orchestrate/),_cf_chl_optor__cf_chl_, and challenge wording such as "Just a moment" counts only on a401,403,429or503, or, forax/agent-blocking, in the page<title>. A Cloudflare challenge page is still detected and named; a200page whose only sign is challenge wording is now scored as a page. Scores on affected sites change, because their pages are now scored. - A page that mentions Imperva, or any other bot-protection provider, is no longer attributed to it. Body signatures are now the provider's own markup, script hosts and reference formats, not its name: Imperva's
Incapsula incident IDline and/_Incapsula_Resource, DataDome's tag and captcha hosts, PerimeterX's_pxAppId, script host and captcha, Kasada'swindow.KPSDKand script path, Sucuri's block-page title and heading, Akamai's reference id and error host, and Cloudflare's ray id with its value. Theips.js,cd.jsanddd.jsattribution patterns, which matched any script whose name ends that way (tips.js,abcd.js,add.js), are gone or anchored to Kasada's own script path. A block page that carries one of these signatures, or the provider's headers, is still detected with its provider. One that carried only the provider's name no longer is, so a headerless403like that is reported as an ordinary refusal. - A site behind Imperva that answers with
x-cdn: Impervais now recognised as Imperva bot protection, the same asx-cdn: Incapsula, so a blocked crawl names the right provider even when the response carries nox-iinfoheader. a11y/aria-hidden-focusanda11y/button-nameno longer report controls the markup shows are hidden. Both rules now share one check. It reads thehiddenattribute, a closed<dialog>or unopened popover, and an inlinestylethat plainly setsdisplay: noneorvisibility: hidden.a11y/button-namealso skips buttons underaria-hidden="true". An inline style that setsdisplayorvisibilityin a form the check cannot read exactly (a comment,var(), an unlisted keyword and the like) is reported as an unconfirmed warning (aria-hidden-focus-unconfirmed,button-name-unconfirmed), not a failure.<style>blocks and stylesheets are not read, so a control hidden only by a class or selector rule is still reported. Off-screen and transparent controls are still checked.core/meta-titlemeasures the title as a browser shows it: runs of spaces, tabs and line breaks count as one space and the ends are trimmed. Themes that indent<title>across several lines (Shopware, Shopify) were reportedTitle too longbecause of their indentation. The finding's value and item now show the collapsed title, and a title holding only whitespace is reported missing, like an empty one.links/tel-mailtono longer reports a mismatch for a link such as<a href="tel:+4960616309800">+49 (0)6061 630 980 0</a>. The(0)is the optional trunk prefix, which is dropped after the country code, so the link dials the number shown. That display form is now formatting advice in a newtel-trunk-prefixcheck at info status, which does not count toward the score and is not listed in the report yet. The reading applies only to the link text and only to calling codes whose trunk prefix is 0 (+31, +32, +33, +41, +43, +44, +49, +61, +64 and +353); a display number that really differs from the href goes through the mismatch check as before. See links/tel-mailto.perf/total-byte-weightno longer counts a page's inline CSS and JavaScript twice. The HTML size already includes them, and the rule added them again, so a page with a 1.8 MB inline stylesheet read as about 3,600 KB. The breakdown's HTML total holds the inline code, and its CSS and JavaScript totals now hold external files only. See perf/total-byte-weight.content/hidden-textreads<style>sheets and inline styles the way a browser tokenizes them. A{,}or;inside an unquotedurl(...), inside a(or[block, or after a backslash no longer ends a rule or a declaration, so a sheet such as.a{background:url(}.b{display:none}.c{)}, which a browser reads as one rule, is no longer reported as hiding.b. See content/hidden-text.- Template fan-out no longer shares a verdict between pages that differ in the
<html>language or aria-hidden, the<body>aria-hidden, or a favicon link. Those are inputs three template rules read. squirrel auditno longer seems to hang after printing the report url on a machine with many local projects. Marking the report as published opened every project database under~/.squirrel/projectsuntil it found the audit (5.5 s for 2,000 projects, and far longer on a large store); it now opens only the audited project's database.squirrel report --publishmarks the database the report was loaded from.- A release download that stalls no longer hangs
squirrel self updateor the background updater. The download used to be bounded only until the server sent its headers, so a body that stopped arriving left the detached updater waiting forever, holding the update lock until the lock went stale after 30 minutes. A download now fails when no data arrives for 60 seconds or it has not finished after 20 minutes, the lock is released, and a later run tries again. The new bounds apply to updates made from this version on: the update to this version still runs on the previous release's downloader. - Docs: custom request headers are on every plan, Free included, where several pages said Pro only, and the pages that still called local audits free in general now say a local audit costs nothing only when it runs signed out or with
--offline. The firewall page now covers letting an audit through Cloudflare Access, Vercel deployment protection and HTTP basic auth, the failed audits guide walks through each failure cause, and the hosted MCP page lists every tool,get_website_settingsandupdate_website_settingsincluded. The docs, README and npm package now give one rule count: 300+.
Release Notes
Audits now come in three sizes, called levels: quick, surface and full. A level is one name for a whole set of settings (pages, crawl strategy, cloud checks, rendering, external link checks and probing), defined once and shared by the CLI, the local MCP server and the dashboard, so a surface audit means the same thing wherever you start it. A signed-in cloud audit costs credits at every level, quick included: 50 for the audit plus 2 per audited page. Signed out or with --offline, the audit runs on your machine and costs nothing. The Audit levels guide has what each level reads, checks and costs. Also in this release: the local MCP audit_website tool now bills a signed-in audit the way squirrel audit does, a dev server on localhost stops getting marked down for having no HTTPS, squirrel self uninstall is safe for package managers to call, and a pile of rule fixes.
Added
- Audit levels:
--level quick|surface|full. A level is a named set of audit settings (pages, crawl strategy, cloud checks, rendering, external link checks and probing), defined once and read bysquirrel audit,squirrel crawl, the local MCP tools, the config and the shell completions. Quick reads up to 25 pages from the URL and its sitemaps (it follows links only when the sitemaps give it nothing to crawl), surface up to 100 pages with one page per URL pattern first, and full every page it can reach, up to 500. Without--levelor[crawler] coverage,squirrel auditruns surface when you are signed in and quick when you are signed out or your session has expired, andsquirrel crawlruns quick.--coverage/-Cand[crawler] coveragekeep working as the older names andfaststill means quick. An unknown level, a repeated--levelor-C, or two flags naming different levels stop with an error before the crawl starts. Picking a level fills in every setting; the flags and config values you already use (--max-pages,--render-mode,--http,--probe,[external_links] enabled = falseand the rest) change single settings, and an audit whose settings differ from its level becomes custom. The run banner says which:Level surface · max 100 pages, orLevel custom (surface + max_pages 200). See Audit levels. - Reports say which level they ran at. The JSON report has
meta.auditLevel(level,basedOn,changes,settings), the LLM report an<audit-level>element, and the text, Markdown and HTML reports anAudit level:line. A JSON report loaded back withsquirrel reportkeeps it. No existing field changes. - The local MCP
audit_websitetool takeslevel(coveragestill works) and defaults to surface. It applies the project config's page limit, rendering, external link and probing settings the waysquirrel auditdoes, including[crawler] max_pages. - After the crawl, the audit lists the pages whose response could not be decoded, with each URL and its declared content-encoding: the first five, then a count of the rest.
Changed
- The local MCP
audit_websitetool bills a signed-in audit the waysquirrel auditdoes, at every level: it registers the run (50 credits) and settles it on its audited pages (2 each), renders pages in the cloud browser as the level says (quick only the pages that need JavaScript, surface and full every page), and runs the cloud checks at surface and full. Before, it could run the cloud checks without registering an audit. A tool call cannot prompt, so when the estimate is above[cloud] confirm_threshold(50 by default, as in the CLI) the call returns it asconfirmation_required, and the agent calls again withconfirm: true. The page budget is fitted to the balance and to[cloud] max_credits_per_audit; an audit that cannot pay for even one page is refused with the cost and the balance, and so is one the server will not register. An audit that errors asks the server to mark its run failed, and stopping the server with Ctrl-C or a termination signal asks it to mark the run cancelled, so both settle like a failed or cancelledsquirrel audit. Signed out, withoffline: true, against a local or private host, and inquick_check, the tools still run on your machine with no cloud call. - A level's settings are now the defaults. Quick no longer checks external links and probes passively, while surface and full check external links and probe actively, signed in or not. Before, external links were checked at every level and probing followed sign-in instead of the level. A signed-out audit with no flags runs quick, so it now skips external link checks. Explicit flags and config values win as before.
max_pages = 100, whichsquirrel initwrites into every config, still reads as unset, and so does[external_links] enabled = true, which it also writes, so a generated config no longer turns external link checks on at quick. Set--level surfaceorfullto get them back. - Hints and locked-check messages point at
--levelinstead of-C, and the quick-level message no longer says "coverage". security/leaked-secretsreports a server-only secret found in a browser-served script (database connection strings, service-role andsk_live_keys, AWS secret keys and similar) in a newleaked-secrets-criticalcheck, "shipped to every visitor". Publishable keys, DigitalOcean Spaces key ids, tutorial values (AWS's documented example keys, placeholder orlocalhostdatabase URLs) and hits only in page HTML are unchanged.perf/bad-cachingexplains a missing ETag behind Cloudflare. Cloudflare drops theETagfrom HTML it rewrites (Email Address Obfuscation, Automatic HTTPS Rewrites, Replace insecure JavaScript libraries, and JavaScript Detections, which Bot Fight Mode turns on), sometimes only for requests that ask for a page, so a plaincurl -Ican show a header browsers never get. When the validators check warns and at least half of the pages missing a validator came through Cloudflare, the finding now says the CDN may be the cause and lists the fixes:no-transformin the HTMLCache-Control, turning those features off, or also sendingLast-Modified. The validators check now warns instead of failing, because a page with no lifetime and no validator already counts against the freshness check. A weakETag(W/"...") still counts as a validator for every content encoding. The rule page shows how to check which side removes it.crawl/pdf-sizedescribes its 60MB failure threshold as squirrel's default, not as Google's limit. Google documents 64MB for PDFs; the rule's messages, description and option text now say so.security/sri,content/meta-in-bodyandschema/rating-scopeuse one message wording whatever the count, andschema/rating-scopeno longer puts the rated entity's name in its check message, so one defect keeps one finding identity across pages.squirrel self uninstallonly deletes what a squirrelscan install created, and is ready for package managers to call. It removes thesquirrellink (in the default bin directory and the one recorded byself install --bin-dir) and~/.squirrel/releases, and keeps settings, credentials and audit data unless you pass--purge, which now also removes the cache directory. Each link is checked against the managed install before anything is removed: a link pointing anywhere else, an unrelated file at the link path, or a symlinked~/.squirrelis reported and left alone, and a symlink inside the releases is never followed. It will not delete a managed install when run from some othersquirrelbinary. It asks before deleting, and without a terminal it refuses unless you pass--yes(the new name for--force, which still works). With nothing left to remove, it exits 0. See self uninstall.- The install docs cover Homebrew:
brew install --cask squirrelscan/tap/squirrelinstalls a launcher that runs the regular installer on first use, after whichsquirrel self updateowns updates.brew uninstall --cask squirrelrunssquirrel self uninstall --yes, and--zapalso removes~/.squirrel. See the quickstart.
Fixed
- A clean site audited on
localhostor a private-network host no longer scores a C or D for being a dev server. The transport and delivery rules (security/https,security/hsts,security/http-to-https,perf/http2,perf/compression,perf/asset-compression,perf/cache-headers,perf/bad-caching) are skipped there and left out of the score, so the same cause (no HTTPS, no compression, no caching policy) is no longer charged by several rules at once. The report says so in one line under the score, and the JSON report asnotApplicable. The host is classified from the audit URL by the same check the cloud handoff already used, now shared in@squirrelscan/utils; public hosts score exactly as before. See Transport and delivery rules do not apply. - The docs said local audits were free. The agent setup pages, the cloud overview, the setup guides and every rule page now say it plainly: signed-out local audits need no account and cost nothing, and signed-in audits spend credits.
squirrel crawl -C fastcrawled with no page limit at all: the crawl cast the flag to a level without checking it, the page lookup forfastcame back empty and the cap becameNaN. The crawl now reads its level through the same parser asaudit, sofastis quick (25 pages) and an unknown level stops before the crawl starts. A non-numeric--max-pagesis refused too, instead of the same unbounded crawl.- The local MCP
quick_checktool described itself as a single-page check. It reads the URL and its sitemaps, 25 pages by default, and now says so. It also follows the project's[crawler] max_pagesand[security] probesettings. - The console report footer counts warnings the same way as its "Total:" line. Before, the footer counted advisory warnings from severity-"info" rules and the score line did not, so one report could show two warning totals.
squirrel audit -f json -o <file>prints the "JSON report saved to" line on stderr instead of stdout.- A malformed
squirrel.tomlprints the "TOML syntax error:" line with the parser detail again. The check compared the error name, which the parser never sets, so the labelled message never showed. content/heading-hierarchydoes not count an empty heading the markup hides (hidden, inlinedisplay:noneorvisibility:hidden, or a hidden oraria-hiddenancestor). The parser marks those headings, so a cached parse counts the same as a fresh one.- A signed-in audit of a localhost, private-network or other non-public host no longer sends the archive-indexing lookup to the cloud, where it could only come back empty.
- Template fan-out, which lets the pages of one template share a rule's verdict, now groups pages only when the markup those rules read matches too: the same scripts and stylesheets (and whether each carries
integrity), the same<meta>tags with the same viewport, geo and refresh values, and the same number of<main>landmarks. Before, a page missing its viewport meta, carrying a second<main>or loading a different per-route bundle could inherit a sibling's result. Fan-out runs when the rule cache is off: in cloud audits, and in CLI audits withSQUIRREL_RULE_CACHE=0. On a recorded 4000-page gymshark.com crawl, 3931 of 3983 pages still group with their template (3946 before). - Results replayed from an earlier run ignore an
itemsfield that is not a list, so a damaged stored row can no longer crash a smart audit or a publish while it merges results. crawl/all-noindex-pagesvalidates itswarnOnPatternsanderrorOnPatternsoptions. A value that is not a list of strings now fails option validation, instead of reaching the matcher and breaking the rule.perf/source-mapsignores<script>and<style>inside<noscript>. A browser with scripting enabled never runs that markup, so a source map referenced there is no longer reported.content/article-tocfinds a table of contents ItemList nested in a JSON-LD@graphwrapper, as Yoast, Rank Math and Slim SEO emit. Before, the rule reported a missing TOC schema on those sites.
Release Notes
squirrelscan can now probe, politely: a new probing intensity setting decides how much an audit may send, and the first two probing rules (GraphQL introspection and GraphQL mutations over GET) use it. There is also a rule for auth tokens kept in web storage, a rule for a CSP that blocks the page's own resources, quieter secret scanning, and the entity page filter now behaves the same in the CLI, the MCP tools and the hosted API.
Added
security/token-storageflags JWTs and session or access tokens written tolocalStorageorsessionStorage. Anything in web storage can be read by any script on the page, so one XSS bug turns a stored token into an account takeover, where anhttpOnlycookie would not. It is passive: it reads the inline scripts and the external scripts the crawl already fetched and sends no extra request. A key is split into words, and whole words decide, soaccess_token,jwtandidTokenflag whiletheme,dismissedandcsrfdo not; a value shaped like a JWT flags under any key. The report names the script and the key and never prints the value.- Two GraphQL probing rules, the first rules that use probing intensity. They run only when probing is
activeoraggressive, probe only the audited origin, and on apassiverun send nothing. Both probe the GraphQL endpoints the audit found in your pages and scripts, plus/graphqland/api/graphql, at most five per rule, and never a path that names a state-changing action such aslogoutordelete. Belowaggressivethey skip pathsrobots.txtdisallows.security/graphql-introspectionsends oneGETwith the read-only query{__schema{types{name}}}and warns when an endpoint returns its schema, naming the endpoint and the type count; on anaggressiverun it may follow up with onePOSTof the same query when theGETanswer is GraphQL without a schema.security/graphql-get-mutationssends oneGETwith the no-op mutationmutation{__typename}, which cannot write, and fails when the server executes it, because a mutation reachable byGETcan be fired cross-site with your visitors' cookies (CSRF). Disabled introspection, errors, a405and HTML or catch-all answers are not flagged. See Security configuration. That makes 301 rules, 20 of them in Security. security/csp-blocks-own-resourcesflags a Content-Security-Policy that blocks what the page itself loads. It matches each page's own policy (the header and any<meta http-equiv>policy) against the page's scripts, preloaded modules, stylesheets, frames and images using real source matching, so*.example.comallowscdn.example.combut notexample.com. It also looks inside the same-site chunks a page loads for third-party script URLs, and checks the hosts a detected vendor needs at runtime (Google Analytics 4 and Google Ads endpoints, Google Tag Manager, PostHog, Stripe, Cloudflare Turnstile, reCAPTCHA), which the HTML never names. A blocked analytics, ads, payments, captcha, form or booking host fails and any other host warns, naming the directive and the host to add. A page with no enforced policy passes, because a missing CSP stays withsecurity/csp. See the rule page. That makes 298 rules, 17 of them in Security.- Probing intensity, a third audit setting next to coverage and render mode.
--probe(-P) takespassive,activeoraggressive, with--passiveand--aggressiveas shortcuts and--pentestfor--coverage full --probe aggressive.--probe-budgetcaps the wall-clock time of all probing together (default30sactive,2maggressive).[security] probeandbudgetset both in config. A logged-in audit defaults toactiveand a logged-out one topassive;aggressiveis never a default.--disable-discovery-probeskeeps probing passive. No probing rules ship yet, so no audit sends anything new: this adds the setting and the shared budget future rules will use. See Security configuration.
Changed
- Publishing from a large cloud audit no longer slows down as a site's page history grows. The merge indexes the prior pages once instead of copying them several times, builds the full page list only when something reads it, and writes back only the pages the run changed. At 25,000 pages this removes tens of milliseconds of cold-start work per publish.
- The
send_feedbacktool description in the local MCP server is neutral about when to use it, and a test now fails when the MCP docs drift from the tools the server lists. squirrel entitiesand the MCPlist_entitiestool now apply the same page filter rule as the hosted API, and a filtered result keepssummarysite-wide.--pagewith an absolutehttp(s)URL is a prefix of the page URL, a value starting with/is a prefix of the path and query (so--page /blogno longer matcheshttps://example.com/tag/blog), and anything else is a substring. Use--page blogfor the old substring behavior. A filteredsquirrel entities -f jsonandlist_entitiesused to return asummaryrecomputed over the matching entities. They now return the site-widesummaryand a newfiltered: { nodes, edges }block with what the filters kept, so scripts that readsummary.nodeCountafter a filter should readfiltered.nodesinstead.--diffstill compares recomputed summaries.- The audit also fetches the same-site script chunks a page preloads with
<link rel="modulepreload">or<link rel="preload" as="script">, sosecurity/csp-blocks-own-resourcescan read the third-party script URLs inside them. They are kept apart from the scripts every other rule reads, so no other rule's result changes, and they are fetched only when a crawled page enforces a CSP, capped at 20 extra fetches per audit. The rule cache format changed, so the first audit of each site after the upgrade runs every rule fresh, once.
Fixed
security/leaked-secretsraises fewer false alarms. A label that ends in a credential word, such as a translatedPassword:heading in compiled JSX, is no longer read as a key whose value is the markup after it, template expressions and placeholder connection strings are not credentials, and a key that runs past a provider's format is reported as a secret of unknown provider instead of being named as that provider's. Public vendor identifiers in a script's query string (Mida, Klaviyo and reCAPTCHA site keys) are skipped. A Statsigsecret-key is reported as a server secret and aclient-key as the public SDK key it is.- A site that sends response headers and then stalls its body can no longer hang the CLI. The shared request helper used by the reachability check, the robots.txt fetch and the update check disarmed its timeout as soon as the headers arrived, so the body read had no time limit. The timeout now covers the body too, and a stalled body fails like any other timeout.
- The audit engine can keep the cloud results it has already paid for when its caller stops the cloud analysis step at a deadline, instead of discarding all of them. A caller turns this on with the new
deadlineMsoption on the container prefetch (or by passing its own accumulator and callingabandon()). Results that came back are used by the rules, rules still waiting reportskippedwith reasonservice-unavailable, and no new cloud call starts after the deadline. The spend lists each call known to be charged, once: every call that came back, plus a render batch the cloud accepted but had not finished. A call still running at the deadline is not listed and may yet be charged, so the estimated balance after the run is marked approximate (balanceAfterApproximate). Nothing in the CLI sets a deadline, so CLI audits are unchanged, and a prefetch that finishes in time returns exactly what it did before. See credits.
Release Notes
squirrel mcp speaks the new MCP 2026-07-28 protocol, squirrel channel gives your Claude Code session a heads-up the moment a cloud audit lands, every rule now runs against the clock to keep booby-trapped pages from stalling an audit, and two new content rules catch the info@example.com and placehold.co your theme shipped with. Plus a big pile of false positives, swept out of the nest.
Added
squirrel mcpsupports MCP protocol revision 2026-07-28 and the 2025-era protocol from one process. It moved to version 2 of the MCP SDK and picks the protocol from the client's first message: a 2026-07-28 client connects with noinitializehandshake and reads the server's capabilities fromserver/discover, and an older client keeps theinitializehandshake. There is nothing to configure. Tool names, descriptions and input schemas are the same in both, and the schemas are now published as JSON Schema 2020-12 rather than draft-07. The MCP docs have the details.squirrel channelpushes cloud audit events into a running Claude Code session. Add it to your MCP config assquirrel channel, and when a cloud audit finishes, fails or finds new issues (audit_complete,audit_failed,issues_detected), your agent gets a one-line event with the ids it needs to pull the report and start on the fixes, without being asked. It polls your organization's notification feed every 30 seconds (--interval, from 5 to 86400),--categoriespicks the events, and it backs off on 429 and 5xx responses. Event text is built from validated ids, hostnames, counts and fixed strings, never from notification titles or text on your site, so a crawled page can't talk to your agent through it. It needs a login session (squirrel auth login), because API keys can't read the feed; without one it sends a singlelogin_requiredevent and keeps running. It remembers where it stopped, per organization, and catches up once on the next start (until the cloud feed supports cursors, that catch-up reads back only the latest 50 notifications). Channels are a research preview in Claude Code, so for now you start Claude Code with the development flag. The channel guide has the setup.- Every rule's synchronous work runs under a time budget: one second per page for a page rule, and one second per page of the site for a site rule, capped at 60 seconds. A rule that waits on the network is timed up to its first wait, and its requests keep their own timeouts. A rule that runs past its budget is stopped and the audit carries on with the next rule. The report records it once per audit, as a failed
<rule>-errorcheck (Rule error: exceeded its 1000 ms time budget and was abandoned, naming the larger budget for a site rule) against the first page it happened on (or the whole site, for a site rule), and a page rule also gets an info<rule>-timeoutscheck with the number of pages it did not check. Ordinary pages come nowhere near it: measured over 500 pages, the slowest page rule's 99th percentile was about 6 ms. A page where a rule timed out is never stored in the rule cache, so the next audit runs it again. The rules reference has the details. content/placeholder-contactflags contact details a template shipped with and nobody replaced: email addresses atexample.com,test.comoryourdomain.com, the fictional555-01xxand UK07700 900xxxphone ranges,123 Main St, and handles such as@yourhandle. It reads the visible text and every link'shref, so amailto:info@example.combehind an "Email us" link counts. It fails on a contact or checkout page, skips other pages under documentation paths such as/docsand/api, warns everywhere else, and never judges a code sample. See the rule page.content/placeholder-mediaflags images that were never finished: a placeholder service source (via.placeholder.com,placehold.co,dummyimage.comand friends, orplaceholderas a word in the image's filename), a blank 1x1 inline image used as content, and camera-default alt text such asDSC_0001orIMG_1234. A placeholder source or a blank stand-in fails and a camera-default alt warns. A lazy-loaded image is judged on its realdata-src, not its stub. See the rule page. That makes 297 rules, 23 of them in Content.- The JSON report lists checks a rule skipped because it hit its scan limit, in a new top-level
skippedCheckslist with the rule, the check, the reason and the pages (with the full count when a long list is clipped), andsummary.skippedadds up those skipped page checks. Before, the JSON dropped them, so a page thatcontent/hidden-textorcontent/dev-leakagecould not finish scanning read as clean. A report with no such skips has the same keys as before. The output docs now describe the JSON shape the CLI really writes.
Changed
perf/total-byte-weightscores each page on its own weight, as its 1600 KB and 5000 KB thresholds intend, instead of adding up the whole crawl. A page's weight is its HTML, its inline CSS and JS, and the stylesheets, scripts, images and fonts it loads, and a file several pages share counts toward each of them. The HTML and fonts (from a preload link or an inline@font-face) are counted for the first time. The message names the heaviest page and how many pages are over each threshold, and it says how many resources it could not size instead of quietly counting them as zero bytes. A site with one heavy page can now warn or fail where it didn't, and a large site of light pages no longer does. Same-site fonts are sized like images, normally with oneHEADrequest per distinct font, inside the existing resource check limit. See the rule page.- The rule cache is keyed on the rule code as well as the CLI version, so running from a source checkout no longer replays findings from before a rule change. The cache format changed, so the first audit of each site after the upgrade runs every rule fresh, once.
crawler.includepatterns narrow the crawl within its scope instead of replacing it. A path-only pattern such as/blog/*no longer admits/blog/URLs on other hosts (they could arrive through a sitemap), while an absolute-URL pattern still reaches the host it names. An explicit[project] domainslist is a hard allowlist: an absolute include for a host it does not list is refused, with one warning naming both. A config that relied on a path-only include to reach another host needs an absolute pattern, or that host in[project] domains. The crawler docs have the details.- Published reports from a large audit count every page the crawl fetched as audited. Before, a page left out of the published sample, or one no page check looked at, was missing from the audited count and from the page charge. On the next publish of such an audit, pages that were left out of the sample and passed every check count toward the score, so its score can rise once, toward what a complete cloud audit gives it.
- A rate-limited crawl says how much it never reached. The partial status reads like
437 pages rate limited by host; 1355 more discovered but not fetched, and the text, markdown, llm and JSON reports carry the count of URLs it found but never fetched. - Reports can say a cloud crawl stopped because it ran out of time rather than at its page limit: the scan scope line adds
stopped early on time, the partial-scan note says the site may have more pages than the score covers (without suggesting a higher page limit, which wouldn't help), and the JSON report carriesstopReason: "time"next tomaxPages, the llm outputstop-reason="time". - The render limit warning says how many pages your plan renders at once, per audit: 1 on Free, 5 on Pro and 10 on Team.
- The credit wall links to the plan comparison on the pricing page next to the upgrade link, and an account already on a paid plan is pointed at a top-up instead of being pitched the plan it already pays for.
- Installer error reports include the version being installed and, on Linux, whether the system uses glibc or musl, so a failed install can be matched to its release.
- The local project store (
~/.squirrel/projects/<domain>/project.db) upgrades itself to schema version 33 the first time this version opens it, to store each page'shreflangalternates. It only adds a column, so v0.0.105 can still open the upgraded store.
Fixed
Thanks to @deniskern, whose reports are behind the hreflang, noindex, dom-size, autocomplete, empty heading, meta-in-body, video caption and LCP fixes below, and the new skippedChecks list.
core/title-unique,content/duplicate-titleandcontent/duplicate-descriptionno longer report region variants of one page, such as/en-gb/shirtand/en-us/shirt, as duplicates when each lists the other as anhreflangalternate in the same language and neither canonicalises elsewhere. Adeandenpair with one title is still reported. A page reused from the crawl cache picks this up once it is fetched again, or with--refresh.crawl/indexability-conflictsno longer reports robots.txtAllowplusnoindexas a conflict, since that is the supported way to deindex a page. Its remaining info check, a page robots.txt blocks without anoindex, now lists the pages.crawl/sitemap-coverageno longer lists a noindex page as missing from the sitemap, andcontent/title-pattern-outlierleaves noindex pages out of its template vote, when the site itself is indexable.- The
dom-max-childrencheck ofperf/dom-sizecounts<body>only, so a<head>full ofhreflangalternates or preload hints no longer trips its 60-children limit. a11y/autocomplete-tokensno longer asks for anautocompletetoken on a country select that is the only personal-data field in its form, such as a locale or market switcher. An explicit token is still checked.a11y/empty-headingskips headings hidden in the markup (hidden, inlinedisplay:noneorvisibility:hidden, or anaria-hiddenor hidden ancestor), and each item shows the heading's tag and its position on the page, so two empty<h2>s can be told apart.content/meta-in-bodyskips schema.org microdata<meta itemprop>tags, which HTML allows in<body>.a11y/video-captionsandvideo/video-accessibleskip decorative video, one that isaria-hiddenor muted with no controls and autoplays or loops, and both rules now agree on what counts as decorative.perf/lcp-hintsuses the same LCP candidate finder asperf/lcp-fetchpriority, so header and nav images, small logos, icons and tracking pixels no longer count as likely LCP images, and a preload matches its image across relative and absolute URLs, query strings andsrcset.content/date-agreementreads dates in French, German, Spanish, Italian, Portuguese and Dutch, year-month-day dates with Japanese, Chinese and Korean markers, and numeric dates, reading an ambiguous08/01/2026both ways. It stays quiet about a date it can't read instead of warning that the page shows none, and a year in a slug like/reviews/2024-honda-civic-reviewno longer counts as a publication date.security/new-tabno longer warns ontarget="_blank"withoutnoopener, because browsers imply it, andnoreferrerimplies it too. It warns only on an explicitrel="opener", readsrelas a list of tokens rather than a substring, and a missingnoreferreris an info-level privacy note.security/mixed-contentreports onlyhttp://URLs in markup that loads a resource, so canonical and alternate<link>s, which are never fetched, no longer count. Loopback URLs such aslocalhostand127.0.0.1are not mixed content and get their ownlocal-development-urlwarning, and each finding names its tag and whether a browser would likely block it or upgrade it, and says it comes from the markup rather than an observed load.links/no-contextual-inboundskips when the crawl stopped at its page limit, because a page it never reached could hold the link.legal/subprocessor-disclosurewarns only when the crawl shows a commercial offer (a pricing, plans, enterprise or trust page, or a sales or demo link), so a pre-launch waitlist page is skipped.content/keyword-stuffingno longer counts the site's own brand name, taken from its domain.- An audit of a site that refuses the crawler (401, 403, 429 or a bot challenge) no longer reports its robots.txt, sitemap,
llms.txtor Markdown response as missing.crawl/robots-txt,crawl/sitemap-exists,ax/llms-txtandax/markdown-responsesay "not checked" and name who refused, a failed or blocked audit scores no categories, and the text, markdown, llm and JSON reports list the refused requests and, when the response names it, the bot protection behind them. - A response whose body doesn't match its declared
content-encodingis recorded as a failed page, with an error naming the URL and the encoding, and the audit is marked partial with a count such as2 pages could not be decoded (bad content-encoding). Before, the page vanished and the audit claimed to be complete. - A meta refresh inside
<noscript>no longer moves the audit to the no-JavaScript page. A browser with scripting on never follows it. - A cached page reused by an incremental crawl follows the same link discovery settings as a freshly fetched one. With link discovery turned off, one reused homepage used to put the whole site back in the queue.
- A JSON-LD node with its own
@typeand a@graphkeeps its own type, so aWebPagewrapping anOrganizationis checked as both. A page reused from the crawl cache picks this up once it is fetched again, or with--refresh. - Soft-404 detection picks up framework error pages served with a 200, such as a Next.js
__next_error__shell, as candidates in every audit. The engine the CLI uses read that marker from the page's DOM after it had already released it, so it missed them. - The entity map published with a report sets aside about a fifth of its node budget (more on a small map, so each kind of problem still gets one) for nodes that show the entity problems the rules report (conflicting properties, dangling references, a missing or malformed
@id, a split identity and the rest), taking turns so no one kind crowds out the rest. A finding's evidence is no longer sampled out of the map just because the entity is rarely linked. The map in the HTML report is chosen the same way. - Merged report messages no longer turn HTTP statuses into ranges: redirect chains ending in a 404 and a 500 stay separate rows instead of merging into a
(404 to 500)status that doesn't exist. Redirect messages now write the status as(HTTP 404). - A signed-in audit whose publish gets a 5xx after the report was already saved now returns the report URL instead of failing. The CLI checks whether the run gained a newly linked report, and does not post the report again after a 5xx.
Security
- Six regular expression scans that a crafted page could make backtrack for seconds now run in linear time and match exactly what they matched before: four in
security/leaked-secrets(JSON Web Tokens, Google OAuth client ids, the<script>tag scan and the key-name look-back), the CSS custom property scan behind the template fingerprint, and the title separator incontent/title-pattern-outlier. The time budget above is the backstop for anything we haven't found yet.
Release Notes
images/responsive-size measures thumbnails in pixels, schema/json-ld-valid follows schema.org and Google more closely and now checks the fields of each breadcrumb and FAQ question, and squirrel entities shows the page each dangling reference comes from.
Changed
images/responsive-sizecompares a thumbnail's real pixel size with the size itswidthandheightattributes give it, allowing for 2x screens, the way Lighthouse's responsive images audit does. It still looks only at images 100x100 or smaller by those attributes (the defaultmax_thumbnail_dimension), with nosrcsetand outside<picture>. A large image shown small is now reported even when its file is light, such as a 1200x1200 JPEG of 20 KB shown at 64x64, once the pixels a 2x screen never draws come to 4 KiB or more of the file, so this rule can report more than before. With the pixel size known, an image no more than twice the size it is shown at is not reported, animated or not, so an animated WebP shown at its own size no longer is. When the pixel size can't be read, such as a JPEG whose dimensions sit past the bytes read, the rule falls back to the file size check from v0.0.104, which still skips SVG, ICO, GIF and animated images. The rule page has the details.- To read that pixel size, the image check now requests the first 32 KiB of each image on the host you audit and its subdomains, normally with one ranged
GETin place of theHEADit sent before. At the default 500-image limit that is about 16 MB of image data, less when files are small or reused from the cache. A server that rejects the range, or answers without a length, can cost extra requests. Raster images an earlier version measured are requested again on the next audit so their size can be read, even when their cache headers would have let the audit reuse them. schema/json-ld-validnow checks the fields of each entry in aBreadcrumbListand aFAQPage. Those per-entry checks never ran before, so the rule can report findings it did not: a crumb with noposition, noitem(the last crumb may leave it out, as Google allows) or nonameon the crumb or itsitem, and a question with nonameoracceptedAnswer.a11y/label-content-name-mismatchand thetabindex-very-highcheck ofa11y/tabindexlist up to ten elements, and now count the elements past those ten toward the score as well, up to 20 per check and page, the same cap every failing check has. A page with more than ten of them can score lower. Warning checks that cut their item lists short now also record how many items they left out, asdetails.additionalin the JSON report:tabindex-positive, and checks ina11y/identical-links-same-purpose,a11y/link-in-text-block,perf/animated-content,perf/source-mapsandschema/rating-scope. Warnings count once per check and page, so their scores don't change.- The local project store (
~/.squirrel/projects/<domain>/project.db) upgrades itself to schema version 32 the first time this version opens it. Version 31 stores the pages behind each entity map reference and version 32 stores each image's pixel size and whether it is animated. Both only add columns, so v0.0.104 can still open the upgraded store.
Fixed
schema/json-ld-validno longer flags valid markup: a single recipe ingredient, recipe instructions given as text or as a single step, a single FAQ question or breadcrumb not wrapped in a list, a breadcrumb named on itsitemnode, a Product offer that is anAggregateOffer(it needslowPriceandpriceCurrency, notpriceandavailability), an Article with no publisher or a publisher with no logo, and fields given on a top-level node that has an@idbut no@type, which a reference to that@idnow finds. A page reused from the crawl cache picks up that last fix once it is fetched again, or with--refresh.squirrel entitiesand the entity tools insquirrel mcpshow the page each dangling reference comes from. The "Example page" column was always empty, because the store kept no pages for a reference. The edges andsitethey read back now match the audit report's entity map, andsiteis the URL you audited rather than the site's origin (older audits that never recorded that URL still show the origin). New audits store the pages, andsquirrel analyzefills them in for an audit stored by an earlier version.- A finding in your dashboard for an element you fixed, or for one a rule update renamed, now resolves when a published audit checks that page again and lists every item of that check, even while the page still fails the check for another element. Before, it stayed open for as long as the page failed the check at all. A check that lists only some of its items, or could not finish its scan, keeps the rest open, because the audit can't tell they were fixed.
content/hidden-textandcontent/dev-leakageskip a page that hits their scan limits when the part they read was clean, instead of passing it. The pass told your dashboard the page was clean, so a finding from an earlier audit that sat past the limit was marked fixed while it was still there. It now stays open.- The configuration docs use the rule ids and option names the rules read. Entries such as
[rule_options."performance/ttfb"],exclude_patternsandmin_external_linksmatched no rule or option, and an audit ignores an unknown rule id or option without a warning (squirrel config validatedoesn't catch it either), so copying them changed nothing.content/keyword-stuffing'sdensity_thresholdis a percentage (3, not0.03), and the URL patterns these rule options take are a substring, a/segment/or aregex:, never a*glob. The rule options reference is corrected and points to the Options table on each rule's page, and thesquirrel initpage shows the config file the command really writes.
Security
- The MCP SDK is updated to 1.32 for a published advisory in its OAuth client (GHSA-6qxp-vccf-f47h).
squirrel mcpruns only the SDK's stdio server and the OAuth client is not part of the squirrel binary, so the CLI was not exposed.
Release Notes
Pages kept out of search stop counting against title, description and heading checks, and four fixes from community bug reports cut false positives in structured data, image and accessibility checks.
Changed
- Pages kept out of search are no longer graded on how they would look in search results. On a page whose robots meta tag or
X-Robots-Tagheader saysnoindex,core/meta-title,core/meta-descriptionandcore/h1record a skipped check instead of a finding, andcore/title-unique,content/duplicate-title,content/duplicate-descriptionandlinks/orphan-pagesleave the page out. Scores can rise for sites with noindex pages such as thank-you pages, internal search or account pages, because those findings no longer count. Accessibility, security, performance and broken-link rules still check every page. When the homepage itself is noindex, as on a staging site or a Vercel, Netlify or Cloudflare Pages preview deployment, nothing is skipped, so a pre-launch audit still checks titles, descriptions and headings everywhere. The rules reference has the details. Thanks to @deniskern for the report (#457).
Fixed
schema/json-ld-validaccepts anImageObject(or an{"@id"}reference) forimageandlogo, and resolves@idreferences across the page's JSON-LD, so@unhead/schema-org(Nuxt) pages validate clean. Validation errors are no longer repeated under "Invalid JSON-LD syntax", and a page reused from the crawl cache is validated again instead of replaying the previous release's results. Thanks to @deniskern for the report (#463).images/responsive-sizereports a small image only when its measured file is heavier than its displayed size needs, instead of flagging every thumbnail withoutsrcset. Thanks to @deniskern for the report (#464).a11y/color-contrastreports only contrast it measured: an inline text and background color pair under 4.5:1. Guesses from class names and stylesheets, such astext-gray-300, are no longer findings. Thanks to @deniskern for the report (#465).a11y/aria-dialog-nameidentifies an unnamed dialog with no id by its classes and start tag (dialog.newsletter-popup) instead of a baredialog. Thanks to @deniskern for the report (#462).- Entity map findings link to the entity map guide at its new home, https://docs.squirrelscan.com/guides/entity-map/fixing.
Security
- Dependencies are updated for published security advisories, including
smol-toml, which readssquirrel.toml.