Skald

Story

We built Skald to count visitors. It caught an AI-key thief.

A visitor from Hong Kong was reading our home page like a person. It was a scanner in a browser costume, hunting for our AI keys. Here is the log, line by line, and what it got: nothing secret.

An illustration: a friendly live visitor on our site, "Sheung Wan, Hong Kong, Chrome, now on /", glitches and its mask slips: it is a hooded thief. He reaches for a ring of keys, Bedrock, OpenAI, Anthropic, LiteLLM, .env, Terraform, while a counter runs to 150 requests in 14 seconds. A spotlight, "Seen in your server log", catches him with his hands up; every key is stamped 404, and a badge says "Nothing leaked". Caught by the log Skald reads.

The story in 50 seconds, with captions on screen.

Why we built Skald

We built Skald for one job: to know who really visits our websites. People, AI assistants, bots.

Skald reads the server's own log, because Google Analytics misses most of them. It only sees visitors who run its script.

The visitor

On 21 October 2026 I was testing Skald's new live view, “What visitors are doing now”, on spaceo.ai. A visitor from Sheung Wan, Hong Kong, was reading our home page.

Chrome browser. It arrived “from” wp.spaceo.ai, our own WordPress site. It even loaded the page's own scripts, like a real browser does.

I thought: a real person.

What visitors are doing nowLive
Sheung Wan, Hong Kong from wp.spaceo.ai now on /
Recreated from Skald's live view at 16:10 New York, 21 Oct 2026.

The twist

Then we opened the server log.

In 14 seconds, from 20:00:46 to 20:01:00 UTC, that “visitor” made 150 requests. It was hunting for our AI keys: Amazon Bedrock configs (27 paths), LiteLLM (7), OpenAI proxies (5), Anthropic and Claude settings (3), ai/credentials and llm/credentials.

Then .env password files (17), Terraform files (9) and AWS deployment files such as appspec.yml and cdk.out.

Not a visitor. A vulnerability scanner in a browser costume, built for the AI era.

Every line said Chrome on Windows. Every line claimed to come from our own site: wp.spaceo.ai on 124 of them, www.spaceo.ai on the 26 that followed a redirect. Here are twelve, as the server wrote them.

12 of its 150 requests, as our server wrote themOne line per request. Hover, tap or tab to a line to magnify it, with what it means in plain words.
  1. address who asked (replaced)
  2. [time] when, UTC
  3. "GET /path" what it asked for
  4. 404 what our server answered
  5. "https://wp.spaceo.ai/…" the page it claimed to come from
  6. "… Chrome …" the browser it claimed to be
  • AI keys
  • Cloud and Terraform files
  • .env files
  • The page and its scripts
  • Other credentials
access.log · spaceo.ai · 21 Oct 2026 · 12 of 150 lines
203.0.113.7 - - [21/Oct/2026:20:00:46 +0000] "GET /api/bedrock HTTP/2.0" 308 13 "https://wp.spaceo.ai/api/bedrock" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"1Claims it came from our own WordPress site, wp.spaceo.ai, from a page of the same name. Part of the costume.203.0.113.7 - - [21/Oct/2026:20:00:47 +0000] "GET / HTTP/2.0" 200 79878 "https://wp.spaceo.ai/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"2Loads our home page (200), like a browser would.203.0.113.7 - - [21/Oct/2026:20:00:49 +0000] "GET /bedrock/config.json HTTP/2.0" 404 22201 "https://wp.spaceo.ai/bedrock/config.json" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:00:49 +0000] "GET /litellm/config.yaml HTTP/2.0" 404 22201 "https://wp.spaceo.ai/litellm/config.yaml" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:00:49 +0000] "GET /anthropic/config.json HTTP/2.0" 404 22202 "https://wp.spaceo.ai/anthropic/config.json" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:00:49 +0000] "GET /claude/config.json HTTP/2.0" 404 22200 "https://wp.spaceo.ai/claude/config.json" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:00:50 +0000] "GET /.env HTTP/2.0" 404 12351 "https://wp.spaceo.ai/.env" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:00:52 +0000] "GET /_next/static/chunks/fd9d1056-210eea99f6122c96.js HTTP/2.0" 200 53773 "https://wp.spaceo.ai/_next/static/chunks/fd9d1056-210eea99f6122c96.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"3Even fetches the page's own script, as a real browser does.203.0.113.7 - - [21/Oct/2026:20:00:57 +0000] "GET /.aws/credentials.json HTTP/2.0" 404 22201 "https://wp.spaceo.ai/.aws/credentials.json" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:00:59 +0000] "GET /.env.production.local HTTP/2.0" 404 12374 "https://wp.spaceo.ai/.env.production.local" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:00:59 +0000] "GET /terraform.tfvars.json HTTP/2.0" 499 0 "https://wp.spaceo.ai/terraform.tfvars.json" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"203.0.113.7 - - [21/Oct/2026:20:01:00 +0000] "GET /appspec.yml HTTP/2.0" 499 0 "https://wp.spaceo.ai/appspec.yml" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) … Chrome/141.0.0.0 …"

