Methodology
Version 1.14, reviewed 2026-10-20. A clean result only means the completed checks did not find the specific signals they test for. Fix instructions have a revision of their own, 2026-10-20, carried beside the rules version on every result, so a report keeps the instructions it was scanned with (2026-10-20: a DMARC record that is published but does not enforce, or leaves subdomains or part of the failing mail out, gets steps that change its tags instead of "add the record"; new fixes for a script host dressed as a well-known service and for a password field in a form that submits by GET).
This page states exactly what xlogs does, what it refuses to do, and what it cannot see. If a security tool will not tell you where its own limits are, you have no way to judge what its results are worth. A scan reads your homepage and up to six more of your own pages, found through your links and your sitemap, and runs every check on each; it never follows a link off your site.
- Exposed keys
- Open databases
- Public .env files
- Private files
- Source maps
- Misconfigurations
- Risky third-party scripts
- Credentials in the browser
Reads your public JavaScript bundles and looks for known secret formats.
Every scan runs 11 checks across these areas and can report 14 kinds of finding. Read-only, and nothing is exploited to confirm it.
The short version
- Every request is a GET or a HEAD, enforced in the code that sends it. There are no form submissions, no logins and no exploitation attempts, and the page crawl skips links that look like actions (log out, delete, unsubscribe).
- No account, no credentials, and no repository access. You paste a URL, that is all we take.
- No score. Every finding carries its own evidence instead of rolling up into one number.
- No AI at scan time. The same responses produce the same findings under the same rule version; a live app can answer differently between scans, so a result describes the moment it ran.
- We would rather miss a low-confidence issue than raise a false alarm.
Every request we send
This is the complete list. Every request to your site is an ordinary GET, identifying itself as xlogs/0.1 (+read-only security probe), with a 12 to 15 second timeout. We never send your site a POST, PUT, PATCH or DELETE. The one POST in the scanner goes to the public OSV advisory database, never to you, and only when your site has already served its lockfile.
| Method | What we request | Why |
|---|---|---|
GET | your URL | The page itself. We read the response headers and the HTML. |
GET | /sitemap.xml and up to six of your own pages | Pages found in your sitemap or linked from your homepage, same origin only. Each page's headers are compared with the homepage's, so a route that drops one is named. |
GET | each same-origin <script src> | Your JavaScript bundles, so we can look for secrets that shipped to the browser. Capped at 25 scripts, 10 MB each. |
GET | a bundle's .map file, if referenced | Only to confirm whether a real source map is public. We check the body actually looks like a source map before flagging it. |
GET | /package-lock.json | One request, to see whether your npm lockfile is downloadable. Only a response that really is a lockfile counts. |
GET | an API description your code names | If your served JavaScript names an OpenAPI or Swagger file on your own site, we read it once to list what it declares. The endpoints in it are listed, never called. |
GET | six paths where sensitive files are often left | /.env, /.env.local, /.env.production, /.git/config, /.git/HEAD, and /.vscode/sftp.json. We require the response to actually look like that file, so a catch-all page returning 200 for everything does not trigger a finding, and what the file contains sets the grade: a credential in it is critical, configuration alone is graded lower. Probed in parallel, so this costs coverage rather than time. |
GET | your Supabase REST API, if we find one | The table list, then one read per table (select=*, limit 1) as an anonymous user, using the public key your app already ships (the legacy anon key, or a current sb_publishable_ key). Capped at 25 tables, and the result says when it stopped short. Hosted Supabase has refused the table list to public keys since April 2026, so on most apps we cannot learn which tables to test, and we say so: that result is inconclusive, never a pass. We do not guess table names. The CLI, given your policy file, also asks the tables it names, and only those. |
DNS | your CNAME, SPF, DMARC and MX records, and two more where they apply | Public DNS lookups to see whether a record points at something you no longer control, and whether your domain protects itself from email spoofing. Where your DMARC record sends reports to another domain, that domain's authorisation record; where your MX or SPF names Google Workspace or Microsoft 365, the DKIM key at the selector the provider documents. |
POST | the public OSV advisory database (not your site) | Only when your site served a lockfile: the package names and exact versions in it, to ask which have published advisories. Nothing else about your site is sent. |
GET | your domain's public registry record (RDAP) | One keyless lookup at the registry, for when the domain was registered and when it expires. |
GET | a public certificate log, for your domain | Certificates issued for a domain are logged publicly. We read that list to show other addresses on your domain you may have forgotten are public. We do not contact those addresses. |
Our request budget. One scan sends at most 60 requests to the site, counting every one of them: the page, up to 25 bundles, source maps, the private-file checks, the page crawl and the database reads. At most 8 are in flight toward one site at a time, however many people are scanning it. A scan that reaches the budget stops and says what it did not check; it never reports the rest as clear. A scan runs once, when someone asks for it. There is no background crawler.
We also refuse to scan anything that is not a public web address. Private, loopback, link-local and cloud-metadata addresses are rejected before a request is made, and that check runs again on every redirect hop.
What we check
The 14 kinds of finding a live scan can report, from the 11 checks it runs. Each finding names what it observed and what it did not test.
Anyone can read this table; confirm that is deliberate
xlogs finds your public Supabase URL and anon key in your app (the same ones your frontend uses), asks the database for its table list, reads one row from each listed table as an anonymous user, and flags any that return rows. Hosted Supabase refuses that list to public keys, so on a hosted project the result says the tables could not be checked. Read-only: it never writes or deletes.
A credential-shaped value is publicly served
xlogs scans your live JavaScript against 18 key formats covering Stripe (live, test and webhook secrets), OpenAI, Anthropic, AWS, Google, GitHub, Slack, private key blocks, and Supabase server keys (service_role and sb_secret_).
Your original source code is downloadable
xlogs checks whether your live JavaScript bundles expose their .map files.
Private files are being served publicly
xlogs makes a read-only request for a small set of paths where sensitive files are often left (.env, .git/config), and grades each file by what it contains.
Missing safety headers
xlogs reads your live site's response headers and lists the protective ones that are missing.
Your site loads a script from a compromised CDN
xlogs lists every third-party domain your page loads scripts from and compares them against a short list of domains with publicly documented supply-chain attacks, each with a citable source. Unknown domains are never flagged, only listed, so this check cannot false-positive on an ordinary CDN.
Someone could send email that looks like it is from you
xlogs reads your domain's public DNS for an SPF record and a DMARC record. It skips this entirely on platform subdomains, because those records belong to the platform and you cannot change them.
Someone else could take over your subdomain
xlogs reads your public DNS. A CNAME pointing at a hosting provider is completely normal and is not reported. It is only flagged when the target does not resolve at all, or the provider itself answers that nothing is claimed there.
An auth token is kept in browser storage
xlogs reads the JavaScript your app already served and looks for a write to localStorage or sessionStorage under a credential-shaped key. It never runs the code and never reads the stored value. Documented auth-library session stores are excluded, because those are the library default rather than your choice.
Your dependency list is downloadable
xlogs requests /package-lock.json once (never on a stack-only scan) and requires the response to actually be an npm lockfile, not just a 200. When it is, the exact versions it states are looked up in the public OSV advisory database. This check sends nothing else to your site.
A pinned CDN file loads without an integrity hash
xlogs reads the script and stylesheet tags in the HTML your homepage already served. It only counts files whose URL names an exact version on jsDelivr, unpkg or cdnjs, because only those can carry a stable hash. Loaders that change on purpose (a payment SDK, an analytics tag, @latest) are never listed.
A cookie is missing a protective flag
xlogs reads the Set-Cookie headers your homepage response already carries and lists each cookie missing Secure, or missing HttpOnly when its name looks like a session or login cookie. Passive: nothing extra is sent, and a cookie set only after login is not seen.
Your page loads a script from a host dressed as a well-known service
xlogs lists every third-party domain your page loads scripts from and flags a host whose name carries a well-known service's domain followed by a different domain, such as cdn.jsdelivr.net.example-cdn.com. The service's own subdomains and its country domains are never flagged, and an unknown host that imitates nothing is only listed.
A password could end up in your page address
xlogs reads the HTML of every page the scan fetched for a form with no method or method="get" that holds a password field with a name. It never submits a form. A field without a name is not sent by the browser, so it is not counted, and neither is a form whose button posts.
What else we observe, and do not grade
A scan sees more than it reports as findings. All of the below is read from responses the scan already fetched, none of it carries a severity, and none of it moves a verdict. It is here because a tool that shows you observations without saying which ones are judgements is inviting you to read the wrong ones as alarms.
The technologies you are built with
Framework, host, CDN, analytics, payments and the rest, read from response headers, page markup, asset paths and public DNS records. A technology is never a finding. Knowing you run Next.js on Vercel explains how to read a missing-header result; it is not itself a result.
Versions, but only where you publish them
We report a version only when the deployment states one itself, and we never map it to a vulnerability database. A version number in a header is rated a zero-severity disclosure by the standard taxonomy precisely because on its own it is useful mainly for choosing an exploit. Naming your stack is public information; guessing your exposure from a version string is not something we will do.
How the site is configured
Canonical, robots, language, share image, structured data, an app manifest, a registered service worker. Reported present or absent with what we looked for, never scored: whether a missing canonical matters depends on the site, and a grade would manufacture a judgement out of an observation.
Public identifiers in your markup
Analytics, tag manager and ad account ids are account-level, which means they are the same on every site using that account and could be used to correlate this site with others. We show you yours so you know what is visible. We do not search for other sites using them, and we will not build that.
What your deployment ships
The bytes actually served: the document, every same-site script we could download, and which are largest. Where something could not be weighed the total says so rather than quietly leaving it out. There is no performance score here, because whether a size is a problem depends on your app and your visitors and neither is visible from a scan.
Third-party hosts your page loads code from
Counted and named, never downloaded. Fetching someone else's CDN to measure it would turn a read-only scan into traffic against their infrastructure. Only the documented-compromised list produces an actual finding.
Libraries identifiable in your bundles
Names we can still recognise in shipped code. Bundlers strip most of them, so this is an incomplete list by construction and is never presented as a dependency inventory.
Other addresses on your domain, from public certificate logs
Certificate transparency logs are public by design and every certificate ever issued for your domain is in them. Names like staging or admin are worth knowing about because you may not have realised they were public. We read the log; we do not scan what we find there.
What we deliberately do not flag
"Severity must be earned" is easy to claim and worthless unless it is falsifiable. So here is the actual list of conditions our checks see and choose not to report, and why. If you think one of these is wrong, that is a specific, checkable disagreement, which is the point.
A public Supabase anon key in your bundle
It is designed to ship in the browser. On its own it is not a leak. We only flag a Supabase key if it decodes to the service_role key, which bypasses your access rules.
The Supabase CLI demo key and the jwt.io sample token
supabase start prints the demo key on every developer machine and it opens nothing on a hosted project; the sample is the token every tutorial quotes. Neither is reported. Any other token is: a signed-in user's session token at medium (low once it has expired) and a token of unknown purpose at low, with its claims marked unverified. Until 2026-10-16 every token that did not decode to a service_role key was dropped.
A Google key in a Firebase web config or a browser Maps setup
Those keys identify your project to Google and ship to every visitor by design; what protects them is the restriction set on the key, which xlogs does not test. So the scan says it saw one and does not report it. A Google key anywhere else is reported: high beside a Gemini call, because a Gemini key must stay secret; medium when your site ships Gemini's client but the key cannot be tied to the call; and low when nothing says which service it is for.
A page that returns 200 for /.env
Plenty of single-page apps answer 200 for every path. We require the response to actually look like an env file before calling it exposed. Each of the six files we request has its own shape check.
An admin or dashboard page that loads
A 200 at /admin almost always means a login page, which is correct and safe. We cannot tell an exposed admin panel from a login form without interpreting the page, and guessing would manufacture alarms. So we do not probe those paths at all until the check can be made evidence-based.
A .map URL that resolves but is not a source map
Same reason. We parse the body and require real source-map structure.
Short strings that merely look like API keys
Our key patterns are deliberately narrow. A loose pattern once matched minified CSS class names and produced nine false criticals on a major site, so we tightened it rather than keep the extra coverage.
An empty or access-controlled database table
A table that returns no rows to an anonymous request is not reported as a finding. It is empty or it is protected, and from outside we cannot tell which, so we never present it as proof that a policy exists.
A CNAME pointing at your hosting provider
That is what a normal custom domain looks like. We only report a DNS record when its target does not resolve at all, or the provider itself answers that nothing is claimed there. Subdomain-takeover checks are notorious for false alarms, so this one requires real evidence.
Missing email records on a platform subdomain
If your app lives at something like my-app.vercel.app, the SPF and DMARC records belong to the platform, not to you. Reporting them would be an alarm you cannot act on, so we skip the check and say why.
Other addresses found in certificate logs
We list them so you can spot a staging or admin site you forgot was public, but a certificate proves a name was issued, not that a host is live or insecure. We do not contact those addresses, so we do not report them as problems.
A missing DKIM record
DKIM lives at a selector name your mail provider or you choose, such as resend._domainkey. We look only at the selector a provider named by your MX or SPF records documents (Google Workspace and Microsoft 365 do), and show what is there as information. Anywhere else it would be a guess, and a guess that comes back empty would be a false alarm, so a missing DKIM record is never a finding.
What we report, but not as loudly as it looks
These are findings the scanner keeps, and grades by a second piece of evidence rather than by the first thing it saw. Each one used to be a critical or a high on something ordinary. The finding stays visible; what changed is the word beside it and the sentence under it, so that what is printed is true of the thing found. Decided 2026-09-06 and 2026-10-16; each one is pinned by a test, and most are permanent cases in the published false-positive corpus.
A table anyone can read
Reading rows as a stranger cannot tell row level security being off from a deliberate read-for-everyone policy: a countries table, a public profile directory and a customers table look the same from outside, and we keep none of the values. So since rules 1.9 every readable table is a review item at medium, whatever its columns are called, and the finding asks you to confirm it is deliberate. The column names decide only which to review first and why: a column named like a credential (a password or its hash, a secret, an API key, a token) or like private data (email, phone, a street address, date of birth, payment or health details), or an amount beside a person's id, puts a table first, and a name is a suggestion, never proof. A table becomes high only when you state, in the CLI's policy file, that this exact table must be private; the finding then says the read conflicts with your requirement, and nothing about what the rows hold. Until rules 1.9 the names decided critical or high.
A source map with no source text in it
webpack's nosources setting ships line mappings and file names for error tracking and leaves the source out. Nothing can be reconstructed from it, so we no longer say it can. It is reported as a file-layout disclosure at low, naming how many file names leaked. A map that carries source text is medium: it exposes code, not data or credentials.
A public library's own source map
When a map belongs to an open-source library you shipped (its licence banner names the package, and the map belongs to a file of that name, or every source path sits under node_modules), it reconstructs code that is already on npm. It is listed in the shipped-library inventory rather than reported as your source.
A served .env with no credential in it
Variables with a public prefix (VITE_, NEXT_PUBLIC_, REACT_APP_ and the like) are compiled into your bundle by design, so a served .env made only of those is low. One with other variables and no credential in it reads as a served configuration file at medium. It is critical when it shows a credential: a recognised secret key (a key that is public by design does not count), a private key, a URL or connection string carrying a password, or a variable named like a password, a secret or a key that holds a set value rather than an empty one, a placeholder or a reference to another variable. A public build prefix does not excuse a password, because it compiles the value into your bundle. xlogs keeps none of the values it read. Until 2026-10-16 one variable without a public prefix made the file critical.
One file from a .git folder
A served .git/HEAD or .git/config used to be high. One of them shows that one file, so it is low. Both answering is a broader observation, two paths instead of one, so that is medium; it is not proof the history can be downloaded, and xlogs never tries. What raises it further is content: a remote address carrying a login or a token makes it critical.
An upload config with a server in it but no password
A .vscode/sftp.json that names a host, a user and a key path used to be high. It tells someone where to aim, not how to get in, so it is medium. One holding a password is critical.
A provider's unclaimed-site phrase on a page that answers 200
The takeover check used to match the provider's error phrase anywhere in the body, so a live docs page quoting that phrase to explain how to fix a custom domain read as a takeover. Every provider serves its unclaimed page with an error status, so the finding now requires the error status beside the phrase.
In the repository scanner: using your own key, notifying a chat channel, opening port 443, compiling a C extension, an official installer, a quoted warning, a token in a README
Seven more of the same shape, all in the GitHub repository scan. A deploy script that runs ssh -i with its own key is not stealing one; reading the key file's contents is. A Slack webhook that posts 'deploy finished' is not exfiltration; one whose body references the environment or a file is. Port 443 open to the world is a website; port 22 or 5432 is a finding. A setup.py that overrides build_ext compiles; a custom install command executes. The documented installer of a mainstream toolchain (rustup, pnpm, bun) is noted, not blocked. A rules file that quotes an injection phrase in order to warn about it is prose, not injection. A token in a documentation file, or beside a placeholder word, is a note, the way a test fixture already was. In every case the loud counterpart still fires, and the corpus holds both.
Standard identifiers, and what they are not
Every finding carries a CWE identifier and its OWASP Top 10 (2021) category, so you can look it up or match it against another tool's output. You can also download any scan as SARIF 2.1.0, the standard format, and open it in VS Code or upload it to GitHub code scanning. That export is free and includes every finding, like everything else here.
Two honest caveats. One finding, subdomain takeover, has no CWE listed, because none of the existing identifiers fit it well and forcing a wrong one would be worse than leaving it blank. And these labels are a convenience, not a compliance product: an xlogs scan is not evidence for SOC 2, ISO 27001, or PCI DSS, and we will never present it as such.
The live-site checks also run from a command line, so you can block a deploy on them: xlogs --url https://myapp.com --fail-on high exits non-zero if anything at or above that severity is found, and also if a security check could not run: a gate that could not test your database has not passed it (--allow-inconclusive accepts such a gap, and still names it). And --sarif out.sarif writes the same report for CI. The gate is read-only like everything else: it blocks a deploy, it never touches your repository, and it needs no token with write access.
The command line tool is released on its own: the release these pages run is v0.2.4 (2026-10-06), and the copy on npm, xlogs-scanner 0.1.0 (2026-08-13), is older still, so either can report differently from a scan on this site.
What was seen, and what it means
Every finding carries two labels, because "we saw it" and "this is a problem" deserve different weight and we refuse to blur them.
Seen: confirmed means xlogs directly observed it: a database table returned real rows to a logged-out request, a private file was actually served, a source map validated, or a key was in the code your site serves. There is a response behind it, not an inference.
Meaning: potential means the impact depends on something xlogs did not test or cannot know: whether a table is meant to be public, whether a served key still works, whether an attack a missing header would block is possible on your app, whether a dangling DNS name can actually be claimed. xlogs does not carry out the attack to find out, so it does not dress the impact up as confirmed. Each finding says what was observed, what was not tested and what to do, which written rule set its grade, which ordinary setups produce the same observation, and what would change the grade.
Meaning: established appears only when the evidence plus a statement you supplied settles it, such as a private-access requirement in the command line tool's policy file. Even then it says only that the read conflicts with your requirement, not what the rows hold or whether anyone read them. Results graded before rules 1.9 carry one label, Confirmed or Potential, and keep it.
Related: when a leaked key is visibly a live key rather than a test key, xlogs raises its severity, because the same kind of secret is not the same risk. A public test key is real exposure but a far smaller fire than a live one.
Every number here traces back to a response
Nothing on a report is estimated, modelled or averaged. Each finding names the request that produced it and what came back, each count is derived from the findings above it rather than stored separately, and the receipt lists the checks that ran so a zero can be read as measured rather than assumed. If we cannot show you where a number came from, we do not print the number.
What we refuse to invent
A security tool has a standing incentive to make results look more dramatic, because a frightening report feels more valuable than a quiet one. These are the four places we refuse it.
- Absence is reported as absence. When a check could not complete, it says so and the result is not clean. No evidence is not negative evidence, and it is not positive evidence either.
- We never guess a vulnerability from a design pattern. Recognising a framework tells us what to check, never what is wrong. A finding needs an observation behind it.
- We never report a version we did not see. Guessing a library version from a filename, then matching it against advisories, produces a long and confident list of things that may not be true.
- We do not exploit anything to make a finding stronger. Where a scan stops is where the evidence stops, and the confidence label says which side of that line a finding sits on.
What deterministic rules do and do not guarantee
Every check is a deterministic rule over a response we fetched, and no language model decides what counts as a finding. That gives reproducibility: the same responses produce the same findings, and a finding always points at the response behind it. It does not give correctness. A rule can match something it should not, and ours have: the false-positive benchmark lists every false positive by case id. No language model means no plausible-sounding finding about something that was never observed; it does not mean no noise.
Tools that generate findings with a language model carry the opposite property. When the Lychee project counted a month of an AI code reviewer's findings on its own pull requests (290 across 28 pull requests, published September 2025), it called 15% useless and 13% based on a wrong assumption, the rest ranging from nitpicks to quality and security improvements, and said the split was specific to its codebase and practices. That is one project's count, not a rate for every such tool. Noise like that costs more than the minutes spent dismissing it: people stop reading, and a scanner is only useful for as long as its output is worth opening.
Why there is no security score
A single number out of 100 has to be tuned to look meaningful, and the tuning is where honesty goes. Once a score exists, there is pressure to make it move: adding checks that are easy to pass, or weighting cosmetic items so the number spreads nicely across users. Several tools in this category include performance and accessibility checks inside a security score.
We report findings instead. Each one states what we observed, where, and what an outsider could do with it. A secret key served to every visitor is critical whether or not the rest of your configuration is tidy, and no average should be able to soften that.
What we publish instead of a score is how often we are wrong: the false-positive benchmark runs two adversarial corpora through the shipped scanner, lists every false positive by case id with the interval its sample size allows, and is checked against the detectors before every deploy.
What xlogs cannot see
A clean xlogs scan means the checks above found nothing, not that your app is secure. Here is what is outside our reach, and who to use instead.
Your source code
The web scanner only sees your deployed site. It never asks for repository access, and it cannot find issues that are only visible in source, such as SQL injection or unsafe HTML rendering.
Dependency and CVE scanning from a LIVE response
The web scanner never guesses a version from a response header and maps it to a CVE: banner-derived version matches are wrong more often than right. The repo scanner is different, because a lockfile states exact versions: point it at a public repo and pinned npm dependencies are checked against the OSV advisory database. Continuous dependency monitoring is still a job for Snyk, Dependabot or Aikido.
Compliance evidence
An xlogs scan is not a SOC 2, ISO 27001 or PCI DSS artifact and must not be presented as one.
Anything behind a login
We never log in, so we only see what a logged-out visitor sees.
Cloud, container and infrastructure posture
Out of scope entirely. That is what an enterprise vulnerability-management platform is for.
Firebase security rules
They are evaluated inside Firebase. We can check your keys and headers, but not those rules.
If you need dependency scanning, compliance evidence, or coverage across cloud and infrastructure, you want a full application-security platform, not this. xlogs answers one question well: is your deployed app leaking something right now, and how do you close it.
Muting a finding
You can mark a finding as reviewed, with a reason: not a real issue, risk accepted, or handled another way. That note lives in your own browser and is sent nowhere, because there is no account to attach it to.
A muted finding stays in the report, dimmed, with your reason shown, and it does not change the verdict or the counts. Muting records that you looked at something and decided. It is not a way to make a problem disappear, and we will not build one.
Privacy, and what we keep
We read only what your app already serves publicly. For the database check we record the table name, its column names, and an approximate row count as evidence, never the contents of your rows. There is no account, and a scan is not kept unless you choose to keep it (a share link, Watch, a verified badge); the privacy page lists each one with what it keeps and for how long.
We do keep an anonymous tally, so we can publish the defect index. For each completed scan we increment counters for the month: which builder platform the app appears to use; each check's outcome (found something, found nothing, did not apply, could not finish); a coarse reason when a check could not finish; the technologies observed and how sure each detection was; which rule for the security badge the scan reached; and a coarse bucket for how long it took, plus a running total of completed scans. That is the entire list. Scans of our own test targets (a deliberately vulnerable app we scan after every release, and xlogs.com itself) go to a separate internal tally and into nothing we publish; until October 19, 2026 they were counted with everyone else's.
No URL, no hostname, no IP, no finding detail, no data from your app. There is no identifier of any kind in those counters, so nothing in them can be traced back to you. We publish counts only and will never publish a list of scanned or affected sites. The open-source command line tool sends none of this: counting happens only here. Full statement in the privacy policy.
