Request for Quote or Purchase (RFQ|RFP) with suspicious sender or recipient pattern

RFQ/RFP scams involve fraudulent emails posing as legitimate requests for quotations or purchases, often sent by scammers impersonating reputable organizations. These scams aim to deceive recipients into providing sensitive information or conducting unauthorized transactions, often leading to financial loss, or data leakage.

Sublime rule (View on GitHub)

  1name: "Request for Quote or Purchase (RFQ|RFP) with suspicious sender or recipient pattern"
  2description: |
  3  RFQ/RFP scams involve fraudulent emails posing as legitimate requests for quotations or purchases, often sent by scammers impersonating reputable organizations.
  4  These scams aim to deceive recipients into providing sensitive information or conducting unauthorized transactions, often leading to financial loss, or data leakage.  
  5type: "rule"
  6severity: "medium"
  7source: |
  8  type.inbound
  9  and (
 10    (
 11      (
 12        length(recipients.to) == 0
 13        or all(recipients.to,
 14               .display_name in (
 15                 "Undisclosed recipients",
 16                 "undisclosed-recipients"
 17               )
 18        )
 19      )
 20      and length(recipients.cc) == 0
 21    )
 22    or (
 23      sender.email.domain.root_domain in $free_email_providers
 24      and any(headers.reply_to, .email.email != sender.email.email)
 25      and any(headers.reply_to, .email.email not in $recipient_emails)
 26    )
 27    or (
 28      length(headers.reply_to) > 0
 29      and all(headers.reply_to,
 30              .email.domain.root_domain != sender.email.domain.root_domain
 31              and not .email.domain.root_domain in $org_domains
 32      )
 33    )
 34    or (
 35      length(recipients.to) == 1
 36      and all(recipients.to, .email.email == sender.email.email)
 37      and (length(recipients.cc) > 0 or length(recipients.bcc) > 0)
 38    )
 39    or (
 40      length(recipients.to) == 0
 41      and length(recipients.cc) == 1
 42      and sender.email.email == recipients.cc[0].email.email
 43    )
 44    or (
 45      length(recipients.to) == 1
 46      and length(recipients.cc) == 0
 47      and sender.email.email == recipients.to[0].email.email
 48    )
 49  )
 50  and (
 51    // Group the keyword patterns that specifically indicate RFQ/RFP
 52    (
 53      (
 54        // RFQ/RFP specific language patterns
 55        regex.icontains(body.current_thread.text,
 56                        '(discuss.{0,15}purchas(e|ing))'
 57        )
 58        or regex.icontains(body.current_thread.text,
 59                           '(sign(ed?)|view).{0,10}(purchase order)|Request for (a Quot(e|ation)|Proposal)'
 60        )
 61        or regex.icontains(body.current_thread.text,
 62                           '(please|kindly|preferred).{0,30}(?:proposal|quot(e|ation))'
 63        )
 64        or regex.icontains(subject.subject,
 65                           '(request for (purchase|quot(e|ation))|\bRFQ\b|\bRFP\b|bid invit(e|ation))'
 66        )
 67        or any(attachments,
 68               regex.icontains(.file_name, "(purchase.?order|Quot(e|ation))")
 69        )
 70        or any(ml.nlu_classifier(body.current_thread.text).tags,
 71               .name == "purchase_order" and .confidence == "high"
 72        )
 73        or any(ml.nlu_classifier(body.current_thread.text).entities,
 74               .name == "financial" and regex.imatch(.text, "rfp|rfq")
 75        )
 76        or any(ml.nlu_classifier(body.current_thread.text).entities,
 77               .name == "request" and strings.icontains(.text, 'submit bid')
 78        )
 79      )
 80      // Required: at least one RFQ/RFP keyword pattern
 81  
 82      // Optional: at least one additional indicator (can be another keyword pattern or a non-keyword indicator)
 83      and (
 84        2 of (
 85          // RFQ/RFP keyword patterns (same as above)
 86          regex.icontains(body.current_thread.text,
 87                          '(discuss.{0,15}purchas(e|ing))'
 88          ),
 89          regex.icontains(body.current_thread.text,
 90                          '(sign(ed?)|view).{0,10}(purchase order)|Request for a Quot(e|ation)'
 91          ),
 92          regex.icontains(body.current_thread.text,
 93                          '(please|kindly|preferred).{0,30}(?:proposal|quot(e|ation))'
 94          ),
 95          regex.icontains(body.current_thread.text,
 96                          '(?:invitation|intent) to bid'
 97          ),
 98          regex.icontains(subject.subject,
 99                          '(request for (purchase|quot(e|ation))|\bRFQ\b|\bRFP\b|bid invit(e|ation))'
100          ),
101          any(attachments,
102              regex.icontains(.file_name, "(purchase.?order|Quot(e|ation))")
103          ),
104          any(ml.nlu_classifier(body.current_thread.text).tags,
105              .name == "purchase_order" and .confidence == "high"
106          ),
107          any(ml.nlu_classifier(body.current_thread.text).entities,
108              .name == "financial" and regex.imatch(.text, "(?:rfp|rfq)")
109          ),
110          any(ml.nlu_classifier(body.current_thread.text).entities,
111              .name == "request" and regex.imatch(.text, "bid invitation")
112          ),
113          any(attachments,
114              any(file.explode(.),
115                  any(ml.nlu_classifier(.scan.ocr.raw).entities,
116                      .name == "request"
117                      and regex.imatch(.text, "view (?:rfp|rfq)")
118                  )
119              )
120          ),
121  
122          // Non-keyword indicators
123          (
124            any(ml.nlu_classifier(body.current_thread.text).entities,
125                .name == "request"
126            )
127            and any(ml.nlu_classifier(body.current_thread.text).entities,
128                    .name == "urgency"
129            )
130            and not any(ml.nlu_classifier(body.current_thread.text).topics,
131                        .name == "Advertising and Promotions"
132                        and .confidence == "high"
133            )
134          ),
135          (
136            0 < length(filter(body.links,
137                              (
138                                .href_url.domain.domain in $free_subdomain_hosts
139                                or .href_url.domain.domain in $free_file_hosts
140                                or network.whois(.href_url.domain).days_old < 30
141                              )
142                              and (
143                                regex.match(.display_text, '[A-Z ]+')
144                                or any(ml.nlu_classifier(.display_text).entities,
145                                       .name in ("request", "urgency")
146                                )
147                                or any(ml.nlu_classifier(.display_text).intents,
148                                       .name in ("cred_theft")
149                                )
150                              )
151                       )
152            ) < 3
153          ),
154          // mentions an attachment that does not exist
155          (
156            length(attachments) == 0
157            and strings.icontains(body.current_thread.text, "attached")
158          ),
159          any(body.current_thread.links, regex.icontains(.href_url.url, 'RFP'))
160        )
161      )
162    )
163    or (
164      length(attachments) == 1
165      and length(body.current_thread.text) < 100
166      and all(attachments,
167              .file_type in $file_types_images
168              and any(file.explode(.),
169                      2 of (
170                        regex.icontains(.scan.ocr.raw,
171                                        '(discuss.{0,15}purchas(e|ing))'
172                        ),
173                        regex.icontains(.scan.ocr.raw,
174                                        '(sign(ed?)|view).{0,10}(purchase order)|Request for a Quot(e|ation)'
175                        ),
176                        regex.icontains(.scan.ocr.raw,
177                                        '(please|kindly).{0,30}quote'
178                        ),
179                        (
180                          any(ml.nlu_classifier(.scan.ocr.raw).entities,
181                              .name == "request"
182                          )
183                          and any(ml.nlu_classifier(.scan.ocr.raw).entities,
184                                  .name == "urgency"
185                          )
186                        ),
187                        any(ml.nlu_classifier(.scan.ocr.raw).tags,
188                            .name == "purchase_order" and .confidence == "high"
189                        ),
190                        any(ml.nlu_classifier(.scan.ocr.raw).entities,
191                            .name == "financial"
192                            and regex.imatch(.text, "rfp|rfq")
193                        ),
194                      )
195              )
196      )
197    )
198    // fake PDF file icon used as a link lure with bid solicitation language
199    or (
200      regex.icontains(subject.subject, 'project\s+summary')
201      and any(html.xpath(body.html, '//div[.//img[contains(@src,"pdf")]]//a').nodes,
202              regex.icontains(.display_text, 'project\s+summary')
203      )
204      and regex.icontains(body.current_thread.text,
205                          '(put a bid|\bbid\s+(for|on)\b|submit.{1,20}(bid|quot(e|ation))|request for (purchase|quot(e|ation))|\bRFQ\b|\bRFP\b|project\s+summary)'
206      )
207    )
208  )
209  // wetransfer includes user specific reply-to's & link display text which triggers NLU logic further within the rule
210  and not (
211    sender.email.domain.root_domain == "wetransfer.com"
212    and coalesce(headers.auth_summary.dmarc.pass, false)
213  )
214  
215  // negate highly trusted sender domains unless they fail DMARC authentication
216  and not (
217    sender.email.domain.root_domain in $high_trust_sender_root_domains
218    and coalesce(headers.auth_summary.dmarc.pass, false)
219  )
220  and (
221    (
222      (
223        not profile.by_sender().solicited
224        or profile.by_sender().days_since.last_contact > 30
225      )
226      and not profile.by_sender().any_messages_benign
227    )
228    // sender address listed as a recipient
229    or (
230      length(recipients.to) == 1
231      and sender.email.email in map(recipients.to, .email.email)
232    )
233  )  
234attack_types:
235  - "BEC/Fraud"
236tactics_and_techniques:
237  - "Evasion"
238  - "Free email provider"
239detection_methods:
240  - "Content analysis"
241  - "Natural Language Understanding"
242  - "URL analysis"
243id: "2ac0d329-c1fb-5c87-98dd-ea3e5b85377a"
to-top