Magnified: line 1 of 12

20:00:46 GET /api/bedrock 308

Asked for an Amazon Bedrock path, where AWS's AI keys live; got a redirect (308).

Claimed to come from https://wp.spaceo.ai/api/bedrock

Source the access log of spaceo.ai, 21 Oct 2026, 20:00:46 to 20:01:00 UTC. The scanner's real address is replaced with 203.0.113.7, an address kept for documentation; the browser name is cut where you see “…”. Nothing else is changed.

Every request the “visitor” made, second by second21 Oct 2026, 20:00:46 to 20:01:00 UTC: 150 requests in 14 seconds. Each bar is one second, coloured by what its requests hunted. Hover or tap a bar to see them.
  • AI keys 83
  • Cloud and Terraform files 32
  • .env files 17
  • The page and its scripts 16
  • Other credentials 2
  1. :46
  2. :47
  3. :48
  4. :49
  5. :50
  6. :51
  7. :52
  8. :53
  9. :54
  10. :55
  11. :56
  12. :57
  13. :58
  14. :59
  15. :00
  1. 1 Starts on /api/bedrock, then loads our home page (200), like a browser
  2. 2 Peak: 52 requests in one second, for Bedrock, LiteLLM, OpenAI, Anthropic and AI credentials
  3. 3 No more AI paths. 57 requests: .env files; cloud and Terraform files; the page's scripts
  4. 4 Done: 150 requests in 14 seconds

20:00:49 UTC: 52 requests, as the log has them

  1. 404/bedrock/config.json
  2. 404/litellm/config.yaml
  3. 404/openai-proxy/config.yaml
  4. 404/openai-proxy/config.yml
  5. 404/llm/config.yml
  6. 404/proxy/config.yml
  7. 404/llm/config.yaml
  8. 404/bedrock/config.yml
  9. 404/proxy/config.yaml
  10. 404/ai/credentials.json
  11. 404/bedrock/config.yaml
  12. 404/bedrock/config.toml
  13. 404/openai-proxy/config.json
  14. 404/ai/config.yaml
  15. 404/anthropic/config.json
  16. 404/ai/config.json
  17. 404/api/ai/config/
  18. 404/ai/config.yml
  19. 404/api/bedrock/
  20. 404/config/bedrock.yaml
  21. 404/config/ai.yaml
  22. 404/config/litellm.yaml
  23. 404/claude/config.json
  24. 404/ai/credentials/
  25. 404/api/v1/bedrock/
  26. 404/model/invoke/
  27. 404/gpt/config.json
  28. 404/config/llm.yaml
  29. 404/litellm/config.yml
  30. 404/bedrock/config.js
  31. 404/api/bedrock-runtime/
  32. 404/config/ai.json
  33. 404/config/bedrock.toml
  34. 404/aws/bedrock/
  35. 404/api/v1/aws/bedrock/
  36. 404/config/bedrock.json
  37. 404/api/aws/bedrock/
  38. 404/llm/credentials/
  39. 404/api/v1/bedrock-runtime/
  40. 308/api/config/ai
  41. 308/invoke
  42. 404/litellm_config.yaml
  43. 404/litellm.yml
  44. 404/litellm.yaml
  45. 499/openai-proxy.yaml
  46. 499/litellm_config.yml
  47. 308/api/invoke
  48. 404/.env.local
  49. 308/v1/model/invoke
  50. 308/api/model/invoke
  51. 308/inference-profiles
  52. 308/api/v1/invoke
