Fail2abuseipdb
A simple application for converting fail2ban's jail output to an abuseipdb-compatible CSV
All Data Structures Files Functions Variables Typedefs Pages
main.cpp
Go to the documentation of this file.
1 /**
2  * @file main.cpp
3  * @author Simon Cahill (simon@simonc.eu)
4  * @brief Contains the main application logic.
5  * @version 0.1
6  * @date 2022-10-07
7  *
8  * @copyright Copyright (c) 2022 Simon Cahill
9  */
10 
11 #include <algorithm>
12 #include <exception>
13 #include <filesystem>
14 #include <fstream>
15 #include <iostream>
16 #include <iterator>
17 #include <map>
18 #include <string>
19 #include <vector>
20 
21 #include <nlohmann/json.hpp>
22 #include <fmt/format.h>
23 
24 #include <getopt.h>
25 #include <unistd.h>
26 
27 #include "string_splitter.hpp"
28 #include "version.hpp"
29 
30 namespace fs = std::filesystem;
31 
32 using fmt::format;
33 
34 using nlohmann::json;
35 
36 using defcat_t = std::vector<int32_t>;
37 using lookup_t = std::map<std::string, int32_t>;
38 using std::cerr;
39 using std::cin;
40 using std::cout;
41 using std::endl;
42 using std::error_code;
43 using std::exception;
44 using std::ifstream;
45 using std::istreambuf_iterator;
46 using std::map;
47 using std::ofstream;
48 using std::string;
49 using std::string_view;
50 using std::system_error;
51 using std::transform;
52 using std::vector;
53 
54 // prototypes
55 static constexpr string_view getShortArgs(); //!< Gets the short string of args for getopt_long
56 static const option* getOptions(); //!< Gets the array of options for getopt_long
57 
58 using svec_t = vector<string>;
59 
60 static bool alreadyReported(const string&); //!< Indicates whether or not an IP has already been reported
61 static bool dumpF2bToFile(); //!< Dumps fail2ban's output to a file before attempting to read it back through parseFail2BanFromFile()
62 static bool findFail2Ban(); //!< Attempts to find fail2ban-client in the system's $PATH
63 static bool outputCsv(const json&); //!< Dumps the CSV-encoded data to the terminal
64 static bool parseArgs(int32_t argc, char** argv); //!< Parses the application arguments
65 static bool parseFail2BanFromFile(); //!< Parses fail2ban output from a given file
66 static bool parseFail2BanFromStdIn(); //!< Parses fail2ban output from stdin
67 static string exec(const string&, int32_t&); //!< Executes a program and returns the output
68 static string getCategoriesForJail(); //!< Gets the categories for the currently selected jail
69 static svec_t getLinesFromJson(const json&, const string&); //!< Gets a CSV-formatted line from a JSON object
70 // static void cacheReportedIp(const string&); //!< Stores the reported IP into a cache file
71 static void printHelpText(const string&); //!< Prints the help text to the terminal
72 static void transformFail2BanInput(string&); //!< Transforms the fail2ban output to valid JSON
73 
74 // globals
75 static bool g_readFromFile = false; //!< Whether or not to read f2b input from a file
76 static bool g_readFromStdIn = false; //!< Whether or not to read f2b input from stdin
77 
78 /**
79  * @brief Category lookup table
80  */
81 static map<string, int32_t>
83  { "sshd", 22 },
84  { "apache-auth", 21 },
85  { "apache-batbots", 19 },
86  { "apache-overflows", 21 },
87  { "apache-nohome", 21 },
88  { "apache-fakegooglebot", 19 },
89  { "apache-modsecurity", 21 },
90  { "apache-shellshock", 21 },
91  { "php-url-fopen", 21 },
92  { "roundcube-auth", 21 },
93  { "postfix", -1 },
94  { "sendmail-auth", 20 },
95  { "sendmail-reject", -1 },
96  { "dovecot", -1 },
97  { "mysqld-auth", 21 },
98  { "pam-generic", 20 },
99  { "postfix-flood-attack", 04 }
100 };
101 
102 /**
103  * @brief Default categories for each entry
104  */
106  15, 18
107 };
108 
109 static string g_cacheFile = "/tmp/f2abipdb.cache"; //!< [[unused currently]] The path to the cache file
110 static string g_fail2banExe = ""; //!< The path to the fail2ban-client executable
111 static string g_fileToRead = "fail2ban.json"; //!< The file to read input from
112 static string g_jailName = ""; //!< The name of the jail (if specific jail exported from f2b)
113 static string g_reportComment = "IP banned by fail2ban; banned in jail {0}. Report generated by fail2abuseipdb.";
114 
115 // main
116 int main(int32_t argc, char** argv) {
117  if (!parseArgs(argc, argv)) { return 0; }
118 
119  int32_t rval = 0;
120 
121  if (g_readFromFile) {
122  rval = parseFail2BanFromFile() ? 0 : 1;
123  } else if (g_readFromStdIn) {
124  rval = parseFail2BanFromStdIn() ? 0 : 2;
125  } else {
126  if (getuid() != 0) {
127  cerr << "Insufficent permissions! To execute fail2ban directly, elevated permissions are required." << endl;
128  return 4;
129  }
130  if (g_fail2banExe.empty()) {
131  cerr << "Searching for fail2ban..." << endl;
132  cerr << "!! WARNING !! Search may or may not be broken!" << endl; // TODO: Remove when bug fixed
133  if (!findFail2Ban()) {
134  cerr << "Failed to find fail2ban! Aborting." << endl;
135  return 3;
136  }
137  }
138  if (!dumpF2bToFile()) {
139  cerr << "Failed to get output from fail2ban" << endl;
140  return 3;
141  }
142 
143  rval = parseFail2BanFromFile() ? 0 : 3;
144  }
145 
146  return rval;
147 }
148 
149 /**
150  * @brief [[Not implemented]] Whether or not an IP has previously been reported.
151  *
152  * @remarks Reserved for future use
153  *
154  * @param ip The IP to check against
155  *
156  * @return true If the IP has previously been reported.
157  * @return false Otherwise.
158  */
159 bool alreadyReported(const string& ip) {
160  return false;
161 }
162 
163 /**
164  * @brief Dumps fail2ban's output to a file
165  *
166  * @return true If dumping was successful.
167  * @return false Otherwise.
168  */
170  int32_t returnCode = 0;
171  g_fileToRead = fmt::format("{0:s}/{1:d}.f2b", fs::temp_directory_path().string(), time(nullptr));
172  exec(fmt::format(R"({0:s} banned 2>/dev/null > {1:s})", g_fail2banExe, g_fileToRead), returnCode);
173 
174  return returnCode == 0;
175 }
176 
177 /**
178  * @brief Attempts to find fail2ban-client on the system-
179  *
180  * @return true If the executable was found.
181  * @return false Otherwise.
182  */
183 bool findFail2Ban() {
184  const static string PATH = "PATH";
185  const static string FAIL2BAN_EXE = "fail2ban-client";
186 
187  if (getenv(PATH.c_str()) == nullptr) { return false; }
188 
189  string pathVar{};
190  {
191  auto pathCharPtr = getenv(PATH.c_str());
192  pathVar = string(pathCharPtr, strnlen(pathCharPtr, 4096));
193  }
194 
195  for (const auto path : StringSplit(pathVar, ":")) {
196  const auto iterator = fs::directory_iterator(path);
197  const auto iteratorEnd = fs::directory_iterator();
198  const auto iterPosition = std::find_if(iterator, iteratorEnd, [&](const auto& file) {
199  return file.is_regular_file() && file.path().filename() == FAIL2BAN_EXE;
200  });
201 
202  if (iterPosition != iteratorEnd) {
203  g_fail2banExe = iterPosition->path().string();
204  return true;
205  }
206  }
207 
208  return false;
209 }
210 
211 /**
212  * @brief Parses the f2b input from the file pointed to by @see g_fileToRead
213  *
214  * @return true If the file was read correctly.
215  * @return false Otherwise.
216  */
218  bool rval = true;
219 
220  string fileContents{};
221  json entries;
222 
223  if (!fs::exists(g_fileToRead) || !fs::is_regular_file(g_fileToRead)) {
224  cerr << "File " << g_fileToRead << " cannot be read! Aborting..." << endl;
225  rval = false;
226  goto Exit;
227  }
228  {
229  ifstream fStream(g_fileToRead, ifstream::openmode::_S_in);
230  if (!fStream.good()) {
231  cerr << "Failed to open file " << g_fileToRead << ". Aborting..." << endl;
232  rval = false;
233  goto Exit;
234  }
235  fStream.unsetf(std::ios_base::skipws);
236  vector<char> buffer((istreambuf_iterator<char>(fStream)), (istreambuf_iterator<char>()));
237  fileContents = string(buffer.begin(), buffer.end());
238  }
239 
240  transformFail2BanInput(fileContents);
241  try {
242  entries = json::parse(fileContents);
243  } catch (const exception& ex) {
244  cerr << "Failed to parse JSON! Invalid format?" << endl
245  << "Error description: " << ex.what() << endl;
246  rval = false;
247  goto Exit;
248  }
249 
250  rval = outputCsv(entries);
251 
252  Exit:
253  return rval;
254 }
255 
256 /**
257  * @brief Attempts to read input from fail2ban from stdin.
258  *
259  * @return true If everything was successful.
260  * @return false Otherwise.
261  */
263  bool rval = true;
264 
265  string f2bOutput{};
266  json entries;
267 
268  for (string line{}; std::getline(cin, line);) {
269  f2bOutput.append(line).append("\n");
270  }
271 
272  if (f2bOutput.empty()) {
273  rval = false;
274  cerr << "Failed to read input from stdin!" << endl;
275  goto Exit;
276  }
277 
278  transformFail2BanInput(f2bOutput);
279  try {
280  entries = json::parse(f2bOutput);
281  } catch (const exception& ex) {
282  cerr << "Failed to parse JSON! Invalid format?" << endl
283  << "Error description: " << ex.what() << endl;
284  rval = false;
285  goto Exit;
286  }
287 
288  rval = outputCsv(entries);
289 
290  Exit:
291  return rval;
292 }
293 
294 /**
295  * @brief Outputs the CSV-encoded data to the terminal.
296  *
297  * @param entries A @see nlohmann::json object containing the f2b entries
298  *
299  * @return true If CSV could be generated and printed.
300  * @return false Otherwise
301  */
302 bool outputCsv(const json& entries) {
303  bool rval = true;
304 
305  const time_t timeNow = time(nullptr);
306  struct tm tStruct{0};
307  localtime_r(&timeNow, &tStruct);
308  string timeString(256, 0);
309  strftime(&timeString[0], timeString.size(), "%F %T%z", &tStruct);
310  timeString.shrink_to_fit();
311 
312  vector<string> csvLines{
313  "IP,Categories,ReportDate,Comment"
314  };
315 
316  // I'm expecting an array of strings (complete jail output) or an array of string (specific fail output)
317  if (!entries.is_array()) {
318  cerr << "Invalid input. Expected array, got " << entries.type_name() << endl;
319  rval = false;
320  goto Exit;
321  }
322 
323  {
324  const auto newLines = getLinesFromJson(entries, timeString);
325  csvLines.insert(csvLines.end(), newLines.begin(), newLines.end());
326  }
327 
328  for (const auto& line : csvLines) {
329  cout << line << endl;
330  }
331 
332  Exit:
333  return rval;
334 }
335 
336 /**
337  * @brief Gets all the lines (reported IPs) from the JSON resulting from fail2ban
338  *
339  * @param entries The JSON to parse
340  * @param timeString The current time
341  *
342  * @return svec_t A vector of strings containing the IPs
343  */
344 svec_t getLinesFromJson(const json& entries, const string& timeString) {
345  vector<string> lines;
346  string currentIp{};
347  for (const auto& entry : entries) {
348  // This should probably be split up,
349  // but right now I can't be bothered.
350  // As long as it works, I'm fine with it, I guess.
351  // For now at least
352  if (!entry.is_object() && entry.is_string()) {
353  currentIp = entry.get<string>();
354 
355  if (alreadyReported(currentIp)) { continue; }
356 
357  // Specific jail output
358  // We don't need to look anything up, just grab default cats and append to list
359  lines.push_back(format(
360  R"({0:s},"{1:s}",{2:s},"{3:s}")",
361  currentIp,
362  getCategoriesForJail(),
363  timeString,
364  format(g_reportComment, g_jailName.empty() ? "UNKNOWN" : g_jailName)
365  ));
366  } else if (entry.is_object()) {
367  const auto& obj = entry.get<json::object_t>();
368  g_jailName = obj.begin()->first;
369  const auto newLines = getLinesFromJson(*entry.begin(), timeString);
370  lines.insert(lines.end(), newLines.begin(), newLines.end());
371  }
372  }
373  return lines;
374 }
375 
376 /**
377  * @brief Gets the categories set for a given jail
378  *
379  * @return string A comma-separated string containing the abuseipdb categories.
380  *
381  * @remarks Categories are listed [https://www.abuseipdb.com/categories](here)
382  */
384  string categories{};
385  for (size_t i = 0; i < g_defaultCategories.size(); i++) {
386  if (i > 0) {
387  categories.push_back(',');
388  }
389 
390  categories.append(std::to_string(g_defaultCategories[i]));
391  }
392 
393  const auto posInMap = std::find_if(g_categoryLookup.begin(), g_categoryLookup.end(), [](const auto x) {
394  return x.first == g_jailName;
395  });
396 
397  if (posInMap != g_categoryLookup.end() && posInMap->second != -1) {
398  categories.append(format(",{0:d}", posInMap->second));
399  }
400 
401  return categories;
402 }
403 
404 // other impl
405 /**
406  * @brief Parses the command-line arguments sent to the application.
407  *
408  * @param argc The amount of args passed.
409  * @param argv An array of strings containing the arguments.
410  *
411  * @return true If application execution should continue.
412  * @return false If the application should halt.
413  */
414 bool parseArgs(int32_t argc, char** argv) {
415  bool rVal = true;
416 
417  int32_t curIdx = 0;
418  char optVal = 0;
419 
420  if (argc == 1) {
421  goto UglyHelp;
422  }
423 
424  while ((optVal = getopt_long(argc, argv, getShortArgs().data(), getOptions(), &curIdx)) != -1) {
425  switch (optVal) {
426  case 'h':
427  UglyHelp:
428  printHelpText(argv[0]);
429  rVal = false;
430  goto Exit;
431  case 's':
432  g_readFromStdIn = true;
433  break;
434  case 'f':
435  g_readFromFile = true;
436  if (optarg == nullptr) {
437  cerr << "Warning: will use default file " << g_fileToRead << "." << endl;
438  } else {
439  g_fileToRead = optarg;
440  }
441  break;
442  case 'v':
443  cerr << "Not yet implemented. Sorry." << endl;
444  break;
445  case 'c':
446  if (optarg == nullptr) {
447  cerr << "Error: missing required argument for comment!" << endl;
448  break;
449  }
450  g_reportComment = optarg;
451  break;
452  case 'j':
453  if (optarg == nullptr) {
454  cerr << "Error: missing required argument for jail name!" << endl;
455  break;
456  }
457  g_jailName = optarg;
458  break;
459  case 'e':
460  if (optarg == nullptr) {
461  cerr << "Error: missing required argument for fail2ban executable!" << endl;
462  break;
463  }
464  g_fail2banExe = optarg;
465  break;
466  }
467  }
468 
469  Exit:
470  return rVal;
471 }
472 
473 /**
474  * @brief Executes a command on the current system and returns both output and exit code.
475  *
476  * @param cmd The command to execute.
477  * @param returnCode The return code of the executed command.
478  *
479  * @return string The output of the executed command.
480  */
481 string exec(const string& cmd, int32_t& returnCode) {
482  string commandOutput;
483 
484  FILE* pipe = popen(cmd.c_str(), "r");
485 
486  if (!pipe) {
487  // you'd have to throw a system_error or something here; or just remove the exception
488  throw system_error(error_code(errno, std::generic_category()), "Failed to start process!");
489  }
490 
491  try {
492  char outputBuffer[512] = {0};
493  while (!feof(pipe) && fgets(outputBuffer, sizeof(outputBuffer) / sizeof(outputBuffer[0]), pipe) != NULL) {
494  commandOutput += outputBuffer;
495  }
496  } catch (...) {
497  returnCode = pclose(pipe);
498  throw;
499  }
500 
501  returnCode = pclose(pipe);
502  return commandOutput;
503 }
504 
505 /**
506  * @brief Prints the help text to the console.
507  *
508  * @param binName The name of the application (argv[0])
509  */
510 void printHelpText(const string& binName) {
511  const static string RAW = R"(
512  {0} v{1} - A simple utility for converting fail2ban entries into an abuseipdb CSV format
513 
514  Usage:
515  {0} -f[/path/to/file] # Use [file] to parse fail2ban jail contents
516  {0} -% # to attempt to get output directly from fail2ban (requires elevated privileges!)
517  {0} --stdin # to read input from stdin
518 
519  Arguments:
520  --help, -h Prints this text and exits
521  --stdin, -s Reads input from stdin
522  --file=, -f[file] Reads input from [file] or fail2ban.json if optarg is empty
523  --version, -v Prints the version information and exits
524  --comment, -c[text] Sets the value for the comment field. Variables are available below
525  --jail-name, -j[jail] Sets the name of the jail (useful if exporting specific jails from fail2ban)
526  --f2b, -e[f2b-client] Sets the location of the fail2ban-client executable (local system will not be searched)
527  --call-f2b, -% No, that's not a typo. Calls fail2ban directly. !! WARNING: REQUIRES ELEVATED PRIVILEGES. NOT RECOMMENDED !!
528 
529  Comment variables:
530  {{0}} Jail name
531  {{1}} Report time
532 
533  Exit codes:
534  0 Success
535  1 Failed to parse input from file
536  2 Failed to parse input from stdin
537  3 Failed to parse input from fail2ban exec
538  4 Insufficent execution rights
539  5 Could not find fail2ban-client
540  )";
541 
542  cout << format(RAW, binName, getProjectVersion()) << endl;
543 }
544 
545 /**
546  * @brief Gets the short args required for getopt_long.
547  *
548  * @return constexpr string_view The arg string.
549  */
550 constexpr string_view getShortArgs() { return "hsf:vc:j:e:%"; }
551 
552 /**
553  * @brief Gets the array of options required for getopt_long.
554  *
555  * @return const option* A const pointer to an array of @see option structs.
556  */
557 const option* getOptions() {
558  const static option OPTIONS[] = {
559  { "help", no_argument, nullptr, 'h' },
560  { "stdin", no_argument, nullptr, 's' },
561  { "file", optional_argument, nullptr, 'f' },
562  { "version", no_argument, nullptr, 'v' },
563  { "comment", required_argument, nullptr, 'c' },
564  { "jail-name", required_argument, nullptr, 'j' },
565  { "f2b", required_argument, nullptr, 'e' },
566  { "call-f2b", no_argument, nullptr, '%' },
567  { nullptr, no_argument, nullptr, 0 }
568  };
569 
570  return OPTIONS;
571 }
572 
573 /**
574  * @brief Transforms fail2ban's output to a valid JSON.
575  *
576  * @param input Raw fail2ban ouput.
577  */
578 void transformFail2BanInput(string& input) {
579  string output{};
580  transform(input.begin(), input.end(), std::back_inserter(output), [&](const char c) {
581  if (c == '\'') { return '"'; }
582  return c;
583  });
584 
585  input = output;
586 }
587 
588 /**
589  * @brief [[Unused / Reserved for future use]] Caches a recently reported IP.
590  *
591  * @param ip The IP to cache.
592  */
593 void cacheReportedIp(const string& ip) {
594  ofstream cacheOutput(g_cacheFile, std::ios_base::openmode::_S_app);
595  cacheOutput << ip << ":" << time(nullptr) << endl;
596  cacheOutput.close();
597 }