TL;DR
- A Guest User Anomaly event says the guest user on one of your sites did far more than usual. It does not say who, or whether anything was read.
- Answering that by hand means joining several event logs, the audit trail and login history across days, and it takes most of a day the first time.
sf audit incident collectfinds the anomaly waves and saves only the guest user's rows into a sealed evidence bundle.sf audit incident reportanalyses that bundle offline.- Each wave gets two answers: a classification (internal testing, automated scan, organic spike, or indeterminate) and a result (access gained, content returned, no evidence, or not assessed).
- The tool would rather say "not assessed" than give you a "no evidence" it cannot back. That rule shaped most of the design.
What You'll Learn
- What a Guest User Anomaly event does and does not tell you
- How to separate a scanner, a corporate proxy, and your own tester in the guest traffic
- How reply sizes show whether a guest call returned data, and where that inference breaks
- How to run the two commands and read the report your security team will receive
The Problem
Salesforce raises a Guest User Anomaly when the guest user on an Experience Cloud site makes far more requests than usual. The event gives you the guest user, a time and a score, and very little else.
Your security team will want more than that, and quickly. Was it one actor or a busy afternoon? Was it a vulnerability scanner, a penetration tester you forgot about, or someone with a script and a reason? Did the site return any records? Did anyone use the traffic to get a login? And if it was bad, did someone change the guest profile afterwards, and did they fix every site or only one?
Each of those lives in a different place. Controller calls are in the AuraRequest event log. Reply sizes are in the Sites log. Logins are in LoginHistory. Profile and sharing changes are in SetupAuditTrail. None of them join in SOQL, the logs are CSV files you download one day at a time, and a busy community produces hundreds of thousands of rows a day, almost all of them ordinary visitors.
The first time I worked one of these by hand, it took most of a day. Download the logs, cut a few hundred thousand rows down to the guest user, join two of them on a request id, then cross-check logins and audit-trail changes for the same days. By the end I had a page of rules about what counted as evidence and what did not. That page became these two commands.
The previous post on triaging guest traffic with sfelf-triage answered "is this IP a scanner?" for a single day of logs. A Guest User Anomaly needs more than that: a baseline to compare against, several days on each side, and the org's own records of who logged in and what changed.
Common Questions This Article Answers:
- What should I do when Salesforce raises a Guest User Anomaly event?
- How do I tell whether a guest user actually read data during the anomaly?
- How do I tell a corporate proxy apart from an attacker in the event logs?
Quick Answer
Collect once against the org, then report as often as you like with no org connection:
# 1. Find the anomaly waves and save the guest user's evidence (read-only)
sf audit incident collect --target-org prodOrg
# 2. Analyse the bundle offline and write HTML, Markdown, JSON and evidence CSVs
sf audit incident report --bundle ~/.sf/incidents/<orgId>/<timestamp> --output ./incident-report
The report opens with one line per wave: what it most likely was, and what happened. Every number in it cites an evidence CSV, so your security team can check the work rather than trust it.
If you already know the window, skip discovery with --window 2026-08-11/2026-08-13, or keep only the wave containing one event with --event <EventIdentifier>.
Step 1: Collect only what the investigation needs
collect is read-only. It reads Guest User Anomaly events for up to 365 days and groups them into waves: runs of anomaly days on the same guest user. For each wave it downloads the event logs for the wave days plus one baseline day on either side.
A heavy day's raw log can run to hundreds of megabytes, so collect streams each file and keeps only rows belonging to the wave's guest user. It stages each raw file on disk while filtering, so allow around 600 MB free. Everyone else's traffic is dropped before it reaches the bundle.
It also snapshots the things you will want later, with no second trip to the org:
SetupAuditTrailacross the waves, queried in four-day windows so no single query hits the 10,000-row capLoginHistoryfor the busiest addresses in the guest traffic- The users those logins belong to, including who created each one
- The guest profile's configuration at the time of collection
The bundle is sealed with SHA-256 hashes and marked complete only when every step finishes. If collection stops halfway, the error names the partial bundle. Rerun with --output pointing at it and only the missing logs are downloaded again; anything whose hash no longer matches is fetched fresh.
Be careful with the bundle. It holds real visitors' IP addresses and the names of users who logged in. It sits unencrypted in ~/.sf/incidents. Treat it like the evidence it is, and delete it when the investigation closes.
Step 2: Who was it? Scanner, proxy, or tester
The report groups guest traffic by network block (a /24 for IPv4, a /64 for IPv6), because an actor rotating through addresses in one range is still one actor.
A block counts as an actor when its wave-day controller calls clear a bar set by the site's own quiet days: at least 100 calls, and more than three times the busiest ordinary block on the quietest baseline day. A block that is busy every hour of every baseline day, such as an office or a partner's integration, must also triple its own normal volume. Without that rule, a big customer's office would get flagged every Monday morning.
Proxies need a second rule. When more than eight different users log in successfully from the same address, that address is shared egress: a corporate proxy, a mobile carrier, a VPN. Logins from it cannot be pinned on whoever made the anomalous calls, so the report counts them separately and does not attribute them. Users created by the guest user (self-registrations) do not count towards the eight, so an actor cannot hide behind accounts it made itself.
With actors identified, each wave gets a classification:
| Classification | What drives it |
|---|---|
| Consistent with internal testing | A successful login from the actor's address by a user the site itself registered. The report names that account, so you can ask the person. |
| Unattributed automated scan | A steady actor carrying scanner markers, or mostly empty user agents. |
| Organic spike | A real spike with no isolated actor, nothing returned, and nothing left unassessed. |
| Indeterminate | Anything else. |
The "organic" label is hard to get on purpose. A spike that returned content to unattributed traffic has not been shown to be organic, so it stays indeterminate.
Step 3: What came back? Reading reply sizes
EventLogFile never records a response body. As the triage post explained, reply size is the best proxy you get. Here it takes a join: AuraRequest has the controller and action, Sites has the RESPONSE_SIZE, and REQUEST_ID connects them.
This join caught me out on the first real org. On some sites one request id is shared across hundreds of calls, and taking the largest size for each id gives every call the same large reply. The tool joins only unambiguous ids (one AuraRequest row, at most two Sites rows) and counts the rest as unjoined. Blank ids are never a join key.
Then it needs to know what an empty reply looks like for each action. An action that returns an empty list has a small, recurring reply size. The tool learns that size from replies not under test: the guest's login and auth calls on wave days, plus data calls on baseline days from blocks that were not active during the wave. The actor's own replies cannot set the baseline, because a scanner receiving the same non-empty reply ten thousand times would otherwise teach the tool that the data is "empty".
A data-access reply larger than the empty size plus a 64-byte band is content returned. The report lists every one, with time, address and action.
Step 4: Did anyone get in, and what changed?
A successful login outranks any reply size. The report checks LoginHistory for every actor address, and one successful login from any of them makes the result "access gained".
Self-registrations during the actor's window are listed for review but never counted as access, because the audit trail does not record an IP for them.
Then the audit trail between waves. If the guest profile, sharing rules or site settings changed between one wave and the next, the report shows the change. If you run several sites and only one was tightened, it flags that asymmetry. It is an easy mistake when a fix goes in under pressure, and it is hard to spot without putting the sites side by side.
The result, and why "not assessed" exists
Each wave's result follows a strict order:
- Access gained: a successful login attributed to the actor.
- Content returned, contents unknown: a data-access reply above the empty size.
- Not assessed: the evidence cannot support a clean answer.
- No evidence of access: everything was checked and nothing turned up.
A report like this ends up in a privacy-breach assessment. A false alarm costs you an afternoon. False reassurance gets written down and relied on. So "no evidence" is blocked whenever any of these hold, and the report lists which:
- A wave day's
AuraRequestorSiteslog was not collected (for example, it had aged out of retention) - Some data-access calls could not be joined to a reply size
- Some controller calls could not be parsed
- The empty-reply size could only be inferred from the replies under test
- Content went to other guest traffic and cannot be ruled out as the actor's
- Successful logins came from shared egress and cannot be attributed
- No baseline day was collected
Most of these rules came from trying to break the tool. Each round asked one question: what input would make the tool say "no evidence" when it should not? Every answer became a test, then a rule. Synthetic fixtures were not enough on their own. Running against a real production org surfaced patterns the fixtures never had, like a request id reused across hundreds of calls, and the rules above exist because of them.
The report text also avoids "attack" and "breach". Those are conclusions for the people who own the incident, not for a tool reading logs.
Sharing the report
The HTML report prints cleanly to PDF. The default audience is the org owner's security team, which needs the real addresses and names. Before sharing more widely, add --redact: linked users become ids and IP addresses are truncated to their /24.
sf audit incident report --bundle ./bundle --redact --prepared-for "Mostly Functional Furniture" --output ./for-the-board
Hosting classification ("this address belongs to a cloud provider") uses only range files you download yourself and pass with --ip-ranges (AWS ip-ranges.json, GCP cloud.json, Azure service tags, or a plain CIDR list). The plugin never fetches them. Nothing leaves your machine except the read-only calls to your own org.
Frequently Asked Questions
Q: Does this need Shield or Event Monitoring?
A: It needs the Guest User Anomaly events, plus the AuraRequest and Sites event logs for the wave days, still inside retention. Check that your org's Event Monitoring entitlement includes both log types. A wave day whose log is missing is recorded as not collected, and that wave's result comes back "not assessed" rather than clean. Retention is the usual gap: the EventLogFile baseline post covers capturing logs daily so the evidence still exists when an anomaly arrives.
Q: Can I rerun the analysis without touching the org again?
A: Yes. report never connects to the org. Rerun it with a different --spike-ratio, new --ip-ranges files, or --redact as often as you like against the same bundle.
Q: How is this different from sfelf-triage?
A: sfelf-triage matches one day of logs against attack signatures and gives a per-IP verdict. sf audit incident starts from the anomaly event, compares against baseline days, and pulls logins, users and audit-trail changes, so it can answer "did anyone get in" and "what did we change afterwards". Use the triage tool for a quick look at a flagged IP, and the incident commands when the answer goes into a report.
Q: The result says "not assessed". Did something go wrong?
A: No. The tool found a gap it cannot see past and told you which one. The listed reasons are your to-do list: fetch the missing day's logs, check the unjoined calls, or find out who uses the shared address.
Q: Is a "content returned" result a confirmed breach?
A: No. It means a guest data call got a reply larger than an empty one. The size proves something came back, not what it was. Check what the guest profile could read at the time, and treat the listed calls as the starting point.
Key Takeaways
- The anomaly event is a starting point. Who it was, what came back and whether anyone logged in live in four other sources, joined across days.
- Baselines come from the site's own quiet days. That is what separates a scanner from your largest customer's office.
- Reply size is a proxy that needs care. Join only unambiguous requests, and learn "empty" from traffic not under test.
- "Not assessed" is an honest answer. Say what the logs cannot show before anyone relies on a "no evidence" they cannot defend.
What's Next?
Recommended Reading:
- A Guest IP Got Flagged for Log4j: Scanner or Breach?
- Why Filtering Salesforce Event Logs by IP Misses Most of the Incident
- Free Salesforce Event Monitoring: Build a Security Baseline from EventLogFile Without Shield
Action Items:
- Install or update the plugin:
sf plugins install @cclabsnz/sf-audit(the incident commands arrived in 1.14.0). - Check whether your org has raised any Guest User Anomaly events:
sf audit incident collect --target-org <alias>reports the waves it finds. - If your org has no Event Monitoring, start capturing EventLogFile daily now, before you need it.
Run it on a real anomaly and found a pattern it misreads? Open an issue on GitHub. Every rule in the verdict came from a case like that.
Responses
Checking your session.
Loading responses.