The numbers, second by second
Second (UTC)AI keysCloud and Terraform files.env filesThe page and its scriptsOther credentialsAll
20:00:46200002
20:00:4710001011
20:00:48200002
20:00:4951010052
20:00:5015010016
20:00:51000011
20:00:52301419
20:00:53042107
20:00:54011204
20:00:55031408
20:00:56010001
20:00:570814013
20:00:58040004
20:00:590950014
20:01:00024006

Source all 150 lines of the log, counted when this page is built. Each request has one colour: the page and its scripts first, then .env, cloud and Terraform files, then AI keys.

What it wanted: the keys

Most of its requests, 83 of 150, went for the keys to AI services: Amazon Bedrock, LiteLLM, OpenAI, Anthropic. The rest went for .env files, cloud files and the page itself.

What it wanted: the keysOne card per family of files it asked for: how many paths, one real example from the log, and what our server answered.
  • Amazon BedrockAWS's AI models27 paths/bedrock/config.jsonAnswered: 404 ×15 · 308 ×9 · 499 ×3404 · nothing leaked
  • AWS deployment files and keysappspec.yml, cdk.out, CloudFormation, ~/.aws23 paths/serverless.yamlAnswered: 404 ×16 · 499 ×6 · 308 ×1404 · nothing leaked
  • .env fileswhere apps keep passwords and keys17 paths/.env.localAnswered: 404 ×15 · 499 ×2404 · nothing leaked
  • Terraformcloud state and variables9 paths/terraform.tfstate.backupAnswered: 404 ×5 · 499 ×4404 · nothing leaked
  • LiteLLMa gateway that holds AI keys7 paths/litellm/config.yamlAnswered: 404 ×6 · 499 ×1404 · nothing leaked
  • OpenAI proxiesconfigs that hold an OpenAI key5 paths/openai-proxy/config.yamlAnswered: 404 ×3 · 499 ×2404 · nothing leaked
  • AI and LLM credentials/ai/credentials, /llm/credentials5 paths/ai/credentials.jsonAnswered: 404 ×3 · 308 ×2404 · nothing leaked
  • Anthropic and Claudesettings with a Claude key3 paths/anthropic/config.jsonAnswered: 404 ×3404 · nothing leaked

Source every path in the log, matched to the family it names. A path can name two: /.env.bedrock is Bedrock and .env, /.env.claude Claude and .env. The rest of the 150: 16 for the page itself, its scripts and their source maps, and 2 for a bare /credentials. Product names and logos belong to their owners; shown to name what the scanner looked for. Brand marks from Simple Icons (CC0).

The relief, with proof

Every secret-file request got “not found” (96), a redirect (26) or a dropped connection (19).

The only things it received (9 answers) were our public home page and its public scripts. Nothing leaked, and the log proves it.

What our server answered, all 150 requestsOne bar, split by answer. Only the 9 green ones got anything, and all of it public.
  • 96× not found (404)The file is not there.
  • 26× redirected (308)The scanner followed each one and got “not found” or nothing.
  • 19× dropped (499)The scanner hung up before an answer.
  • 9× answered (200)Our public home page (1) and its own public scripts (8). Nothing secret.

Source the answer code on each line of the log: 404 not found, 308 a redirect, 499 the client hung up, 200 sent.

The scale nobody sees

This was one scanner. On one of our sites, Skald's audited figures show 1,085 to 4,148 scanner probes a day (17 to 20 Oct 2026), from 100 to 150 addresses. The php2shell guide has them day by day.

Google Analytics shows none of it: scanners never run its script.

