How do they match? Tell us more specifics. For instance is there an ip field in firewall events whose values match up with the values of offending_ip in the sample events?
Also what are you trying to get for each of those IP's? Do you need the raw text or do you just want to get usernames or session ids or total bytes etc...?
Assuming there are two different IP fields involved, and you want to join on the IP values, and assuming that you want to get, say, the username, the simplest way is with stats.
(index=sample offending_ip=*) OR (index=main source="firewall") | eval status=if(isnull(offending_ip),firewall_ip_field,offending_ip) | stats last(user) by offending_ip
The eval clause there takes some getting used to but it is normalizing the field names for you, so that stats can zip it up using a consistent field name.
Almost everyone gravitates toward join at first, but it's only rarely the best tool for the job. In short join is a powerful but obscure and less performant tool for the corner cases that stats and transaction cannot handle.