Skip to content
API Documentation
BastionGuard™
PhishingPage.cpp
Go to the documentation of this file.
1/*
2 * BastionGuard™
3 * Copyright (C) 2025–2026 Calogero Scarnà
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, version 3.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 *
17 * BastionGuard™ is a trademark of Calogero Scarnà.
18 * The BastionGuard™ name and branding are not licensed under the GPL.
19 */
20
21#include "PhishingPage.hpp"
22
23#include <glibmm/i18n.h>
24#include <glibmm/main.h>
25#include <giomm.h>
26
27#include <regex>
28#include <sstream>
29#include <iomanip>
30#include <thread>
31#include <chrono>
32
34 : Gtk::Box(Gtk::Orientation::VERTICAL, 14)
35{
36 add_css_class("phishing-page");
37
38 set_margin_top(20);
39 set_margin_bottom(20);
40 set_margin_start(20);
41 set_margin_end(20);
42 set_vexpand(true);
43 set_hexpand(true);
44
45 lbl_title_.set_markup("<span weight='bold' size='x-large'>Phishing Scanner</span>");
46 lbl_title_.set_halign(Gtk::Align::START);
47 lbl_title_.set_xalign(0.0f);
48 lbl_title_.add_css_class("page-title");
49 lbl_title_.set_margin_bottom(8);
50 append(lbl_title_);
51
52 phishing_card_.set_margin_bottom(10);
53 append(phishing_card_);
54
56 sigc::mem_fun(*this, &PhishingPage::on_analyze_requested));
57
58 scroller_.set_vexpand(true);
59 scroller_.set_hexpand(true);
60 scroller_.add_css_class("phishing-result-box");
61
62 txt_output_.set_editable(false);
63 txt_output_.set_cursor_visible(false);
64 txt_output_.set_wrap_mode(Gtk::WrapMode::WORD_CHAR);
65 txt_output_.set_monospace(false);
66 txt_output_.add_css_class("phishing-result-text");
67 txt_output_.get_buffer()->set_text(_("Ready."));
68 scroller_.set_child(txt_output_);
69 append(scroller_);
70
71 lbl_status_.set_halign(Gtk::Align::START);
72 lbl_status_.set_xalign(0.0f);
73 lbl_status_.set_text(_("Ready."));
74 lbl_status_.add_css_class("phishing-status");
75 lbl_status_.set_margin_top(4);
76 append(lbl_status_);
77}
78
80 destroyed_ = true;
81 if (alive_) alive_->store(false, std::memory_order_release);
82}
83
84std::string PhishingPage::trim_copy(const std::string& s) {
85 const auto begin = s.find_first_not_of(" \t\r\n");
86 if (begin == std::string::npos)
87 return {};
88
89 const auto end = s.find_last_not_of(" \t\r\n");
90 return s.substr(begin, end - begin + 1);
91}
92
93std::string PhishingPage::url_encode(const std::string& s) {
94 std::ostringstream oss;
95 oss << std::hex << std::uppercase;
96
97 for (unsigned char c : s) {
98 if ((c >= 'A' && c <= 'Z') ||
99 (c >= 'a' && c <= 'z') ||
100 (c >= '0' && c <= '9') ||
101 c == '-' || c == '_' || c == '.' || c == '~') {
102 oss << c;
103 } else {
104 oss << '%' << std::setw(2) << std::setfill('0') << static_cast<int>(c);
105 }
106 }
107
108 return oss.str();
109}
110
111std::string PhishingPage::fetch_url_body(const std::string& url) {
112 try {
113 auto proc = Gio::Subprocess::create(
114 {
115 "/usr/bin/curl",
116 "-L",
117 "--silent",
118 "--show-error",
119 "--fail",
120 "--max-time", "20",
121 url
122 },
123 Gio::Subprocess::Flags::STDOUT_PIPE | Gio::Subprocess::Flags::STDERR_PIPE
124 );
125
126 auto result = proc->communicate_utf8("");
127
128 if (proc->get_successful())
129 return result.first.raw();
130 } catch (...) {
131 }
132
133 return {};
134}
135
136std::string PhishingPage::html_entity_decode(std::string s) {
137 auto repl = [&](const std::string& from, const std::string& to) {
138 std::size_t pos = 0;
139 while ((pos = s.find(from, pos)) != std::string::npos) {
140 s.replace(pos, from.size(), to);
141 pos += to.size();
142 }
143 };
144
145 repl("&amp;", "&");
146 repl("&lt;", "<");
147 repl("&gt;", ">");
148 repl("&quot;", "\"");
149 repl("&#039;", "'");
150 repl("&nbsp;", " ");
151
152 return s;
153}
154
155std::string PhishingPage::strip_tags(const std::string& s) {
156 std::string out = std::regex_replace(s, std::regex("<[^>]*>"), " ");
157 out = html_entity_decode(out);
158 out = std::regex_replace(out, std::regex(R"(\s+)"), " ");
159 return trim_copy(out);
160}
161
162std::optional<std::string> PhishingPage::extract_first(
163 const std::string& text,
164 const std::regex& re,
165 int group)
166{
167 std::smatch m;
168 if (std::regex_search(text, m, re) && m.size() > static_cast<size_t>(group))
169 return m[group].str();
170
171 return std::nullopt;
172}
173
175 const std::string& html,
176 const std::string& label)
177{
178 std::vector<std::string> out;
179
180 const std::string pattern =
181 "<strong>\\s*" + label + R"(\s*:</strong>\s*<ul>([\s\S]*?)</ul>)";
182
183 std::smatch m;
184 if (!std::regex_search(html, m, std::regex(pattern, std::regex::icase)))
185 return out;
186
187 const std::string ul = m[1].str();
188
189 std::regex li_re(R"(<li>([\s\S]*?)</li>)", std::regex::icase);
190 auto begin = std::sregex_iterator(ul.begin(), ul.end(), li_re);
191 auto end = std::sregex_iterator();
192
193 for (auto it = begin; it != end; ++it) {
194 out.push_back(strip_tags((*it)[1].str()));
195 }
196
197 return out;
198}
199
201 ParsedResult r;
202
203 if (auto v = extract_first(
204 html,
205 std::regex(
206 R"(<span class="phishing-badge [^"]*">CLASSIFICATION</span>\s*([^<]+))",
207 std::regex::icase)); v) {
208 r.classification = trim_copy(strip_tags(*v));
209 }
210
211 if (auto v = extract_first(
212 html,
213 std::regex(
214 R"(<div class="phishing-sev-level">([^<]+)</div>)",
215 std::regex::icase)); v) {
216 r.severity = trim_copy(strip_tags(*v));
217 }
218
219 if (auto v = extract_first(
220 html,
221 std::regex(
222 R"(<strong>\s*Normalized Host:\s*</strong>\s*<code class="phishing-code">([^<]+)</code>)",
223 std::regex::icase)); v) {
224 r.normalized_host = trim_copy(strip_tags(*v));
225 }
226
227 if (auto v = extract_first(
228 html,
229 std::regex(
230 R"(<strong>\s*Evaluation Chain:\s*</strong>\s*<code class="phishing-code">([^<]+)</code>)",
231 std::regex::icase)); v) {
232 r.evaluation_chain = trim_copy(strip_tags(*v));
233 }
234
235 if (auto v = extract_first(
236 html,
237 std::regex(
238 R"(<strong>\s*Matched Indicator:\s*</strong>\s*<code class="phishing-code">([^<]+)</code>)",
239 std::regex::icase)); v) {
240 r.matched_indicator = trim_copy(strip_tags(*v));
241 }
242
243 if (auto v = extract_first(
244 html,
245 std::regex(
246 R"(<strong>\s*Link Status \‍(Live Probe\):\s*</strong>\s*<span class="phishing-badge [^"]*">\s*([^<]+)\s*</span>)",
247 std::regex::icase)); v) {
248 r.live_probe_status = trim_copy(strip_tags(*v));
249 }
250
251 if (auto v = extract_first(
252 html,
253 std::regex(
254 R"(<div style="margin-top:\.4rem; color:#475569;">\s*([\s\S]*?)\s*</div>)",
255 std::regex::icase)); v) {
256 r.live_probe_text = trim_copy(strip_tags(*v));
257 }
258
259 const auto details = extract_all_li_after_label(html, "Analysis Details");
260 std::ostringstream oss;
261
262 oss << "Classification: "
263 << (r.classification.empty() ? "N/A" : r.classification.raw()) << "\n";
264
265 if (!r.severity.empty())
266 oss << "Severity: " << r.severity << "\n";
267
268 if (!r.normalized_host.empty())
269 oss << "Normalized Host: " << r.normalized_host << "\n";
270
271 if (!r.evaluation_chain.empty())
272 oss << "Evaluation Chain: " << r.evaluation_chain << "\n";
273
274 if (!r.matched_indicator.empty())
275 oss << "Matched Indicator: " << r.matched_indicator << "\n";
276
277 if (!r.live_probe_status.empty())
278 oss << "Live Probe: " << r.live_probe_status << "\n";
279
280 if (!r.live_probe_text.empty())
281 oss << "Live Probe Details: " << r.live_probe_text << "\n";
282
283 if (!details.empty()) {
284 oss << "\nAnalysis Details:\n";
285 for (const auto& d : details)
286 oss << " - " << d << "\n";
287 }
288
289 r.report_text = oss.str();
290 return r;
291}
292
293void PhishingPage::on_analyze_requested(const Glib::ustring& url) {
294 phishing_card_.set_feedback(_("Analysis started..."), false);
295 lbl_status_.set_text(_("Analyzing URL..."));
296 txt_output_.get_buffer()->set_text(_("Starting analysis for: ") + url + "\n");
297
298 auto alive = alive_;
299 std::thread([this, alive, url]() {
300 const std::string endpoint =
301 "https://bastionguard.eu/bastionguard-security-intelligence/?q=" +
302 url_encode(url.raw());
303
304 const std::string html = fetch_url_body(endpoint);
305
306 if (!alive || !alive->load(std::memory_order_acquire))
307 return;
308
309 if (html.empty()) {
310 Glib::signal_idle().connect_once([this, alive]() {
311 BastionGuard::if_alive(alive, [this]() {
313 _("Unable to contact BastionGuard Security Intelligence."), true);
314 lbl_status_.set_text(_("Remote analysis failed."));
315 txt_output_.get_buffer()->set_text(
316 _("The remote BastionGuard Security Intelligence page did not return valid HTML."));
317 });
318 });
319 return;
320 }
321
322 ParsedResult result = parse_result_html(html);
323
324 Glib::signal_idle().connect_once([this, alive, result]() {
325 BastionGuard::if_alive(alive, [this, &result]() {
326 if (result.classification.empty()) {
328 _("Unexpected response format from remote analyzer."), true);
329 lbl_status_.set_text(_("Parsing failed."));
330 txt_output_.get_buffer()->set_text(
331 _("The remote page responded, but no classification block was found."));
332 return;
333 }
334
335 txt_output_.get_buffer()->set_text(result.report_text);
336
337 if (result.classification.find("MALICIOUS") != Glib::ustring::npos) {
338 lbl_status_.set_text(_("Malicious result detected."));
339 phishing_card_.set_feedback(_("Malicious result detected."), true);
340 } else if (result.classification.find("SUSPICIOUS") != Glib::ustring::npos) {
341 lbl_status_.set_text(_("Suspicious result detected."));
342 phishing_card_.set_feedback(_("Suspicious result detected."), true);
343 } else {
344 lbl_status_.set_text(_("Analysis complete."));
345 phishing_card_.set_feedback(_("Analysis complete."), false);
346 }
347 });
348 });
349 }).detach();
350}
#define _(s)
sigc::signal< void(const Glib::ustring &)> & signal_analyze_requested()
void set_feedback(const Glib::ustring &text, bool is_error=false)
static ParsedResult parse_result_html(const std::string &html)
PhishingCheckCard phishing_card_
virtual ~PhishingPage()
static std::string fetch_url_body(const std::string &url)
static std::string trim_copy(const std::string &s)
static std::string strip_tags(const std::string &s)
Gtk::TextView txt_output_
static std::string url_encode(const std::string &s)
static std::string html_entity_decode(std::string s)
std::atomic< bool > destroyed_
Gtk::Label lbl_status_
void on_analyze_requested(const Glib::ustring &url)
BastionGuard::AliveFlag alive_
Gtk::Label lbl_title_
static std::vector< std::string > extract_all_li_after_label(const std::string &html, const std::string &label)
Gtk::ScrolledWindow scroller_
static std::optional< std::string > extract_first(const std::string &text, const std::regex &re, int group=1)
void if_alive(const AliveFlag &alive, Fn &&fn)