Scanner probes a day, on one of our sitesThe low and the high day of 17 to 20 Oct 2026, and how many addresses sent them.
  • 1,085–4,148scanner probes a day
  • 100–150addresses a day
  • Noneof it in Google Analytics

Source Skald's audited daily counts, 17 to 20 Oct 2026, one of our sites; every day counted twice and matched. Day by day in the php2shell guide.

Two birds, one stone

The log Skald reads to count real visitors is the same log that records every scanner. The tool we built for marketing turned out to be a security tool too.

One install, one log, two answers: who is really visiting, and who is trying to break in.

One log, two answersOne stone, the log Skald reads, gets two different attackers in one throw.

An illustration: a developer at a laptop throws one stone, the Skald mark. In one arc it pops two different birds: a "Fake visitor", a bot in glasses and a false nose, and a "Scanner", a red bird with a crowbar. They turn into two badges, "Real visitors counted" and "Attackers caught", under the words "One log. Two answers."

Fake visitors inflate your visitor counts; scanners hunt your secret files. Both are lines in the same server log.

One line of the log203.0.113.7 … "GET /anthropic/config.json HTTP/2.0" 404 … "https://wp.spaceo.ai/anthropic/config.json" "… Chrome/141.0.0.0 …"

Who is really visiting

  • People, AI assistants, search engines and bots, each counted apart
  • Where each visit came from, AI assistants included
  • Which pages they read, and the leads they became

Who is trying to break in

  • Scanners asking for secret files, like this one
  • What your server answered, and whether anything was exposed
  • Attack attempts, fake browsers and a block list

What you get

Skald's Security section (2.21 and later) turns the same log into seven reports. Each opens in the live demo, with sample data.

What Skald's Security section shows you

Is anything exposed?

Every probe that got an answer, set against the server's own harmless page. A different size is a possible exposure to open and check now.

Is anything exposed?, with sample data: 2,145 scanner probes, 1,073 answered with a page, 1,072 of them the host's harmless page and 1 possible exposure, /.env.production answered 200 with 1,873 bytes, with what to do.
  1. The verdict first: 1 possible exposure, check it now.
  2. 2,145 probes; 1,072 of the answers were the harmless page.
  3. The one that differs: /.env.production answered 200, 1,873 bytes, with what to do.
Is anything exposed? in Skald, with sample data. Source: Skald's live demo.
Open “Is anything exposed?” in the live demo

Who is scanning us?

Each scanner: where it came from, what it looked for, and what your server answered.

Who is scanning us?, with sample data: probes per day as a bar chart and what they looked for, such as secret files, cloud configs, admin panels and WordPress.
  1. Probes per day, for the last 30 days.
  2. What they looked for: secret files (.env, keys) lead.
  3. One row per scanner: where from, what it asked for, what it got.
Who is scanning us? in Skald, with sample data. Source: Skald's live demo.
Open “Who is scanning us?” in the live demo

Attack attempts

Login hammering, request bursts and injection in the address, with the log signs worth a look.

Attack attempts, with sample data: 22 injection attempts, 99 login and form posts, 360 burst requests, log signs worth a look, and path tricks by kind.
  1. Injection attempts, login and form posts, burst requests.
  2. Log signs worth a look, each with its usual level.
  3. Path tricks by kind: Log4Shell, path traversal, SQL injection.
Attack attempts in Skald, with sample data. Source: Skald's live demo.
Open “Attack attempts” in the live demo

Fake browsers

Bots that claim to be a browser, like this one, and what gave them away.

Fake browsers, with sample data: 172 fake-browser requests, what gave them away, the pages they took, and one row per address.
  1. 172 requests from bots dressed as browsers.
  2. What gave them away.
  3. The pages they took.
Fake browsers in Skald, with sample data. Source: Skald's live demo.
Open “Fake browsers” in the live demo

Your stack and advisories

What runs your site, read from outside once a day, and whether this week's flaws apply, with what to do.

Your stack, with sample data: the PHP advisory with Update needed for PHP 8.3.10 and no official fix for PHP 8.1.2, the Node.js advisory with Check your version, and what serves each host.
  1. PHP 8.3.10: update needed, with the fixed release.
  2. Node.js hides its version: check it on the server.
  3. What serves each site, read from outside.
