I run AWS WAF in front of production web services, and I also do vulnerability assessments. Working on both sides has taught me one lesson twice: a WAF log records a request and a decision. It does not record an outcome.
The gap is easy to miss because a log line looks complete: a client address, a URI, headers, a rule and an action. It is tempting to read it as a story, but the end of the story, what the application did with the request, is not in it.
Below I describe what the log holds and leaves out, then three habits that keep my conclusions inside what I can show. They come from my own environments, where web ACLs sit in front of CloudFront and Application Load Balancers. Please read them as one practitioner's experience, not as rules.
What a log line holds, and what it does not
Logging is something you enable. A web ACL can send its logs to CloudWatch Logs, S3 or Firehose, and the AWS documentation describes the content as "the time that AWS WAF received a web request from your AWS resource, detailed information about the request, and details about the rules that the request matched."
The fields I read most often:
httpRequest: client address, headers, URI, query string and method.action: the terminating action (allow, block, CAPTCHA or challenge).terminatingRuleId: the rule that ended evaluation, orDefault_Actionif none did.nonTerminatingMatchingRules: rules that matched without ending evaluation, such as Count rules.
In the field reference I cannot find a field for what the protected application returned. The only response-related field, responseCodeSent, is documented as "The response code sent with a custom response." That is a response configured in a rule, not the application's answer. Sampled requests do not close the gap either: they cover only the previous three hours, and the API returns at most 500 requests, drawn from the first 5,000 in the window.
Take one line: action allow, Default_Action, plus a Count match from a SQL injection rule on a login field. I can say that a rule saw something that looked like SQL injection, that the rule only counted, and that the default action let the request through. I cannot say what the application answered, who sent the request, or whether the rule was right. A block entry is not more complete. It names the first terminating rule, and the documentation notes that the request "might contain other threats, in addition to the one that's reported in the log".
Those three "cannots" are where my three habits start.
Habit 1: Check impact at the origin, with read-only access
When someone asks "did anything get through?", the WAF log tells me what was allowed. It cannot tell me what happened next.
The usual place to see the response is the access log of the layer in front of the application. An Application Load Balancer log has elb_status_code and target_status_code. A CloudFront standard log has sc-status. But both are optional. The ALB documentation says its access logs are "disabled by default", and CloudFront calls standard logging "an optional feature". In some environments I work in, they are off, and turning them on is not my decision. There the WAF log is the only edge record, and it holds requests without responses.
So I design for that:
- I use the origin's own web server and application logs, with read-only access, to answer one narrow question: did this request reach the application, and what did the application answer?
- I match on time window, method and path first. I use the client address only once I know which hop it belongs to (Habit 2).
- I word the result carefully. A normal response in the origin log is not proof that an attack failed, and an error is not proof that it worked. What I can honestly write is "I found no sign of impact in these logs", with the logs named.
If neither the edge nor the origin keeps a response record, I say that I can describe the request and the decision, but not the outcome. When I can choose, I turn edge logging on before I need it.
Habit 2: Verify who a client is before you name it
A rule name describes the shape of a request. It does not say who sent it or why. A SQL injection signature that matches a search field may come from an attacker, from a scanner someone authorized, or from a person typing something unusual. A reputation lookup is a snapshot of what was known about an address at some earlier time.
My order of work:
- Establish the path first. Did the request come through a CDN or a load balancer, or directly? A log records the peer that connected to that layer: the AWS documentation for WAF sampled requests, ALB logs and CloudFront logs each say that, behind a proxy or load balancer, the recorded address is the proxy's or load balancer's. A forwarded-for header is a claim, not a fact, until I know who set it.
- Check ownership. WHOIS tells me who holds the address range. It does not tell me who is using it.
- Check behavior. Which paths, in what order, at what rate, and whether the headers stay consistent from one request to the next.
- Only then name it. I keep the facts in one list and the conclusion in another. The conclusion stays empty until the path is settled.
This is slower than reading a rule name. It is also how I avoid reporting the wrong party.
Habit 3: Measure false positives, and keep one source of truth
I do not guess whether a rule causes false positives. With managed rule groups I could not reason from the rules even if I wanted to, because the documentation says you "can't view all of the details for the individual rules within a rule group". So I measure.
- A new or changed rule starts in Count. AWS gives the same advice: test and tune "the rules in count mode with your production traffic before enabling them".
- For a managed rule group, I override its rules to Count. The documentation calls this the most common use of rule action overrides, "to test and monitor a rule group's behavior before putting it into production". The matches then appear as non-terminating matches, and the action I overrode is kept in
overriddenAction. So I can count what would have been blocked, per rule and per path. - If a rule fits most of the site but not one part, a scope-down statement narrows which requests a managed rule group or rate-based rule evaluates. The price: requests outside the scope count as not matching, so I choose the scope deliberately.
- Rule changes take "from a few seconds to a number of minutes" to propagate, so I do not read the log lines around a switch as clean evidence.
Then I keep one source of truth. For every rule I need to know whether it blocks or only counts. I keep one authoritative record of that, every report quotes it, and when I need to be sure I read the deployed web ACL back with GetWebACL and compare.
Finally, I check that my own rules survived. Providers update managed rule groups, and if you use the default version, AWS WAF "automatically updates the default version setting" when the provider changes its recommended version. After such an update, or after re-applying an infrastructure template, I read the web ACL back and confirm that every rule I rely on is still there with the action I intend. I have also met a third-party rule service where registering a change was not enough: it took effect only after an explicit re-apply. A change that was saved and a change that is active are different facts.
What this changes in a report
I write reports for the people who own the service as detection followed by defense: what was detected, what the WAF did about it, and what remains open. I do not report that "there was a lot of bot traffic". Volume is not an outcome, and the log cannot support the conclusion that phrase invites.
Where I do not have the authority to change the infrastructure, I write "we cannot detect this here" and say why. I do not propose a request I know cannot be granted.
I am writing up two of these ideas as method in a companion paper (in preparation): how to separate false positives using a rule's history, and how to fix predictions before looking at the data. If your habits differ, especially for confirming impact when edge access logs are off, I would like to hear them.