Your stack and advisories in Skald, with sample data. Source: Skald's live demo.
Open “Your stack and advisories” in the live demo

Block list

The addresses and networks to block, ready for nginx, Cloudflare or fail2ban, with a note to review before blocking.

Block list, with sample data: nginx deny lines, a Cloudflare IP list and a plain list; networks with two or more scanners, and each address with its probes.
  1. Review before blocking: why, in plain words.
  2. Ready to paste: nginx, Cloudflare or a plain list.
  3. Networks with two or more scanners, then each address.
Block list in Skald, with sample data. Source: Skald's live demo.
Open “Block list” in the live demo

Alerts to your team

A possible exposure, a new scan wave or an advisory for your stack, sent to your team by email or Slack.

Alerts, with sample data: the rules Possible exposure, New scan wave and Advisory matches your stack, each with when it opens.
  1. Three rules, each with when it opens.
  2. Off until you turn them on.
  3. Every alert of the last 30 days.
Alerts to your team in Skald, with sample data. Source: Skald's live demo.
Open “Alerts to your team” in the live demo

Skald tells you what applies and what was tried; it does not block attacks. Free to run on your own server, source-available (Elastic License 2.0).

How we keep it honest

  • Every number is counted twice, by two separately written counts, and re-checked every morning. A day that doesn't match is withheld.
  • Our own audit caught two false “possible exposure” rules in the first version, before any alert went out.
  • 8 releases in 30 hours (2.20.0 to 2.22.3, 21–22 Oct 2026), each with public release notes.
  • 2,600+ automated tests on four Python versions, and protected releases.
  • The advisory list is updated with each release: php2shell was in Skald the night it was announced.

The same night

The same night, PHP announced fixes for two critical flaws, CVE-2026-109618 and CVE-2026-109619, in 8.5.12, 8.4.27, 8.3.36 and 8.2.35 (php.net). Cloudflare, which calls them php2shell, reports attacks already (Cloudflare's blog).

We added it to Skald, built a free check and an open-source command-line tool, php2shell-check (MIT).

Built for this week's threats

Skald's “Your stack” check reads what serves each site, once a day from outside, and matches it against the advisories shipped with each release. Today that is two.

Critical

PHP: php2shell

CVE-2026-109618 and CVE-2026-109619, with attacks already seen.

Fixed in

  • 8.5.12
  • 8.4.27
  • 8.3.36
  • 8.2.35

php.net · Cloudflare

High

Node.js: October 2026 releases

The highest of the fixes is CVE-2026-111497. A Next.js site runs on Node.js.

Fixed in

  • 22.23.4
  • 24.22.1
  • 26.12.1

nodejs.org

Each finding says one of three things, with what to do:

  • Update neededThe server shows a version without the fix. Update to the fixed release of its branch, then restart it.
  • Check your versionThe server hides its version. Check it on the server, for example with php -v.
  • No official fixPHP 8.1 and older get no official release with the fix. Move to a supported branch.
Your stack, with sample data: the PHP advisory with Update needed for PHP 8.3.10 and no official fix for PHP 8.1.2, the Node.js advisory with Check your version, and what serves each host.
  1. PHP 8.3.10: update needed.
  2. Node.js: check your version.
  3. What serves each host.
Your stack, with sample data. See what your own site shows the world with the free check, or from your terminal with php2shell-check.

Skald tells you what applies to your stack and how to fix it. It does not detect or block an exploit of these flaws.

Watch: the story of Skald (50 s)

    Your turn

    Your server log already knows who's knocking. Are you reading it?

    Free check

    What your site shows the world, in 10 seconds.

    Check my site free https://skaldanalytics.com/check/

    Live demo

    Skald's Security section, with sample data.

    Open the live demo https://spcaeo.github.io/skald/#threats/exposed

    Install

    Free on your own server. Docker, one command.

    Install Skald https://skaldanalytics.com/install/

    Skald on GitHub (source-available) · php2shell-check, the free command-line check (MIT)