├── common ├── __init__.py ├── utils.py └── eventdb_mentat.py ├── doc ├── um_alg.odg ├── architecture.odg ├── architecture.png ├── um_alg_full.png ├── arch-parallel.png ├── arch-parallel_draw.io_link.txt ├── um_alg_simple.png ├── task-distribution.png ├── NERD_intro_for_developers_(CZ).odp └── NERD_intro_for_developers_(CZ).pdf ├── NERDweb ├── static │ ├── spin.gif │ ├── flags.png │ ├── devel-bar.png │ ├── pdns_help.png │ ├── censys_icon.png │ ├── shodan_icon.png │ ├── talos_icon.png │ ├── valli_icon.png │ ├── dshield_icon.png │ ├── greynoise-logo.png │ ├── nerd_logo_350.png │ ├── abuse_ip_db_icon.png │ ├── nerd_logo_simple_86.png │ ├── whatismyipaddress.png │ ├── 403.html │ ├── links_dropdown.js │ ├── search_options.js │ ├── ip_poll.js │ ├── status-box.js │ ├── main.js │ ├── format_date.js │ └── jquery.multiselect.css ├── wsgi.py ├── templates │ ├── 429.html │ ├── feed.html │ ├── shodan_response.html │ ├── account_info.html │ ├── ipblock.html │ ├── org.html │ ├── bgppref.html │ ├── asn.html │ ├── noaccount.html │ ├── data.html │ └── map.html ├── wsgi-debug.py ├── robots.txt └── shodan_rpc_client.py ├── .gitignore ├── install ├── httpd │ ├── http_blocklist.conf │ ├── munin.conf │ ├── nerd-debug.conf │ └── nerd.conf ├── pip_requirements_nerdweb.txt ├── configure_rabbitmq.sh ├── configure_mongo.sh ├── pip_requirements_nerdd.txt ├── munin │ ├── nerd_warden_queue │ ├── nerd_errors │ ├── nerd_tasks_by_src │ ├── nerd_shodan │ ├── nerd_web_errors │ ├── nerd_not_updated │ ├── nerd_entities │ ├── nerd_rec_ops │ ├── nerd_mongo_rs │ ├── nerd_running_components │ ├── nerd_web_endpoints │ └── nerd_warden_delay ├── install_configure_bind.sh ├── configure_cron.sh ├── nerd-supervisor.service ├── create_user_db.sql ├── supervisord.conf.d │ ├── dshield.ini │ ├── updater.ini │ ├── otx_receiver.ini │ ├── shodan_requester.ini │ ├── ecl_master.ini │ ├── misp_receiver.ini │ ├── warden_receiver.ini │ ├── blacklists2redis.ini │ ├── blacklists.ini │ ├── warden_filer.ini │ └── workers.ini ├── common.sh ├── download_data_files.sh ├── warden_filer.cfg.template ├── mongo_prepare_db.js ├── configure_postgres.sh ├── configure_supervisor.sh ├── prepare_environment.sh ├── install_configure_munin.sh ├── install_warden_filer.sh ├── create_warden_db.sql ├── cron │ └── nerd ├── configure_apache.sh └── supervisord.conf ├── scripts ├── rmq_purge_worker_queues.sh ├── backup_user_db.sh ├── nerd_clean_eventdb.sh ├── get_ttl_token_stats.sh ├── set_api_token.sh ├── generate_ip_rep_list.sh ├── set_prefix_repscore.js ├── check_status.sh ├── update_db_meta_info.js ├── generate_blocklist.sh ├── add_user.sh ├── rmq_reconfigure.sh ├── download_maxmind_geolite.sh ├── put_task.py ├── get_iana_assignment_files.py ├── shodan_requester.py └── fix_ref_cnt.js ├── client-scripts ├── README.md └── nerd.nse ├── NERDd ├── g.py ├── modules │ ├── intervals_between_events.py │ ├── update_planner.py │ ├── eml_asn_rank.py │ ├── test_module.py │ ├── dns.py │ ├── geolocation.py │ ├── reputation.py │ ├── reserved_ip.py │ ├── bgp_rank.py │ ├── ttl_updater.py │ ├── event_type_counter.py │ ├── hostname.py │ ├── event_counter.py │ └── caida_as_class.py ├── core │ ├── basemodule.py │ ├── db.py │ └── scheduler.py └── dshield.py ├── README.md ├── etc ├── acl ├── nerdweb.yml └── event_logging.yml └── LICENSE /common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/um_alg.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/um_alg.odg -------------------------------------------------------------------------------- /doc/architecture.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/architecture.odg -------------------------------------------------------------------------------- /doc/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/architecture.png -------------------------------------------------------------------------------- /doc/um_alg_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/um_alg_full.png -------------------------------------------------------------------------------- /NERDweb/static/spin.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/spin.gif -------------------------------------------------------------------------------- /doc/arch-parallel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/arch-parallel.png -------------------------------------------------------------------------------- /doc/arch-parallel_draw.io_link.txt: -------------------------------------------------------------------------------- 1 | https://www.draw.io/#G1vjHISYT1Pso0v4_icCMu4mLWLNMXoHan -------------------------------------------------------------------------------- /doc/um_alg_simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/um_alg_simple.png -------------------------------------------------------------------------------- /NERDweb/static/flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/flags.png -------------------------------------------------------------------------------- /doc/task-distribution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/task-distribution.png -------------------------------------------------------------------------------- /NERDweb/static/devel-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/devel-bar.png -------------------------------------------------------------------------------- /NERDweb/static/pdns_help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/pdns_help.png -------------------------------------------------------------------------------- /NERDweb/static/censys_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/censys_icon.png -------------------------------------------------------------------------------- /NERDweb/static/shodan_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/shodan_icon.png -------------------------------------------------------------------------------- /NERDweb/static/talos_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/talos_icon.png -------------------------------------------------------------------------------- /NERDweb/static/valli_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/valli_icon.png -------------------------------------------------------------------------------- /NERDweb/static/dshield_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/dshield_icon.png -------------------------------------------------------------------------------- /NERDweb/static/greynoise-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/greynoise-logo.png -------------------------------------------------------------------------------- /NERDweb/static/nerd_logo_350.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/nerd_logo_350.png -------------------------------------------------------------------------------- /NERDweb/static/abuse_ip_db_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/abuse_ip_db_icon.png -------------------------------------------------------------------------------- /NERDweb/static/nerd_logo_simple_86.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/nerd_logo_simple_86.png -------------------------------------------------------------------------------- /NERDweb/static/whatismyipaddress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/NERDweb/static/whatismyipaddress.png -------------------------------------------------------------------------------- /doc/NERD_intro_for_developers_(CZ).odp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/NERD_intro_for_developers_(CZ).odp -------------------------------------------------------------------------------- /doc/NERD_intro_for_developers_(CZ).pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CESNET/NERD/HEAD/doc/NERD_intro_for_developers_(CZ).pdf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .vagrant/ 3 | alerts.tar.gz 4 | alerts/ 5 | caida-as2types.txt 6 | 7 | \.idea/ 8 | 9 | alerts_1000/ 10 | -------------------------------------------------------------------------------- /NERDweb/wsgi.py: -------------------------------------------------------------------------------- 1 | from nerd_main import app as application 2 | application.debug = True # Enable logging of exceptions into Apache log file 3 | -------------------------------------------------------------------------------- /install/httpd/http_blocklist.conf: -------------------------------------------------------------------------------- 1 | # HTTP blocklist template 2 | # To enable the blocklist put this file in /etc/nerd/http_blocklist.conf and reload apache 3 | 4 | Require not ip 1.2.3.4 5 | Require not ip 4.5.6.7 6 | -------------------------------------------------------------------------------- /NERDweb/templates/429.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 429 Too Many Requests 5 | 6 | 7 |

Too Many Requests

8 |

{{ message }} Try again soon.

9 | 10 | -------------------------------------------------------------------------------- /install/pip_requirements_nerdweb.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | pytz 3 | Flask 4 | Flask-WTF 5 | pymongo>=3.11 6 | WTForms 7 | Flask-pymongo 8 | Flask-Mail 9 | psycopg2-binary 10 | redis 11 | hiredis 12 | cachetools 13 | event_count_logger 14 | -------------------------------------------------------------------------------- /install/configure_rabbitmq.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | BASEDIR=$(dirname $0) 4 | . $BASEDIR/common.sh 5 | 6 | echob "=============== Configure RabbitMQ ===============" 7 | 8 | # Configure RMQ queues for 2 workers by default 9 | /nerd/scripts/rmq_reconfigure.sh 2 10 | -------------------------------------------------------------------------------- /scripts/rmq_purge_worker_queues.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Clear contents of all nerd-worker-* queues in RabbitMQ. 3 | 4 | queue_list=$(rabbitmqadmin list queues name -f tsv | grep "^nerd-worker-") 5 | 6 | for q in $queue_list 7 | do 8 | rabbitmqadmin purge queue name=$q 9 | done 10 | -------------------------------------------------------------------------------- /scripts/backup_user_db.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | # Dump NERD user database into an .sql file "./db_backup_.sql". 3 | date=$(date -I) 4 | pg_dump -U nerd nerd_users -t users -f db_backup_$date.sql 5 | echo "Dump of database 'nerd_users' (table 'users' only) stored into db_backup_$date.sql" 6 | 7 | -------------------------------------------------------------------------------- /install/configure_mongo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | BASEDIR=$(dirname $0) 4 | . $BASEDIR/common.sh 5 | 6 | echob "=============== Configure MongoDB ===============" 7 | 8 | echob "** Configuring MongoDB **" 9 | 10 | # Set up MongoDB for NERD (create indexes) 11 | mongosh nerd $BASEDIR/mongo_prepare_db.js 12 | -------------------------------------------------------------------------------- /install/pip_requirements_nerdd.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | pymongo 3 | psycopg2-binary 4 | redis 5 | hiredis 6 | dnspython 7 | geoip2 8 | pygeoip 9 | pycares 10 | shodan 11 | apscheduler 12 | pika>=1.0.0 13 | redis 14 | hiredis 15 | numpy 16 | pandas 17 | xgboost==1.6.1 18 | pymisp==2.4.111.2 19 | zmq 20 | amqpstorm 21 | jsonpath_rw 22 | jsonpath_rw_ext 23 | OTXv2 -------------------------------------------------------------------------------- /install/munin/nerd_warden_queue: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$1" == "config" ]]; then 4 | cat <<'END' 5 | graph_title Warden queue length 6 | graph_category nerd 7 | graph_vlabel Number of IDEA messages 8 | queue.label messages 9 | queue.warning 8000 10 | END 11 | exit 0 12 | fi 13 | 14 | echo "queue.value $(ls /data/warden_filer/warden_receiver/incoming/ | wc -l)" 15 | -------------------------------------------------------------------------------- /install/munin/nerd_errors: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$1" == "config" ]]; then 4 | cat <<'END' 5 | graph_title Errors 6 | graph_category nerd 7 | graph_vlabel Number of errors per 5 min 8 | error_in_module.label "Error in module" 9 | error_in_module.warning 1 10 | END 11 | fi 12 | 13 | ecl_reader /etc/nerd/event_logging.yml -g errors -i 5m | sed -E 's/:([0-9]+)$/.value \1/' 14 | -------------------------------------------------------------------------------- /scripts/nerd_clean_eventdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Remove all events older then 14 days from Event DB 3 | d="$(date -d "-14 days" -I)T00:00:00" 4 | echo "Removing all events older than $d ..." 5 | psql -U nerd -d nerd_warden -c "DELETE FROM events WHERE detecttime < '$d'; DELETE FROM events_sources WHERE detecttime < '$d'; DELETE FROM events_targets WHERE detecttime < '$d';" 6 | echo Done 7 | -------------------------------------------------------------------------------- /client-scripts/README.md: -------------------------------------------------------------------------------- 1 | # Scripts 2 | 3 | ## nmap script 4 | 5 | `nerd.nse` script in LUA for nmap (www.nmap.org), which 6 | allows users to look up a scanned target in NERD. 7 | The script requires API token stored in a file called `nerdapifile` 8 | in the current working directory or it is possible to specify path 9 | to the file using `--script-args` (`nerd.apifile=`). 10 | 11 | 12 | -------------------------------------------------------------------------------- /NERDweb/static/403.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 403 Forbidden 5 | 6 | 7 |

Access denied

8 |

Your IP address has been permanently blocked due to excessive amount of queries or another abuse of the service. If you think this is an error or you need a special access to the service for legitimate reasons, contact admins at "nerd@cesnet.cz"

9 | 10 | 11 | -------------------------------------------------------------------------------- /scripts/get_ttl_token_stats.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Get statistics on number of IP records by TTL tokens 3 | # grep is used because mongosh puts an empty line at the end that we don't want there 4 | mongosh nerd --quiet --eval 'db.ip.aggregate([{$project: {ttl: {$objectToArray: "$_ttl"}}}, {$unwind: "$ttl"}, {$group: {"_id": "$ttl.k", cnt: {$sum: 1}}}, {$sort: {cnt: -1}}]).forEach( function(x) { print(x["_id"] + "\t" + x["cnt"]); })' | grep -v "^$" 5 | -------------------------------------------------------------------------------- /install/install_configure_bind.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Install and configure BIND (supports DNS queries made by various modules) 3 | 4 | BASEDIR=$(dirname $0) 5 | . $BASEDIR/common.sh 6 | 7 | echob "=============== Install & Configure BIND ===============" 8 | 9 | echob "** Installing BIND **" 10 | yum install -y -q bind bind-utils 11 | 12 | echob "** Starting BIND **" 13 | systemctl enable named.service 14 | systemctl restart named.service 15 | -------------------------------------------------------------------------------- /NERDweb/wsgi-debug.py: -------------------------------------------------------------------------------- 1 | from nerd_main import app 2 | 3 | # Enable logging of exceptions into Apache log file 4 | app.debug = True 5 | 6 | # Enable interactive debuggin in web browser. 7 | # ! Don't use this on production server, it allows to run arbitarary code ! 8 | from werkzeug.debug import DebuggedApplication 9 | application = DebuggedApplication(app, True) 10 | 11 | # Enable testing mode of NERDweb. 12 | # This allows anyone to log in as admininstrator ("devel autologin")! 13 | from nerd_main import config 14 | config.testing = True 15 | -------------------------------------------------------------------------------- /scripts/set_api_token.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Set API token to given user (in 'users' table in PSQL DB 'nerd'). 3 | # If no token is specified, a random string of 10 alphanumeric characters is used. 4 | 5 | if [ -z "$1" ]; then 6 | echo -e "Usage:\n $0 user_name [token]" >&2 7 | exit 1 8 | fi 9 | user=$1 10 | if [ -z "$2" ]; then 11 | token="$(Feed - {{name}} 5 |
6 | Description: 7 |
8 | {{description}} 9 |
10 |
11 | 12 |
13 |
Type of feed:
{{feed_type}}
14 |
Links
15 | {% if firehol_link %} 16 |
FireHOL link: {{ firehol_link }}
17 | {% endif %} 18 | {% if provider_link %} 19 |
Provider link: {{ provider_link }}
20 | {% endif %} 21 | {% if url %} 22 |
Feed link: {{ url }}
23 | {% endif %} 24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /NERDweb/templates/shodan_response.html: -------------------------------------------------------------------------------- 1 | {% if not data.error -%} 2 |

Ports:{% for val in data.ports|sort %}{{ val }}{%- endfor -%}

3 | {% if data.os -%} 4 |

OS: {{ data.os }}

5 | {% endif -%} 6 | {% if data.tags -%} 7 |

Tags: {% for val in data.tags|sort %}{{ val }}{%- endfor -%}

8 | {% endif -%} 9 | 10 | {%- elif "No information available" in data.error -%} 11 |
-- no information available --
12 | {%- elif data.error == "timeout" -%} 13 | Error: RPC timeout 14 | {%- else -%} 15 | Error while requesting shodan api: {{ data.error }} 16 | {%- endif %} -------------------------------------------------------------------------------- /install/munin/nerd_tasks_by_src: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sources="blacklists 4 | misp_receiver 5 | otx_receiver 6 | updater 7 | warden_receiver 8 | updater_manager 9 | web 10 | misp_updater 11 | " 12 | 13 | if [[ "$1" == "config" ]]; then 14 | cat <<'END' 15 | graph_title Tasks processed by source 16 | graph_category nerd 17 | graph_vlabel Number of tasks per 5 min 18 | END 19 | for each_source in ${sources}; do 20 | echo "${each_source}.label ${each_source}" 21 | echo "${each_source}.draw AREASTACK" 22 | echo "${each_source}.min 0" 23 | done 24 | exit 0 25 | fi 26 | 27 | ecl_reader /etc/nerd/event_logging.yml -g tasks_by_src -i 5m | sed -E 's/:([0-9]+)$/.value \1/' -------------------------------------------------------------------------------- /NERDweb/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: /nerd/login/ 3 | Disallow: /nerd/_ips_count 4 | Disallow: /nerd/api/ 5 | Disallow: /nerd/ajax/ 6 | Disallow: /nerd/pdns/ 7 | Disallow: /nerd/misp_event/ 8 | Disallow: /Shibboleth.sso 9 | 10 | # Disallow some bots that generate a lot of traffic and we think it is not useful 11 | 12 | # OpenAI bot crawling training data - it doesn't make sense to use NERDs dynamic data for training (bots for search and user actions are still allowed) 13 | User-Agent: GPTBot 14 | Disallow: /nerd/ 15 | Disallow: /Shibboleth.sso 16 | 17 | # Moz.com crawler for their backlink checker - lot of traffic and useless to search backlinks in NERD data 18 | User-Agent: DotBot 19 | Disallow: /nerd/ 20 | Disallow: /Shibboleth.sso 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NERD - Network Entity Reputation Database 2 | 3 | NERD is a software and a service which acquires, stores and aggregates various data about known malicious network entities (mostly IP addresses) and provides them in a comprehensible way to users. 4 | 5 | The main NERD instance runs at [nerd.cesnet.cz](https://nerd.cesnet.cz/). 6 | 7 | See the [project wiki](https://github.com/CESNET/NERD/wiki) for more information. 8 | 9 | --- 10 | 11 | _This software was developed within the scope of the Security Research 12 | Programme of the Czech Republic 2015 - 2020 (BV III / 1 VS) granted by 13 | the Ministry of the Interior of the Czech Republic under the project No. 14 | VI20162019029 The Sharing and analysis of security events in the Czech 15 | Republic._ 16 | -------------------------------------------------------------------------------- /install/munin/nerd_shodan: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$1" == "config" ]]; then 4 | cat <," per line 5 | # Output goes to stdout. 6 | 7 | echo "# All IP addresses and their reputation scores in NERD database. Generated at $(date -u '+%Y-%m-%d %H:%M UTC')" 8 | 9 | # grep is used because mongosh puts an empty line at the end that we don't want there 10 | mongosh nerd --quiet --eval ' 11 | function int2ip (ipInt) { 12 | return ( (ipInt>>>24) + "." + (ipInt>>16 & 255) + "." + (ipInt>>8 & 255) + "." + (ipInt & 255) ); 13 | } 14 | db.ip.find({rep: {$gt: 0}}, {rep: 1}).sort({rep: -1}).forEach( function(rec) { print(int2ip(rec._id) + "," + rec.rep.toFixed(3)); } ); 15 | ' | grep -v "^$" 16 | 17 | -------------------------------------------------------------------------------- /install/munin/nerd_not_updated: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$1" == "config" ]]; then 4 | cat <<'END' 5 | graph_title Not updated entities 6 | graph_info Number of entities with '_nru' (next regular update) field older than 1 hour. Should always be zero if automatic updates work well. 7 | graph_category nerd 8 | graph_vlabel Number of entities 9 | ip.label Number of IPs 10 | ip.warning 1 11 | asn.label Number of ASNs 12 | asn.warning 1 13 | END 14 | exit 0 15 | fi 16 | 17 | date="$(date --rfc-3339=seconds --utc -d "-1 hour")" 18 | # grep is used because mongosh puts an empty line at the end that we don't want there 19 | mongosh nerd --quiet --eval " 20 | print('ip.value', db.ip.find({_nru1d: {\$lt: ISODate(\"${date}\")}}).count()); 21 | print('asn.value', db.asn.find({_nru1d: {\$lt: ISODate(\"${date}\")}}).count()); 22 | " | grep -v "^$" 23 | 24 | -------------------------------------------------------------------------------- /install/munin/nerd_entities: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$1" == "config" ]; then 4 | cat <<'END' 5 | graph_title Number of entities in database 6 | graph_category nerd 7 | graph_vlabel Number of entities 8 | ip.label Number of IPs 9 | bgppref.label Number of BGP prefixes 10 | ipblock.label Number of IP blocks 11 | asn.label Number of ASNs 12 | org.label Number of Organizations 13 | END 14 | exit 0 15 | fi 16 | 17 | # grep is used because mongosh puts an empty line at the end that we don't want there 18 | mongosh nerd --quiet --eval " 19 | print('ip.value', db.ip.estimatedDocumentCount()); 20 | print('bgppref.value', db.bgppref.estimatedDocumentCount()); 21 | print('ipblock.value', db.ipblock.estimatedDocumentCount()); 22 | print('asn.value', db.asn.estimatedDocumentCount()); 23 | print('org.value', db.org.estimatedDocumentCount()); 24 | " | grep -v "^$" 25 | 26 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/dshield.ini: -------------------------------------------------------------------------------- 1 | [program:dshield] 2 | command = python3 /nerd/NERDd/dshield.py -c /etc/nerd/nerd.yml 3 | 4 | priority = 30 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 2 seconds until program is considered sucessfully running 8 | startsecs = 2 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 -------------------------------------------------------------------------------- /install/httpd/munin.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | AuthUserFile /etc/munin/munin-htpasswd 4 | AuthName "Munin" 5 | AuthType Basic 6 | require valid-user 7 | 8 | # This next part requires mod_expires to be enabled. 9 | # 10 | # We could use around here, but I want it to be 11 | # as evident as possible that you either have to load mod_expires _or_ 12 | # you coment out/remove these lines. 13 | 14 | # Set the default expiery time for files 5 minutes 10 seconds from 15 | # their creation (modification) time. There are probably new files by 16 | # that time. 17 | 18 | ExpiresActive On 19 | ExpiresDefault M310 20 | 21 | 22 | 23 | ScriptAlias /munin-cgi/munin-cgi-graph /var/www/html/munin/cgi/munin-cgi-graph 24 | 25 | 26 | Options ExecCGI 27 | SetHandler cgi-script 28 | 29 | 30 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/updater.ini: -------------------------------------------------------------------------------- 1 | [program:updater] 2 | command = python3 /nerd/NERDd/updater.py -c /etc/nerd/nerdd.yml 3 | 4 | priority = 20 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 5 seconds until program is considered sucessfully running 8 | startsecs = 5 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 29 | 30 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/otx_receiver.ini: -------------------------------------------------------------------------------- 1 | [program:otx_receiver] 2 | command = python3 /nerd/NERDd/otx_receiver.py -c /etc/nerd/nerdd.yml 3 | 4 | priority = 30 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 2 seconds until program is considered sucessfully running 8 | startsecs = 2 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 -------------------------------------------------------------------------------- /install/supervisord.conf.d/shodan_requester.ini: -------------------------------------------------------------------------------- 1 | [program:shodan_requester] 2 | command = python3 /nerd/scripts/shodan_requester.py -v 3 | 4 | priority = 5 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 2 seconds until program is considered sucessfully running 8 | startsecs = 2 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = true 13 | ;exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 29 | 30 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/ecl_master.ini: -------------------------------------------------------------------------------- 1 | [program:ecl_master] 2 | command = python3 /usr/local/bin/ecl_master /etc/nerd/event_logging.yml 3 | 4 | priority = 5 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 3 seconds until program is considered successfully running 8 | startsecs = 3 9 | startretries = 1 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 5 seconds to exit before it's killed 16 | stopwaitsecs = 5 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 2 24 | 25 | stdout_logfile = /var/log/nerd/ecl_master.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 29 | 30 | 31 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/misp_receiver.ini: -------------------------------------------------------------------------------- 1 | [program:misp_receiver] 2 | command = python3 /nerd/NERDd/misp_receiver.py -c /etc/nerd/nerdd.yml 3 | 4 | priority = 30 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 2 seconds until program is considered sucessfully running 8 | startsecs = 2 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 29 | 30 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/warden_receiver.ini: -------------------------------------------------------------------------------- 1 | [program:warden_receiver] 2 | command = python3 /nerd/NERDd/warden_receiver.py -c /etc/nerd/nerdd.yml 3 | 4 | priority = 30 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 2 seconds until program is considered sucessfully running 8 | startsecs = 2 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 29 | 30 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/blacklists2redis.ini: -------------------------------------------------------------------------------- 1 | [program:blacklists2redis] 2 | command = python3 /nerd/scripts/blacklists2redis.py -c /etc/nerd/blacklists.yml 3 | 4 | priority = 5 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 2 seconds until program is considered sucessfully running 8 | startsecs = 2 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 29 | 30 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/blacklists.ini: -------------------------------------------------------------------------------- 1 | [program:blacklists] 2 | command = python3 /nerd/NERDd/blacklists.py -c /etc/nerd/nerdd.yml -s /etc/nerd/primary_blacklists.yml 3 | priority = 30 4 | 5 | ; ** (Re)starting ** 6 | ; Wait for 2 seconds until program is considered sucessfully running 7 | startsecs = 2 8 | startretries = 0 9 | 10 | ; Automatically restart if program exits with an exit code other than 0 11 | autorestart = unexpected 12 | exitcodes = 0 13 | 14 | ; Give program 10 seconds to exit before it's killed 15 | stopwaitsecs = 10 16 | 17 | ; ** Logging ** 18 | ; Redirect stderr to stdout (results in just one log file) 19 | redirect_stderr = true 20 | 21 | stdout_logfile_maxbytes = 50MB 22 | stdout_logfile_backups = 5 23 | 24 | stdout_logfile = /var/log/nerd/%(program_name)s.log 25 | 26 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 27 | environment = PYTHONUNBUFFERED=1 28 | 29 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/warden_filer.ini: -------------------------------------------------------------------------------- 1 | [program:warden_filer] 2 | command = python3 /opt/warden_filer/warden_filer.py -c /etc/nerd/warden_filer.cfg receiver 3 | 4 | priority = 50 5 | 6 | ; ** (Re)starting ** 7 | ; Wait for 5 seconds until program is considered sucessfully running 8 | startsecs = 2 9 | startretries = 0 10 | 11 | ; Automatically restart if program exits with an exit code other than 0 12 | autorestart = unexpected 13 | exitcodes = 0 14 | 15 | ; Give program 10 seconds to exit before it's killed 16 | stopwaitsecs = 10 17 | 18 | ; ** Logging ** 19 | ; Redirect stderr to stdout (results in just one log file) 20 | redirect_stderr = true 21 | 22 | stdout_logfile_maxbytes = 50MB 23 | stdout_logfile_backups = 5 24 | 25 | stdout_logfile = /var/log/nerd/%(program_name)s.log 26 | 27 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 28 | environment = PYTHONUNBUFFERED=1 29 | 30 | -------------------------------------------------------------------------------- /scripts/set_prefix_repscore.js: -------------------------------------------------------------------------------- 1 | // Set reputation score of each BGP prefix as an average of rep. scores of all 2 | // IP addresses within it (including the noes not in DB, which heve rep.score=0) 3 | 4 | // var cnt = db.bgppref.count(); 5 | // var i = 0; 6 | 7 | db.ip.aggregate( 8 | [ 9 | {$match: {"bgppref": {$exists: 1}}}, 10 | {$project: {"bgppref": 1, "rep": 1}}, 11 | //{$limit: 50}, 12 | {$group: {_id: "$bgppref", sum_rep: {$sum: "$rep"}}}, 13 | ], 14 | {allowDiskUse:true} 15 | ).forEach(function (x) { 16 | prefix_id = x["_id"]; 17 | prefix_len = prefix_id.split("/")[1]; 18 | prefix_size = 1 << (32 - prefix_len); 19 | avg_rep = x["sum_rep"] / prefix_size; 20 | // print("(" + prefix_id + ").rep <= " + avg_rep); 21 | db.bgppref.updateMany({"_id": prefix_id}, {"$set": {"rep": avg_rep}}); 22 | // i += 1; 23 | // if (i % 1000 == 0) { 24 | // print("Done: "+i+"/"+cnt); 25 | // } 26 | }) 27 | -------------------------------------------------------------------------------- /install/common.sh: -------------------------------------------------------------------------------- 1 | # To be run at the beginning of all installation scripts 2 | 3 | # disable "fastestmirror plugin, which in fact slows down yum" 4 | alias yum="yum --disableplugin=fastestmirror" 5 | 6 | # Set up functions for colored output, but not during Vagrant provisioning, as it doesn't work well there 7 | if ! [ -e /vagrant_provisioning ] 8 | then 9 | # print section headers in blue color 10 | echob () { 11 | tput setaf 4 # light blue 12 | tput bold 13 | echo "$@" 14 | tput sgr0 15 | } 16 | 17 | # print important notes in yellow color 18 | echoy () { 19 | tput setaf 3 # yellow 20 | tput bold 21 | echo "$@" 22 | tput sgr0 23 | } 24 | 25 | # print warnings and errors in red color 26 | echor () { 27 | tput setaf 1 # red 28 | tput bold 29 | echo "$@" 30 | tput sgr0 31 | } 32 | 33 | else 34 | # run during warden provisioning, don't use colors 35 | alias echob=echo 36 | alias echoy=echo 37 | alias echor=echo 38 | fi -------------------------------------------------------------------------------- /scripts/check_status.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ret_code=0 4 | 5 | check_service () { 6 | status="$(systemctl is-active ${1}.service)" 7 | if [ $status == "active" ]; then 8 | echo $1 $(tput setaf 2)$status$(tput sgr0) 9 | else 10 | echo $1 $(tput setaf 1)$status$(tput sgr0) 11 | ret_code=1 12 | fi 13 | } 14 | 15 | check_process () { 16 | if pgrep -f $1 > /dev/null; then 17 | echo $1 $(tput setaf 2)running$(tput sgr0) 18 | else 19 | echo $1 $(tput setaf 1)not running$(tput sgr0) 20 | ret_code=1 21 | fi 22 | } 23 | 24 | check_service postgresql-11 25 | check_service mongod 26 | check_service redis 27 | check_service rabbitmq-server 28 | check_service named 29 | check_service httpd 30 | check_service postfix 31 | check_service munin-node 32 | #check_process warden_filer.py 33 | #check_process blacklists2redis.py 34 | #check_process shodan_requester.py 35 | #check_process nerdd.py 36 | check_service nerd-supervisor 37 | 38 | exit $ret_code 39 | 40 | -------------------------------------------------------------------------------- /install/munin/nerd_rec_ops: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | etypes="ip 4 | asn 5 | bgppref 6 | ipblock 7 | org 8 | " 9 | 10 | if [[ "$1" == "config" ]]; then 11 | cat <<'END' 12 | graph_title Tasks processed by entity and operation type 13 | graph_category nerd 14 | graph_vlabel Number of tasks per 5 min 15 | END 16 | for etype in ${etypes}; do 17 | echo "${etype}_updated.label ${etype} record updated" 18 | echo "${etype}_updated.draw AREASTACK" 19 | echo "${etype}_updated.min 0" 20 | echo "${etype}_created.label ${etype} record created" 21 | echo "${etype}_created.draw AREASTACK" 22 | echo "${etype}_created.min 0" 23 | echo "${etype}_removed.label ${etype} record removed" 24 | echo "${etype}_removed.draw AREASTACK" 25 | echo "${etype}_removed.min 0" 26 | echo "${etype}_noop.label ${etype} (noop)" 27 | echo "${etype}_noop.draw AREASTACK" 28 | echo "${etype}_noop.min 0" 29 | done 30 | exit 0 31 | fi 32 | 33 | ecl_reader /etc/nerd/event_logging.yml -g rec_ops -i 5m | sed -E 's/:([0-9]+)$/.value \1/' 34 | -------------------------------------------------------------------------------- /NERDd/modules/intervals_between_events.py: -------------------------------------------------------------------------------- 1 | from core.basemodule import NERDModule 2 | import g 3 | 4 | import logging 5 | 6 | 7 | class IntervalsBetweenEvents(NERDModule): 8 | """ 9 | This module stores timestamps of the last N warden events. 10 | """ 11 | def __init__(self): 12 | self.log = logging.getLogger("FMPmodule") 13 | self.max_events = 21 14 | 15 | # Register all necessary handlers. 16 | g.um.register_handler( 17 | self.updateIntervalsBetweenEvents, 18 | 'ip', 19 | ('last_warden_event',), 20 | ('_intervals_between_events',) 21 | ) 22 | 23 | def updateIntervalsBetweenEvents(self, ekey, rec, updates): 24 | if '_intervals_between_events' in rec: 25 | timestamps = rec['_intervals_between_events'] 26 | timestamps.append(rec['last_warden_event']) 27 | else: 28 | timestamps = [rec['last_warden_event']] 29 | 30 | return [ 31 | ('set', '_intervals_between_events', timestamps[-self.max_events:]) 32 | ] -------------------------------------------------------------------------------- /scripts/update_db_meta_info.js: -------------------------------------------------------------------------------- 1 | // Update collections n_ip_by_cat and n_ip_by_node, which contain number of IPs with given Category and Node, respectively. They also serve as lists of all existing Categories and Nodes. 2 | db.ip.aggregate([{$unwind: {path: "$events"}}, {$group: {_id: {ip: "$_id", x: "$events.cat"}}}, {$group: {_id: "$_id.x", n: {$sum: 1}}}, {$out: "n_ip_by_cat"}], {allowDiskUse:true}) 3 | db.ip.aggregate([{$unwind: {path: "$events"}}, {$group: {_id: {ip: "$_id", x: "$events.node"}}}, {$group: {_id: "$_id.x", n: {$sum: 1}}}, {$out: "n_ip_by_node"}], {allowDiskUse:true}) 4 | db.ip.aggregate([{$unwind: {path: "$bl"}}, {$match: {"bl.v": 1}}, {$group: {_id: {ip: "$_id", x: "$bl.n"}}}, {$group: {_id: "$_id.x", n: {$sum: 1}}}, {$out: "n_ip_by_bl"}], {allowDiskUse:true}) 5 | db.ip.aggregate([{$unwind: {path: "$dbl"}}, {$match: {"dbl.v": 1}}, {$group: {_id: {ip: "$_id", x: "$dbl.n"}}}, {$group: {_id: "$_id.x", n: {$sum: 1}}}, {$out: "n_ip_by_dbl"}], {allowDiskUse:true}) 6 | db.ip.aggregate([{$project: {ttl: {$objectToArray: "$_ttl"}}}, {$unwind: "$ttl"}, {$group: {_id: "$ttl.k", n: {$sum: 1}}}, {$out: "n_ip_by_ttl"}], {allowDiskUse:true}) 7 | //TODO tags (needs to change storage format) 8 | -------------------------------------------------------------------------------- /install/download_data_files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | BASEDIR=$(dirname $0) 4 | . $BASEDIR/common.sh 5 | 6 | echob "=============== Download data files ===============" 7 | 8 | # Perform everything as "nerd" user instead of root 9 | cd / 10 | sudo -u nerd sh <{print(x.name)})') 6 | cat <<'END' 7 | graph_title MongoDB replica-set status 8 | graph_info Status of individual nodes of the MongoDB replica-set 9 | graph_category nerd 10 | graph_vlabel Status code (1=pri, 2=sec) 11 | graph_scale no 12 | END 13 | for node in $nodes; do 14 | node2=${node//./_} # replace dots with underscores, since dot has a special meaning in Munin 15 | echo "${node2}.label ${node}" 16 | echo "${node2}.min 0" 17 | echo "${node2}.max 10" 18 | # Issue warning if any member's state is anything else than 1 (PRIMARY) or 2 (SECONDARY), as it means some problem 19 | # See https://docs.mongodb.com/manual/reference/replica-states/#replica-set-member-states 20 | echo "${node2}.warning 1:2" 21 | done 22 | exit 0 23 | fi 24 | 25 | # Get list of nodes in the replica-set and their status 26 | # Print directly in munin format: .value 27 | mongosh nerd --quiet --eval 'rs.status().members.forEach((x)=>{print(x.name.replace(/\./g, "_") + ".value " + x.state)})' 28 | -------------------------------------------------------------------------------- /install/munin/nerd_running_components: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Checks if all components are running. 3 | # Simply returns 1 or 0 for each component showing whether it is in RUNNING state or not 4 | 5 | statuscmd="supervisorctl -c /etc/nerd/supervisord.conf -s http://localhost:9001 status" 6 | 7 | if [[ "$1" == "config" ]]; then 8 | # Get the list of configured components 9 | comps=$($statuscmd | cut -d ' ' -f 1) 10 | cat <&2 # Error message to stderr 18 | exit 1 19 | fi 20 | 21 | echo "# IP addresses in NERD database with reputation score over ${thr} (excluding whitelisted ones). Generated at $(date -u '+%Y-%m-%d %H:%M UTC')" 22 | 23 | mongosh nerd --quiet --eval ' 24 | function int2ip (ipInt) { 25 | return ( (ipInt>>>24) + "." + (ipInt>>16 & 255) + "." + (ipInt>>8 & 255) + "." + (ipInt & 255) ); 26 | } 27 | db.ip.find({rep: {$gt: '"$thr"'}, "tags.whitelist": {$exists: false}}, {_id: 1}).sort({rep: -1}).forEach( function(rec) { print(int2ip(rec._id)); } ); 28 | ' 29 | -------------------------------------------------------------------------------- /install/supervisord.conf.d/workers.ini: -------------------------------------------------------------------------------- 1 | [program:w] ; workers 2 | command = python3 /nerd/NERDd/worker.py %(process_num)d -c /etc/nerd/nerdd.yml 3 | 4 | priority = 10 5 | 6 | ; Run several workers in parallel 7 | numprocs = 2 8 | ; WARNING: If changing number of worker processes, the following process 9 | ; must be followed: 10 | ; 1. stop all inputs (e.g. warden_receiver, updater) 11 | ; 2. when all queues are empty, stop all workers 12 | ; 3. reconfigure queues in RabbitMQ using /nerd/scripts/rmq_reconfigure.sh 13 | ; 4. change the settings here and in nerd.yml 14 | ; 5. reload supervisord and start everything again 15 | 16 | process_name = worker%(process_num)d 17 | 18 | ; ** (Re)starting ** 19 | ; Wait for 5 seconds until program is considered sucessfully running 20 | startsecs = 5 21 | startretries = 1 22 | 23 | ; Automatically restart if program exits with an exit code other than 0 24 | autorestart = unexpected 25 | exitcodes = 0 26 | 27 | ; Give program 30 seconds to exit before it's killed 28 | stopwaitsecs = 30 29 | 30 | ; ** Logging ** 31 | ; Redirect stderr to stdout (results in just one log file) 32 | redirect_stderr = true 33 | 34 | stdout_logfile_maxbytes = 50MB 35 | stdout_logfile_backups = 5 36 | 37 | stdout_logfile = /var/log/nerd/worker%(process_num)d.log 38 | 39 | ; prevent stdout to be buffered, otherwise it's printed with a long delay 40 | environment = PYTHONUNBUFFERED=1 41 | 42 | -------------------------------------------------------------------------------- /NERDd/core/basemodule.py: -------------------------------------------------------------------------------- 1 | """Abstract class for NERD module""" 2 | 3 | # Each handler function should have the following prototype: 4 | # 5 | # def (self, ekey, rec, updates): 6 | # 7 | # Arguments: 8 | # ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 9 | # rec -- record currently assigned to the key 10 | # updates -- list of all attributes whose update triggered this call and their 11 | # new values (or events and their parameters) as a list of 2-tuples: 12 | # [(attr, val), (!event, param), ...] 13 | # 14 | # Returns: 15 | # List of update requests, i.e. 3-tuples describing requested attribute updates 16 | # or events (for details, see comment at the beginning of update_manager.py). 17 | 18 | class NERDModule: 19 | """ 20 | Abstract class for NERD modules. 21 | """ 22 | def start(self): 23 | """ 24 | Run the module - used to run own thread if needed. 25 | 26 | Called after initialization, may be used to create and run a separate 27 | thread if needed by the module. Do nothing unless overridden. 28 | """ 29 | pass 30 | 31 | def stop(self): 32 | """ 33 | Stop the module - used to stop own thread. 34 | 35 | Called before program exit, may be used to finalize and stop the 36 | separate thread if it is used. Do nothing unless overridden. 37 | """ 38 | pass 39 | -------------------------------------------------------------------------------- /install/mongo_prepare_db.js: -------------------------------------------------------------------------------- 1 | // Set up indexes in MongoDB 2 | db.ip.createIndex({"events_meta.total":-1},{background: true}) 3 | db.ip.createIndex({"geo.ctry":1},{background: true}) 4 | db.ip.createIndex({"ts_added":-1},{background: true}) 5 | //db.ip.createIndex({"ts_last_update":-1},{background: true}) // not needed anywhere 6 | db.ip.createIndex({"last_activity":-1},{background: true}) 7 | db.ip.createIndex({"hostname":1},{background: true}) 8 | db.ip.createIndex({"bl.n": 1, "bl.v": 1},{partialFilterExpression: {"bl": {$exists: true}}, background: true} ) 9 | db.ip.createIndex({"dbl.n": 1, "dbl.v": 1},{partialFilterExpression: {"dbl": {$exists: true}}, background: true} ) 10 | db.ip.createIndex({"rep":-1},{background: true}) 11 | db.ip.createIndex({"bgppref":1},{background: true}) 12 | db.ip.createIndex({"ipblock":1},{background: true}) 13 | db.asn.createIndex({"org":1},{background: true}) 14 | db.ipblock.createIndex({"org":1},{background: true}) 15 | 16 | // Needed by Updater 17 | //db.ip.createIndex({"_nru4h": 1},{background: true}) 18 | db.ip.createIndex({"_nru1d": 1},{background: true}) 19 | db.ip.createIndex({"_nru1w": 1},{background: true}) 20 | //db.asn.createIndex({"_nru4h": 1},{background: true}) 21 | db.asn.createIndex({"_nru1d": 1},{background: true}) 22 | db.asn.createIndex({"_nru1w": 1},{background: true}) 23 | 24 | // Needed by munin nerd_delay plugin 25 | db.ip.createIndex({"_ttl.warden":-1},{background: true}) 26 | -------------------------------------------------------------------------------- /install/configure_postgres.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Configure PSQL databases 3 | # There are two databases by default: 4 | # 1) user database for web (mandatory) 5 | # 2) database of Warden events (optional, disabled by default) 6 | # The second one can be enabled by passing "--warden" as argument 7 | 8 | BASEDIR=$(dirname $0) 9 | . $BASEDIR/common.sh 10 | 11 | echob "=============== Configure PostgreSQL ===============" 12 | 13 | echob "** Configuring PostgreSQL database **" 14 | 15 | cd / # to avoid "could not change directory to /home/vagrant" error in Vagrant 16 | 17 | sudo -u postgres /usr/pgsql-11/bin/createuser nerd 18 | 19 | # Create a user database for NERDweb in PostgreSQL 20 | sudo -u postgres /usr/pgsql-11/bin/createdb --owner nerd nerd_users 21 | sudo -u nerd /usr/pgsql-11/bin/psql -d nerd_users -f $BASEDIR/create_user_db.sql 22 | 23 | # (Optional) Create a database for Warden events 24 | if [ "$1" == "--warden" ] ; then 25 | sudo -u postgres /usr/pgsql-11/bin/createdb --owner nerd nerd_warden 26 | sudo -u nerd /usr/pgsql-11/bin/psql -d nerd_warden -f $BASEDIR/create_warden_db.sql 27 | fi 28 | 29 | # TODO install pgadmin4 30 | # Install pgAdmin4 and set it up to run via WSGI under Apache 31 | # yum install -y pgadmin4 32 | # Run initial setup to set the admin user account 33 | # see https://computingforgeeks.com/how-to-install-pgadmin-4-on-centos-7-fedora-29-fedora-28/ 34 | # python /usr/lib/python2.7/site-packages/pgadmin4-web/setup.py 35 | -------------------------------------------------------------------------------- /install/munin/nerd_web_endpoints: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$1" == "config" ]]; then 4 | cat < set _nru* 31 | def add_nru_fields(self, ekey, rec, updates): 32 | """When a new entity is added, add NRU (next regular update) fields to 33 | its record.""" 34 | return [ 35 | ('set', '_nru4h', rec['ts_added'] + timedelta(seconds=4*60*60)), 36 | ('set', '_nru1d', rec['ts_added'] + timedelta(days=1)), 37 | ('set', '_nru1w', rec['ts_added'] + timedelta(days=7)), 38 | ] 39 | -------------------------------------------------------------------------------- /NERDweb/static/links_dropdown.js: -------------------------------------------------------------------------------- 1 | // add eventListener for click on button --> expand dropdown 2 | document.addEventListener("DOMContentLoaded", function () { 3 | document.querySelectorAll('.links-dropbtn').forEach( 4 | element => 5 | element.addEventListener('click', toggleDropdown) 6 | ); 7 | }); 8 | 9 | function toggleDropdown(event){ 10 | var dropdown = event.target.nextElementSibling; 11 | // close all other dropdown menus 12 | close_dropdowns(dropdown); 13 | dropdown.classList.toggle('links-show'); 14 | } 15 | 16 | // function which closes all dropdown menus or all except the one, which is passed as currentDropdown 17 | function close_dropdowns(currentDropdown){ 18 | var dropdowns = document.getElementsByClassName("links-dropdown-content"); 19 | var i; 20 | for (i = 0; i < dropdowns.length; i++) { 21 | var openDropdown = dropdowns[i]; 22 | if (openDropdown.classList.contains('links-show')) { 23 | if(currentDropdown === null){ 24 | openDropdown.classList.remove('links-show'); 25 | } 26 | else { 27 | if (currentDropdown !== openDropdown) { 28 | openDropdown.classList.remove('links-show'); 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | // Close the dropdown menu if the user clicks outside of it 36 | window.onclick = function(event) { 37 | if (!event.target.matches('.links-dropbtn')) { 38 | close_dropdowns(null) 39 | } 40 | }; -------------------------------------------------------------------------------- /NERDweb/static/search_options.js: -------------------------------------------------------------------------------- 1 | var tabButtons=document.querySelectorAll(".tabContainer .buttonContainer button"); 2 | var tabPanels=document.querySelectorAll(".tabContainer .tabPanel"); 3 | 4 | function parser(){ 5 | var search = document.getElementById('ip_list').value; 6 | var ip_list = search.match(/(\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:(?!\/)|\/[12]?[0-9]|\/3[012])\b)/g); 7 | if (ip_list !== null) 8 | { 9 | ip_list = ip_list.toString(); 10 | ip_list = ip_list.replaceAll(/,/g, '\n'); 11 | // remove duplicates by converting to set, then back 12 | ip_list = Array.from(new Set(ip_list.split('\n'))).toString().replaceAll(',', '\n'); 13 | } 14 | 15 | document.getElementById('ip_list').value = ip_list; 16 | } 17 | 18 | function changeTab(panelIndex) { 19 | sessionStorage.setItem("currentPanel", panelIndex); 20 | showPanel(sessionStorage.getItem("currentPanel")); 21 | } 22 | 23 | function showPanel(panelIndex) { 24 | 25 | tabButtons.forEach(function(node){ 26 | node.classList.remove("selected"); 27 | }); 28 | 29 | tabButtons[panelIndex].classList.add("selected"); 30 | 31 | tabPanels.forEach(function(node){ 32 | node.style.display="none"; 33 | }); 34 | 35 | tabPanels[panelIndex].style.display="block"; 36 | } 37 | 38 | 39 | 40 | if (sessionStorage.getItem("currentPanel") !== null) 41 | changeTab(sessionStorage.getItem("currentPanel")); 42 | else 43 | changeTab(0); 44 | 45 | -------------------------------------------------------------------------------- /install/munin/nerd_warden_delay: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$1" == "config" ]]; then 4 | cat <<'END' 5 | graph_title Warden event processing delay 6 | graph_info Difference between current time and the highest end time of all the processed Warden events. 7 | graph_category nerd 8 | graph_vlabel Delay [minutes] 9 | graph_scale no 10 | warden_delay.label Delay 11 | warden_delay.warning -5:30 12 | END 13 | exit 0 14 | fi 15 | 16 | # Simple version using "last_activity", but this is set by MISP events as well, so it's not perfect for checking dealy of Warden events 17 | #echo "delay.value $(mongosh nerd --quiet --eval 'print((ISODate().getTime() - db.ip.find({}, {last_activity: 1, _id: 0}).sort({last_activity: -1}).limit(1).next().last_activity.getTime())/60000)')" 18 | 19 | # This version uses "_ttl.warden" minus configured "record_life_length.warden", which should result in the time of the last added warden alert 20 | warden_rec_ttl=$(python3 -c 'import yaml,sys; c=yaml.safe_load(open("/etc/nerd/nerd.yml")); print(c.get("record_life_length",{}).get("warden") or sys.exit(1))' 2>/dev/null) 21 | if [[ "$?" != 0 ]] || ! egrep -q "^[0-9]+$" <<<"$warden_rec_ttl"; then 22 | # Can't load "record_life_length.warden" or it's not a number, use default (14 days) 23 | warden_rec_ttl=14 24 | fi 25 | echo "warden_delay.value $(mongosh nerd --quiet --eval "print((ISODate().getTime() - (db.ip.find({\"_ttl.warden\": {\$exists: true}}, {\"_ttl.warden\": 1, _id: 0}).sort({\"_ttl.warden\": -1}).limit(1).next()._ttl.warden.getTime() - ($warden_rec_ttl*24*3600000)))/60000)")" 26 | -------------------------------------------------------------------------------- /common/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD: auxiliary/utilitiy functions and classes 3 | """ 4 | import re 5 | import datetime 6 | 7 | ipv4_re = re.compile(r"^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$") 8 | 9 | def ipstr2int(s): 10 | res = ipv4_re.match(s) 11 | if res is None: 12 | raise ValueError('Invalid IPv4 format: {!r}'.format(s)) 13 | a1, a2, a3, a4 = res.groups() 14 | # Check if octets are between 0 and 255 is omitted for better performance 15 | return int(a1) << 24 | int(a2) << 16 | int(a3) << 8 | int(a4) 16 | 17 | def int2ipstr(i): 18 | return '.'.join((str(i >> 24), str((i >> 16) & 0xff), str((i >> 8) & 0xff), str(i & 0xff))) 19 | 20 | 21 | # Regex for RFC 3339 time format 22 | timestamp_re = re.compile(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt ]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?([Zz]|(?:[+-][0-9]{2}:[0-9]{2}))$") 23 | 24 | def parse_rfc_time(time_str): 25 | """Parse time in RFC 3339 format and return it as naive datetime in UTC.""" 26 | res = timestamp_re.match(time_str) 27 | if res is not None: 28 | year, month, day, hour, minute, second = (int(n or 0) for n in res.group(*range(1, 7))) 29 | us_str = (res.group(7) or "0")[:6].ljust(6, "0") 30 | us = int(us_str) 31 | zonestr = res.group(8) 32 | zoneoffset = 0 if zonestr in ('z', 'Z') else int(zonestr[:3])*60 + int(zonestr[4:6]) 33 | zonediff = datetime.timedelta(minutes=zoneoffset) 34 | return datetime.datetime(year, month, day, hour, minute, second, us) - zonediff 35 | else: 36 | raise ValueError("Wrong timestamp format") -------------------------------------------------------------------------------- /NERDweb/templates/account_info.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 |

Account information

5 | 6 | 7 |

Name: {{ user.get('name', '[unknown]') }}

8 |

User ID: {{ user.id }}

9 |

Login type: {{ user.login_type }}

10 |

Groups: {{ user.groups|join(', ') }}

11 | 12 |
13 |

API token: {{ token.value }}

14 |
15 | 16 |

API documentation

17 |
18 | 19 | {% if passwd_form %} 20 |
21 |

Change password:

22 |
23 | {{ passwd_form.csrf_token }} 24 | {{ passwd_form.old_passwd.label }} {{ passwd_form.old_passwd(size=20) }}{% if passwd_form.old_passwd.errors %} {{ '; '.join(passwd_form.old_passwd.errors) }}{% endif %}
25 | {{ passwd_form.new_passwd.label }} {{ passwd_form.new_passwd(size=20) }}{% if passwd_form.new_passwd.errors %} {{ '; '.join(passwd_form.new_passwd.errors) }}{% endif %}
26 | {{ passwd_form.new_passwd2.label }} {{ passwd_form.new_passwd2(size=20) }}{% if passwd_form.new_passwd2.errors %} {{ '; '.join(passwd_form.new_passwd2.errors) }}{% endif %}
27 | (Passwords are stored securely, hashed using bcrypt method.) 28 |
29 | {% endif %} 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /NERDweb/templates/ipblock.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 |

IP block

5 | 6 | {% if not ipblock or not ac('ipsearch') %} 7 | {# Print nothing if no IP-block was passed or insufficient permissions #} 8 | {% else %} 9 | 10 |
11 | 12 |
13 |

14 | {% if rec %}{{ "%.3f"|format(rec.rep)|replace("0.",".") if rec.rep is defined else "--" }}{% endif %} 15 | {{ ipblock }}{{ rec.name if rec.name else '' }} 16 |

17 | 18 | {% if not rec %} 19 |

Record not found in database

20 | {% else %} 21 | 22 |
23 | {% for attr,val in rec|dictsort %} 24 | {% if attr.startswith("_") and not ac('internal_attrs') %} 25 | {# pass (hide attrs starting with '_' from normal users) #} 26 | {% elif attr == "org" %} 27 |
Organization
28 |
{{ val }}
29 | {% elif attr == "ips" %} 30 |
IPs ({{val|length}})
31 |
5%} class="scrollable"{% endif %}> 32 | {% for ip in val|sort %} 33 |
{{ ip }}
34 | {% endfor %} 35 |
36 | {% elif val|is_date %} 37 |
{{ attr }}
{{ val }}
38 | {% else %} 39 |
{{ attr }}
{{ val }}
40 | {% endif %} 41 | {% endfor %} 42 | 43 |
44 | {% endif %}{# if not found #} 45 | {% endif %}{# if nothing specified #} 46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /install/configure_supervisor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Optional parameter "open" opens http port on all interfaces (default: localhost only) 3 | # Use only in development environment (Vagrant), never in production! 4 | 5 | BASEDIR=$(dirname $0) 6 | . $BASEDIR/common.sh 7 | 8 | echob "=============== Configure Supervisor ===============" 9 | 10 | open_port=0 11 | if [ "$1" = "open" ]; then 12 | open_port=1 13 | fi 14 | 15 | echob "** Copying supervisor config files **" 16 | 17 | # Copy main configuration file 18 | cp $BASEDIR/supervisord.conf /etc/nerd/supervisord.conf 19 | if [ $open_port = 1 ]; then 20 | # replace "localhost:9001" (which should be in the config file) by "*:9001" 21 | echor "WARNING: SUPERVISORD HTTP PORT IS OPEN ON ALL INTERFACES!" 22 | sed -i "s/^port=[^:]*/port=*/" /etc/nerd/supervisord.conf 23 | fi 24 | 25 | # Copy files specifying individual NERD components running under Supervisor 26 | mkdir -p /etc/nerd/supervisord.conf.d/ 27 | cp $BASEDIR/supervisord.conf.d/* /etc/nerd/supervisord.conf.d/ 28 | 29 | chown -R nerd:nerd /etc/nerd/supervisord.conf 30 | chown -R nerd:nerd /etc/nerd/supervisord.conf.d/ 31 | 32 | echob "** Create nerdctl script **" 33 | echob "'nerdctl' is an alias for 'supervisorctl' with NERD configuration file" 34 | 35 | echo '#!/bin/sh 36 | supervisorctl -c /etc/nerd/supervisord.conf $@' >/usr/bin/nerdctl 37 | chmod +x /usr/bin/nerdctl 38 | 39 | echob "** Set up supervisord systemd unit **" 40 | 41 | cp $BASEDIR/nerd-supervisor.service /etc/systemd/system/nerd-supervisor.service 42 | systemctl daemon-reload 43 | #systemctl enable nerd-supervisor 44 | #systemctl restart nerd-supervisor 45 | 46 | echoy "** TO RUN NERD, START ITS SUPERVISOR: systemctl start nerd-supervisor" 47 | -------------------------------------------------------------------------------- /NERDweb/templates/org.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 |

Organization

5 | 6 | {% if not org or not ac('ipsearch') %} 7 | {# Print nothing if no org was passed or insufficient permissions #} 8 | {% else %} 9 | 10 |
11 | 12 |
13 |

{{ rec.name if rec.name else org }}{{ '('+org+')' if rec.name else '' }}

14 | 15 | {% if not rec %} 16 |

Record not found in database

17 | {% else %} 18 | 19 | 20 |
21 | {% for attr,val in rec|dictsort %} 22 | {% if attr.startswith("_") and not ac('internal_attrs') %} 23 | {# pass (hide attrs starting with '_' from normal users) #} 24 | {% elif attr == "asns" %} 25 |
Autonomous Systems ({{val|length}})
26 |
5%} class="scrollable"{% endif %}> 27 | {% for asn in val|sort %} 28 |
{{ 'AS' + asn|string }}
29 | {% endfor %} 30 |
31 | {% elif attr == "ipblocks" %} 32 |
IP Blocks ({{val|length}})
33 |
5%} class="scrollable"{% endif %}> 34 | {% for ipb in val|sort %} 35 |
{{ ipb }}
36 | {% endfor %} 37 | 38 | {% elif attr == "address" %} 39 |
{{ attr }}
{{ val|replace("\n","
"|safe) }}
40 | {% elif val|is_date %} 41 |
{{ attr }}
{{ val }}
42 | {% else %} 43 |
{{ attr }}
{{ val }}
44 | {% endif %} 45 | {% endfor %} 46 | 47 |
48 | {% endif %}{# if not found #} 49 | {% endif %}{# if nothing specified #} 50 | 51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /NERDweb/templates/bgppref.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 |

BGP prefix

5 | 6 | {% if not bgppref or not ac('ipsearch') %} 7 | {# Print nothing if no prefix was passed or insufficient permissions #} 8 | {% else %} 9 | 10 |
11 | 12 |
13 |

14 | {% if rec %}{{ "%.6f"|format(rec.rep)|replace("0.",".") if rec.rep is defined else "--" }}{% endif %} 15 | {{ bgppref }} 16 |

17 | 18 | {% if not rec %} 19 |

Record not found in database

20 | {% else %} 21 | 22 |
23 | {% for attr,val in rec|dictsort %} 24 | {% if attr.startswith("_") and not ac('internal_attrs') %} 25 | {# pass (hide attrs starting with '_' from normal users) #} 26 | {% elif attr == "asn" %} 27 |
Origin ASN(s) ({{val|length}})
28 |
5%} class="scrollable"{% endif %}> 29 | {% for asn in val|sort %} 30 |
{{ 'AS' + asn|string }}
31 | {% endfor %} 32 |
33 | {% elif attr == "ips" %} 34 |
IPs ({{val|length}})
35 |
5%} class="scrollable"{% endif %}> 36 | {% for ip in val|sort %} 37 |
{{ ip }}
38 | {% endfor %} 39 | 40 | {% elif val|is_date %} 41 |
{{ attr }}
{{ val }}
42 | {% else %} 43 |
{{ attr }}
{{ val }}
44 | {% endif %} 45 | {% endfor %} 46 | 47 |
48 | {% endif %}{# if not found #} 49 | {% endif %}{# if nothing specified #} 50 | 51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /scripts/add_user.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$#" -ne 5 ]]; then 4 | echo "Create a local user in NERD user database." 5 | echo "Usage:" 6 | echo " $0 username list,of,groups name email org" 7 | echo 8 | echo "Username should be prefixed with 'local:' or 'shibboleth:'" 9 | echo "Groups are plain strings separated by commas, e.g.: registered,trusted" 10 | exit 1 11 | fi 12 | 13 | die () { echo "Exiting due to error"; exit 2; } 14 | 15 | username=$1 16 | groups=$2 17 | name=$3 18 | email=$4 19 | org=$5 20 | 21 | groups_for_sql="{\"$(sed 's/, */","/g' <<<"$groups")\"}" 22 | 23 | echo "Adding a new user with the following parameters:" 24 | echo " Username: $username" 25 | echo " Groups: $groups_for_sql" 26 | echo " Full name: $name" 27 | echo " Email: $email" 28 | echo " Organization: $org" 29 | echo 30 | if [[ $username =~ ^local: ]]; then 31 | read -p "Enter password for the new user (or leave empty to generate a random one): " pass 32 | echo 33 | if [[ "$pass" == "" ]]; then 34 | pass="$(/dev/null; then 6 | echo "(Re)configure RabbitMQ exchanges and queues for NERD workers." >&2 7 | echo "Number of workers must be a non-negative integer." >&2 8 | echo "Zero means to remove all NERD exchanges and queues." >&2 9 | echo >&2 10 | echo "Usage: $0 number_of_workers" >&2 11 | exit 1 12 | fi 13 | 14 | N=$1 15 | 16 | echo "** Removing all NERD exchanges and queues **" 17 | 18 | exchange_list=$(rabbitmqadmin list exchanges name -f tsv | grep "^nerd-.*-task-exchange") 19 | for q in $exchange_list 20 | do 21 | rabbitmqadmin delete exchange name=$q 22 | done 23 | 24 | queue_list=$(rabbitmqadmin list queues name -f tsv | grep "^nerd-worker-") 25 | for q in $queue_list 26 | do 27 | rabbitmqadmin delete queue name=$q 28 | done 29 | 30 | if [[ "$N" -eq 0 ]]; then 31 | exit 0 32 | fi 33 | 34 | echo "** Setting up exchanges and queues for $N workers **" 35 | 36 | # Declare exchanges (normal and priority) 37 | rabbitmqadmin declare exchange name=nerd-main-task-exchange type=direct durable=true 38 | rabbitmqadmin declare exchange name=nerd-priority-task-exchange type=direct durable=true 39 | 40 | # Declare queues for N workers 41 | for i in $(seq 0 $(($N-1))) 42 | do 43 | rabbitmqadmin declare queue name=nerd-worker-$i durable=true 'arguments={"x-max-length": 100, "x-overflow": "reject-publish"}' 44 | rabbitmqadmin declare queue name=nerd-worker-$i-pri durable=true 45 | done 46 | 47 | # Bind queues to exchanges 48 | for i in $(seq 0 $(($N-1))) 49 | do 50 | rabbitmqadmin declare binding source=nerd-main-task-exchange destination=nerd-worker-$i routing_key=$i 51 | rabbitmqadmin declare binding source=nerd-priority-task-exchange destination=nerd-worker-$i-pri routing_key=$i 52 | done 53 | -------------------------------------------------------------------------------- /install/install_configure_munin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Install and configure Munin to monitor the host and some metrics of NERD processing. 3 | # 4 | # Note: 5 | # - munin is the master, which queries status of (potentially multiple) hosts 6 | # - munin-node runs on the host and provides provides information to the maser 7 | # In this case, the same host runs both munin (master) and munin-node 8 | 9 | BASEDIR=$(dirname $0) 10 | . $BASEDIR/common.sh 11 | 12 | echob "=============== Install & configure Munin ===============" 13 | 14 | yum -y -q install munin munin-node 15 | 16 | # ** Configure munin-node ** 17 | # Allow connections from localhost only 18 | sed -i -e 's/^host \*$/# host \*/' -e 's/^#\s*host 127.0.0.1/host 127.0.0.1/' /etc/munin/munin-node.conf 19 | 20 | # Install and enable NERD plugins 21 | cp $BASEDIR/munin/* /usr/share/munin/plugins/ 22 | chmod +x /usr/share/munin/plugins/nerd_* 23 | ln -s /usr/share/munin/plugins/nerd_* /etc/munin/plugins/ 24 | # except nerd_mongo_rs, since Mongo Replica-set is not configured by default - remove the symlink to disable plugin 25 | rm /etc/munin/plugins/nerd_mongo_rs 26 | 27 | # Enable Apache and named (BIND) plugins 28 | ln -s /usr/share/munin/plugins/apache_* /etc/munin/plugins/ 29 | ln -s /usr/share/munin/plugins/named /etc/munin/plugins/ 30 | 31 | # Enable & run munin-node 32 | systemctl enable munin-node 33 | systemctl restart munin-node 34 | 35 | # Enable & run munin (a script has to be run periodically, older versions were run by cron, now it's done using systemd timer) 36 | systemctl enable munin.timer 37 | systemctl start munin.timer 38 | 39 | # ** Enable web access ** 40 | # Copy prepared config file for Apache 41 | cp $BASEDIR/httpd/munin.conf /etc/httpd/conf.d/munin.conf 42 | 43 | touch /etc/munin/munin-htpasswd 44 | 45 | systemctl reload httpd 46 | 47 | echoy 48 | echoy "INFO: Munin is available at http:///munin/ (if NERD is not installed into \"/\")" 49 | echoy "INFO: To enable access, add an account to /etc/munin/munin-htpasswd:" 50 | echoy "INFO: htpasswd -B /etc/munin/munin-htpasswd username" 51 | 52 | -------------------------------------------------------------------------------- /install/install_warden_filer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Install warden_filer into /data 3 | 4 | BASEDIR=$(dirname $0) 5 | . $BASEDIR/common.sh 6 | 7 | echob "=============== Install Warden filer ===============" 8 | 9 | echob "** Installing Warden client Python library **" 10 | if ! [ -f /usr/lib/python3*/site-packages/warden_client.py ] ; then 11 | # Download and extract into Python3 site-packages 12 | wget -q https://homeproj.cesnet.cz/tar/warden/warden_client_3.0-beta3.tar.bz2 13 | tar -xjf warden_client_3.0-beta3.tar.bz2 14 | cp warden_client_3.0-beta3/warden_client.py /usr/lib/python3*/site-packages/ 15 | rm -rf warden_client_3.0-beta3 warden_client_3.0-beta3.tar.bz2 16 | fi 17 | 18 | echob "** Installing Warden filer **" 19 | if ! [ -d /opt/warden_filer ] ; then 20 | # Download and extract to /opt 21 | wget -q https://homeproj.cesnet.cz/tar/warden/warden_filer_3.0-beta3.tar.bz2 22 | tar -xjf warden_filer_3.0-beta3.tar.bz2 23 | mkdir -p /opt/warden_filer 24 | cp warden_filer_3.0-beta3/* /opt/warden_filer/ 25 | rm -rf warden_filer_3.0-beta3.tar.bz2 warden_filer_3.0-beta3 26 | fi 27 | 28 | echob "** Preparing directory structure for Warden filer receiver **" 29 | # directory for incoming IDEA files 30 | mkdir -p /data/warden_filer/warden_receiver/{incoming,temp,errors} 31 | chown -R nerd:nerd /data/warden_filer 32 | chmod -R 775 /data/warden_filer 33 | 34 | # Start receiving from the last message available at the time of first start 35 | echo -1 >/data/warden_filer/warden_filer.id 36 | 37 | echob "** Preparing template configuration file **" 38 | install -o nerd -g nerd -m 664 $BASEDIR/warden_filer.cfg.template /etc/nerd/warden_filer.cfg.template 39 | echoy 40 | echoy "!!! WARDEN CLIENT MUST BE MANUALLY CONFIGURED !!!" 41 | echoy "!! Before it can be run, do:" 42 | echoy "!! - Register the client at a Warden server" 43 | echoy "!! - Get client certificates (use warden_apply.sh available at Warden website)" 44 | echoy "!! - Fill 'url', 'name' and path to certs in '/etc/nerd/warden_filer.cfg.template'." 45 | echoy "!! - Rename 'warden_filer.cfg.template' to 'warden_filer.cfg'." 46 | echoy 47 | -------------------------------------------------------------------------------- /install/create_warden_db.sql: -------------------------------------------------------------------------------- 1 | ------ event database (IDEA messages) ------ 2 | CREATE TABLE IF NOT EXISTS events ( 3 | id VARCHAR PRIMARY KEY, 4 | sources inet[], -- list of IP addresses or CIDR ranges (TODO: if "from-to" range is in IDEA, it's converted to a set of CIDR ranges) 5 | targets inet[], 6 | detecttime timestamp NOT NULL, -- DetectTime 7 | starttime timestamp, -- EventTime or WinStartTime 8 | endtime timestamp, -- CeaseTime or WinEndTime 9 | idea jsonb NOT NULL 10 | ); 11 | 12 | --CREATE INDEX IF NOT EXISTS sources_idx ON events USING GIN (sources); 13 | --CREATE INDEX IF NOT EXISTS targets_idx ON events USING GIN (targets); 14 | CREATE INDEX IF NOT EXISTS detecttime_idx ON events (detecttime DESC); 15 | CREATE INDEX IF NOT EXISTS category_idx ON events USING GIN ((idea -> 'Category')); 16 | 17 | 18 | CREATE TABLE IF NOT EXISTS events_sources ( 19 | source_ip inet NOT NULL, 20 | -- source_tags VARCHAR[] DEFAULT NULL, 21 | message_id VARCHAR NOT NULL REFERENCES events (id) ON DELETE CASCADE, 22 | detecttime timestamp 23 | ); 24 | CREATE INDEX IF NOT EXISTS events_sources_message_id_idx ON events_sources (message_id); 25 | CREATE INDEX IF NOT EXISTS events_sources_ip_time_idx ON events_sources (source_ip,detecttime DESC); 26 | CREATE INDEX IF NOT EXISTS events_sources_time_idx ON events_sources (detecttime DESC); 27 | 28 | CREATE TABLE IF NOT EXISTS events_targets ( 29 | target_ip inet NOT NULL, 30 | -- target_tags VARCHAR[] DEFAULT NULL, 31 | message_id VARCHAR NOT NULL REFERENCES events (id) ON DELETE CASCADE, 32 | detecttime timestamp 33 | ); 34 | CREATE INDEX IF NOT EXISTS events_targets_message_id_idx ON events_targets (message_id); 35 | CREATE INDEX IF NOT EXISTS events_targets_ip_time_idx ON events_targets (target_ip,detecttime DESC); 36 | CREATE INDEX IF NOT EXISTS events_targets_time_idx ON events_targets (detecttime DESC); 37 | 38 | -- Query: 39 | -- SELECT e.idea FROM events_sources as es INNER JOIN events as e ON es.message_id = e.id WHERE es.source_ip = %s ORDER BY es.detecttime DESC LIMIT %s 40 | 41 | -------------------------------------------------------------------------------- /install/httpd/nerd-debug.conf: -------------------------------------------------------------------------------- 1 | # NERDweb (Flask app) configuration for Apache 2 | # (development/debug version - DON'T USE IN PRODCTION!) 3 | 4 | Define NERDBaseLoc / 5 | Define NERDBaseLocS / 6 | Define NERDBaseDir /nerd/NERDweb 7 | 8 | # Set up WSGI script (use debug version of the script for development/debugging) 9 | WSGIDaemonProcess nerd_wsgi python-path=${NERDBaseDir} 10 | WSGIScriptAlias ${NERDBaseLoc} ${NERDBaseDir}/wsgi-debug.py 11 | 12 | 13 | WSGIProcessGroup nerd_wsgi 14 | 15 | 16 | 17 | 18 | Require all granted 19 | 20 | 21 | 22 | # Static files must be served directly by Apache, not by Flask 23 | Alias ${NERDBaseLocS}static/ ${NERDBaseDir}/static/ 24 | 25 | Require all granted 26 | # Remove timestamps from .css and .js files, which are added to the 27 | # filenames to force refresh of the files whenever they're modified. 28 | # (URLs are generated to point to e.g. /static/style.1234567890.css, 29 | # where the number is file modification time) 30 | RewriteEngine on 31 | RewriteBase ${NERDBaseLocS}static/ 32 | RewriteRule ^(.*)\.[\d]{10}\.(css|js)$ $1.$2 [L] 33 | #LogLevel alert rewrite:trace3 34 | 35 | 36 | # Authentication using local accounts 37 | 38 | AuthType basic 39 | AuthName "NERD web" 40 | AuthUserFile "/etc/nerd/htpasswd" 41 | Require valid-user 42 | 43 | 44 | # Authentication using Shibboleth 45 | # 46 | # AuthType shibboleth 47 | # ShibRequestSetting requireSession 1 48 | # Require shib-session 49 | # 50 | 51 | # API handlers 52 | 53 | # Pass Authorization header 54 | WSGIPassAuthorization On 55 | # Return JSON-formatted error message in case something goes wrong. 56 | ErrorDocument 500 "{\"err_n\": 500, \"error\": \"Internal Server Error\"}" 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /NERDweb/templates/asn.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 |

Autonomous system number (ASN)

5 | 6 | {# Note: The onsubmit script overrides funcionality of Submit button to put IP address to custom URL #} 7 |
8 | {{ form.asn.label }} {{ form.asn(size=10) }} 9 | {{ form.asn.errors|join(';') }} 10 | 11 |
12 | 13 | {% if not asn or not ac('assearch') %} 14 | {# Print nothing if no ASN was passed #} 15 | {% else %} 16 | 17 |
18 | 19 |
20 |

AS{{ asn }} {{ rec.name }}

21 | 22 | {% if not rec %} 23 |

Record not found in database

24 | {% else %} 25 | 26 |
27 | {% for attr,val in rec|dictsort %} 28 | {% if attr.startswith("_") and not ac('internal_attrs') %} 29 | {# pass (hide attrs starting with '_' from normal users) #} 30 | {% elif attr == "ctry" %} 31 |
Country
32 | 33 | {{ ctrydata.names.get(val, '?') }}
34 | {% elif attr == "rir" %} 35 |
RIR
{{ val|upper }}
36 | {% elif attr == "bgppref" %} 37 |
BGP Prefixes ({{val|length}})
38 |
5%} class="scrollable"{% endif %}> 39 | {% for pref in val|sort %} 40 |
{{ pref }}
41 | {% endfor %} 42 |
43 | {% elif attr == "org" %} 44 |
Organization
45 |
{{ val }}
46 | {% elif val|is_date %} 47 |
{{ attr }}
{{ val }}
48 | {% else %} 49 |
{{ attr }}
{{ val }}
50 | {% endif %} 51 | {% endfor %} 52 | 53 | 54 |
55 | {% endif %}{# if not found #} 56 | {% endif %}{# if nothing specified #} 57 | 58 | {% endblock %} 59 | -------------------------------------------------------------------------------- /client-scripts/nerd.nse: -------------------------------------------------------------------------------- 1 | local http = require "http" 2 | local ipOps = require "ipOps" 3 | local io = require "io" 4 | local stdnse = require "stdnse" 5 | local json = require "json" 6 | 7 | 8 | description = [[ 9 | Looks up info about the target in the NERD system. 10 | ]] 11 | 12 | --- 13 | -- @args nerd Takes the following optional argument: 14 | -- * nerd.apifile=file Path to file with NERD API key, default is ./nerdapifile. 15 | -- @usage 16 | -- # Basic usage: 17 | -- nmap target --script nerd 18 | -- nmap target --script nerd --script-args nerd.apifile=/home/user/apifile 19 | -- 20 | -- @output 21 | -- Host script results: 22 | -- |_nerd: IP not found in NERD 23 | -- 24 | -- Host script results: 25 | -- |_nerd: {"asn": [], "bgppref": "", "bl": [], "geo": {"ctry": "CZ"}, "hostname": "ns.cesnet.cz", "ip": "195.113.144.194", "ipblock": "", "rep": 0.2, "tags": []} 26 | 27 | author = "Tomas Cejka " 28 | license = "Same as Nmap--See https://nmap.org/book/man-legal.html" 29 | categories = {"external"} 30 | 31 | hostrule = function( host ) 32 | local is_private, err = ipOps.isPrivate( host.ip ) 33 | if is_private == nil then 34 | stdnse.debug1("Error in Hostrule: %s.", err) 35 | return false 36 | end 37 | return not is_private 38 | end 39 | 40 | function load_key() 41 | local file = nil 42 | local apifile = stdnse.get_script_args('nerd.apifile') 43 | if type( apifile ) ~= "string" or apifile == "" then 44 | apifile = "nerdapifile" 45 | end 46 | file = io.input(apifile) 47 | 48 | if file then 49 | local content = file:read "l" 50 | file:close() 51 | return content 52 | else 53 | return nil 54 | end 55 | end 56 | 57 | action = function( host ) 58 | local apitoken = load_key() 59 | local header = {header={Authorization= "token " .. apitoken}} 60 | local resp = http.get_url("https://nerd.cesnet.cz/nerd/api/v1/ip/" .. host.ip, header) 61 | local content = resp.body 62 | local status, parsed = json.parse(content) 63 | if not(status) or parsed.err_n == 404 then 64 | return "IP not found in NERD" 65 | end 66 | return content 67 | end 68 | 69 | -------------------------------------------------------------------------------- /NERDweb/static/ip_poll.js: -------------------------------------------------------------------------------- 1 | // Functions to request cretion of a new IP address record, wait until data are ready and then reloads the page (ip.html) 2 | 3 | var pollInterval; 4 | var queryCounter; 5 | 6 | function show_error(message){ 7 | var elem = $(".notfound-fetching"); 8 | elem.removeClass().addClass("notfound-error"); 9 | elem.text(message); 10 | elem.css("display", "block"); 11 | } 12 | 13 | function request_ip_data(url, poll_url) { 14 | // Request creation of a temporary record for the IP 15 | fetch(url) 16 | .then(function(response) { 17 | if (response.ok) { 18 | // Show information that the data are being fetched 19 | $(".notfound-fetching").css("display", "block"); 20 | // Start polling for the data prepared (every second) 21 | pollInterval = setInterval(_poll, 1000, poll_url); 22 | queryCounter = 0; 23 | } 24 | else if (response.status == 429) { 25 | show_error("ERROR: Can't fetch IP data, rate limit exceeded."); 26 | } 27 | else { 28 | console.error("Unexpected reply from " + url + ": ", response); 29 | } 30 | }) 31 | .catch(function(error){ 32 | console.error("Error when querying " + url + ": ", error); 33 | }); 34 | } 35 | 36 | function _poll(url){ 37 | fetch(url) 38 | .then((response) => response.text()) 39 | .then(function(response) { 40 | if (response === "true") { 41 | location.reload(); 42 | } 43 | else if (queryCounter > 30) { // stop polling after 30 seconds 44 | clearInterval(pollInterval); 45 | show_error("Timeout - backend is probably overloaded or stopped for maintenance. Try again later."); 46 | } 47 | queryCounter++; 48 | }) 49 | .catch(function(error){ 50 | clearInterval(pollInterval); 51 | console.error("Error when polling _is_prepared:", error); 52 | show_error("Error while trying to load the data!"); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /NERDweb/templates/noaccount.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 | {% if user %} 5 |
6 | 7 | 35 |
36 | 37 | {% else %} 38 |

ERROR: This should be page for authenticated but not registered users, but no user info was found in session.

39 | {% endif %} 40 | 41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /install/cron/nerd: -------------------------------------------------------------------------------- 1 | #MAILTO=someone@example.org 2 | 3 | # Update metainformation about numbers of IPs with particular event Category and Node 4 | */30 * * * * nerd mongosh --quiet nerd /nerd/scripts/update_db_meta_info.js 5 | # Compute reputation scores of BGP prefixes once an hour 6 | 55 * * * * nerd mongosh --quiet nerd /nerd/scripts/set_prefix_repscore.js 7 | 8 | # Generate list of IPs and reputation scores every hour 9 | # (generate to a temp file and rename to avoid clients reading an incomplete file) 10 | 00 * * * * nerd /nerd/scripts/generate_ip_rep_list.sh > /data/web_data/ip_rep.csv.tmp && mv /data/web_data/ip_rep.csv{.tmp,} 11 | 00 * * * * nerd /nerd/scripts/generate_blocklist.sh 0.5 | sort -n > /data/web_data/bad_ips.txt.tmp && mv /data/web_data/bad_ips.txt{.tmp,} 12 | 00 * * * * nerd /nerd/scripts/generate_blocklist.sh 0.2 | sort -n > /data/web_data/bad_ips_med_conf.txt.tmp && mv /data/web_data/bad_ips_med_conf.txt{.tmp,} 13 | 14 | # Remove old IDEA messages from PostgreSQL every day at 03:00 15 | # (enable if local PSQL is used to store alerts from Warden) 16 | #0 03 * * * nerd /nerd/scripts/nerd_clean_eventdb.sh > /dev/null 17 | 18 | # Download GeoIP database every Tuesday evening 19 | # Fill API_KEY and uncomment 20 | # TODO: It's probably needed to somehow notify NERDd that it needs to reload the database 21 | #10 23 * * 2 nerd /nerd/scripts/download_maxmind_geolite.sh #FILL_THE_API_KEY_HERE_AND_UNCOMMENT 22 | 23 | # rsync Uceprotect blacklist 3 times a day 24 | 40 01,09,17 * * * nerd rsync -azq rsync-mirrors.uceprotect.net::RBLDNSD-ALL/dnsbl-1.uceprotect.net /data/blacklists/uceprotect-level1 25 | # rsync PSBL blacklist 3 times a day 26 | 41 01,09,17 * * * nerd rsync -zq psbl-mirror.surriel.com::psbl/psbl.txt /data/blacklists/psbl.txt 27 | 28 | # Export Crowdsec community blacklist to CSV 29 | # (enable if you're using Crowdsec) 30 | #42 01,09,17 * * * nerd sqlite3 -csv /opt/crowdsec/data/crowdsec.db "SELECT value, scenario FROM decisions WHERE origin='CAPI';" > /data/blacklists/crowdsec.csv 31 | 32 | # Check Apache log for 5xx errors every hour. If grep produces output, it's sent to the email contact. 33 | # Run at the end of every hour and simply filter all log lines with the current hour (not perfect, but simple) 34 | #59 * * * * root egrep "$(date +\%d/\%b/\%Y:\%H).* 5[0-9][0-9] [0-9]+" /var/log/httpd/ssl_access_log -------------------------------------------------------------------------------- /scripts/download_maxmind_geolite.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Download MaxMind's GeoLite2 database to /data/geoip/GeoLite2-City.mmdb 4 | # 5 | # MaxMind no longer allows to download the data without registration. 6 | # Therefore, this script must be called manually with licence key passed. 7 | # 8 | # Usage: 9 | # ./donwload_maxmind_geolite.sh LICENCE_KEY 10 | # 11 | # 12 | 13 | KEY=$1 14 | if [ -z "$KEY" ]; then 15 | echo "Licence key is needed (just register for free on maxmind.com)." >&2 16 | echo "Usage:" >&2 17 | echo " ./download_maxmind_geolite.sh LICENCE_KEY" >&2 18 | echo "Run as user 'nerd' or root." >&2 19 | exit 1 20 | fi 21 | 22 | user=$(whoami) 23 | if [[ "$user" != "nerd" && "$user" != "root" ]]; then 24 | echo "Run as user 'nerd' or root." >&2 25 | exit 2 26 | fi 27 | 28 | # exit when any command fails 29 | set -e 30 | 31 | echo "Downloading MaxMind GeoLite2 database to /data/geoip/GeoLite2-City.mmdb" 32 | # Needed by geolocation module 33 | mkdir -p /data/geoip 34 | cd /data/geoip 35 | # Download archive using the licence key 36 | wget -q "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${KEY}&suffix=tar.gz" -O GeoLite2-City.tar.gz 37 | # The archive contains a directory named by creation date, containing the DB 38 | # and some txt file - extract just the DB file (*/GeoLite2-City.mmdb) to 39 | # current dir 40 | tar -xzf GeoLite2-City.tar.gz '*/GeoLite2-City.mmdb' --strip-components=1 41 | 42 | echo "Downloading MaxMind GeoLite2 databases (CSV) to /data/geoip/GeoLite2-*.csv" 43 | # Needed by FMP computation scripts 44 | # three files needed: GeoLite2-ASN-Blocks-IPv4.csv, GeoLite2-Country-Blocks-IPv4.csv, GeoLite2-Country-Locations-en.csv 45 | wget -q "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN-CSV&license_key=${KEY}&suffix=zip" -O GeoLite2-ASN-CSV.zip 46 | unzip -joq GeoLite2-ASN-CSV.zip 'GeoLite2-ASN-CSV_*/GeoLite2-ASN-Blocks-IPv4.csv' 47 | wget -q "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country-CSV&license_key=${KEY}&suffix=zip" -O GeoLite2-Country-CSV.zip 48 | unzip -joq GeoLite2-Country-CSV.zip 'GeoLite2-Country-CSV_*/GeoLite2-Country-Blocks-IPv4.csv' 'GeoLite2-Country-CSV_*/GeoLite2-Country-Locations-en.csv' 49 | 50 | if [[ "$user" == "root" ]]; then 51 | echo "Setting ownership to 'nerd' account" 52 | chown -R nerd:nerd /data/geoip 53 | fi 54 | 55 | echo "Done" 56 | -------------------------------------------------------------------------------- /NERDd/modules/eml_asn_rank.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module querying ASN ranking API of "The Email Laundry (EML)" company. 3 | """ 4 | 5 | from core.basemodule import NERDModule 6 | import g 7 | 8 | import logging 9 | import requests 10 | 11 | class EML_ASN_rank(NERDModule): 12 | """ 13 | EML ASN rank module. 14 | 15 | Queries newly added ASNs in EML's API for their ASN rank. 16 | 17 | Event flow specification: 18 | asn: !NEW -> get_rank -> eml_rank 19 | """ 20 | 21 | def __init__(self): 22 | self.log = logging.getLogger('EML_ASN_rank') 23 | #self.log.setLevel("DEBUG") 24 | self.url = g.config.get('eml_api.url', None) 25 | self.apikey = g.config.get('eml_api.key', None) 26 | if not self.url or not self.apikey: 27 | self.log.warning("API URL or key not set, EML ASN rank module disabled.") 28 | return 29 | self.log.debug("EML ASN Rank module initialized") 30 | 31 | g.um.register_handler( 32 | self.get_rank, 33 | 'asn', 34 | ('!NEW','every1d','!refresh_eml_rank'), 35 | ('eml_rank',) 36 | ) 37 | 38 | def get_rank(self, ekey, rec, updates): 39 | """ 40 | Query the EML API to get ASN rank. 41 | 42 | Arguments: 43 | ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 44 | rec -- record currently assigned to the key 45 | updates -- list of all attributes whose update triggered this call and 46 | their new values (or events and their parameters) as a list of 47 | 2-tuples: [(attr, val), (!event, param), ...] 48 | 49 | 50 | Returns: 51 | List of update requests. 52 | """ 53 | etype, key = ekey 54 | if etype != 'asn': 55 | return None 56 | 57 | try: 58 | r = requests.get('{}asn/{}?key={}'.format(self.url, key, self.apikey)) 59 | r.raise_for_status() 60 | data = r.json() 61 | rank = float(data['asnrankinfo']['asnrank']) 62 | #self.log.debug("Setting EML ASN Rank of {} to {}".format(key, rank)) 63 | except Exception as e: 64 | self.log.error("Can't get rank for AS{}: {}".format(key, repr(e))) 65 | return None 66 | 67 | return [('set', 'eml_rank', rank)] 68 | -------------------------------------------------------------------------------- /NERDweb/templates/data.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | 4 |

Downloadable data

5 | 6 |
    7 |
  • List of all IPs in NERD database with their reputation scores: 8 | {% if file_sizes['ip_rep.csv'] is number %}ip_rep.csv ({{file_sizes['ip_rep.csv']|filesizeformat}}){% else %}ERROR File not found{% endif %} 9 |
    • Don't use this file as a blocklist as is! It contains every single IP reported as malicious by some of our data sources and it probably contains some false positives (FP). Look at the associated reputation score, which summarizes the number of alerts, their age, and number of different sources that reported the IP. Higher score means lower probability of FP. So, if this data is to be used as a blocklist, we recommend to select only the IPs with reputation score greater than some threshold. Or use one of the files below, instead.
    10 |
  • 11 |
  • List of malicious IPs (high confidence): 12 | {% if file_sizes['bad_ips.txt'] is number %}bad_ips.txt ({{file_sizes['bad_ips.txt']|filesizeformat}}){% else %}ERROR File not found{% endif %} 13 |
    • IP addresses with reputation score greater than 0.5 (means many recent reports from multiple sources). Very low chance of false positives. IPs tagged as research scanners are not included.
    14 |
  • 15 |
  • List of malicious IPs (medium confidence): 16 | {% if file_sizes['bad_ips_med_conf.txt'] is number %}bad_ips_med_conf.txt ({{file_sizes['bad_ips_med_conf.txt']|filesizeformat}}){% else %}ERROR File not found{% endif %} 17 |
    • IP addresses with reputation score greater than 0.2. This also includes less active IPs or those reported by just one source (but still multiple recent alerts are needed). Mostly reliable, but may contain a few false positives. IPs tagged as research scanners are not included.
    18 |
  • 19 |
20 | 21 | All files are updated once per hour. Please, don't download the data more often than once per hour!
We recommend downloading it few minutes after a whole hour (i.e. between xx:01 and xx:05). 22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /NERDd/core/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD entity database wrapper. 3 | 4 | Provides EntityDatabase class -- an abstract layer above the database system 5 | implementing entity database. 6 | """ 7 | 8 | # import mongo 9 | 10 | class UnknownEntityType(ValueError): 11 | pass 12 | 13 | class EntityDatabase: 14 | """ 15 | Abstract layer above the entity database. It provides an interface for 16 | database operations independet on underlying database system. 17 | 18 | This is a trivial in-memory database based on Python dict - useful only for 19 | debugging/testing. 20 | """ 21 | # List of known/supported entity types - currently only IP addresses (both IPv4 and IPv6 are treated the same) 22 | _supportedTypes = ['ip'] 23 | 24 | def __init__(self, config): 25 | """ 26 | Connect to database and initialize all internal structures as neccessary. 27 | """ 28 | self._db = {'ip': {}} 29 | 30 | # def __del__(self): 31 | # """ 32 | # Destructor. Close connection to database. 33 | # """ 34 | # pass 35 | 36 | def getEntityTypes(self): 37 | """ 38 | Return list of known entity types. 39 | 40 | Currently only 'ip' type is supported. 41 | """ 42 | return self._supportedTypes 43 | 44 | 45 | def get(self, etype, key): 46 | """ 47 | Return record of given entity. 48 | 49 | Arguments: 50 | etype entity type (str), e.g. 'ip' 51 | key entity identifier (str), e.g. '192.0.2.42' 52 | 53 | Return the record as JSON document or None if it is not present in the database. 54 | 55 | Raise UnknownEntityType if there is not database collection for given etype. 56 | """ 57 | if etype not in self._supportedTypes: 58 | raise UnknownEntityType("There is no collection for entity type "+str(etype)) 59 | 60 | return self._db[etype].get(key, None) 61 | 62 | def put(self, etype, key, record): 63 | """ 64 | Store a record into the database or replace old with a new one. 65 | 66 | Arguments: 67 | etype entity type (str), e.g. 'ip' 68 | key entity identifier (str), e.g. '192.0.2.42' 69 | record JSON document with properties of the entity to be stored in DB 70 | """ 71 | self._db[etype][key] = record 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /NERDd/core/scheduler.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD scheduler - allows modules to register functions (callables) to be run at 3 | specified times or intervals (like cron does). 4 | 5 | Based on APScheduler package 6 | """ 7 | 8 | import logging 9 | from apscheduler.schedulers.background import BackgroundScheduler 10 | from apscheduler.triggers.cron import CronTrigger 11 | 12 | class Scheduler(): 13 | """ 14 | NERD scheduler - allows modules to register functions (callables) to be run 15 | at specified times or intervals (like cron does). 16 | """ 17 | def __init__(self): 18 | self.log = logging.getLogger("Scheduler") 19 | #self.log.setLevel("DEBUG") 20 | logging.getLogger("apscheduler.scheduler").setLevel("WARNING") 21 | logging.getLogger("apscheduler.executors.default").setLevel("WARNING") 22 | self.sched = BackgroundScheduler(timezone="UTC") 23 | self.last_job_id = 0 24 | 25 | def start(self): 26 | self.log.debug("Scheduler start") 27 | self.sched.start() 28 | 29 | def stop(self): 30 | self.log.debug("Scheduler stop") 31 | self.sched.shutdown() 32 | 33 | def register(self, func, year=None, month=None, day=None, week=None, 34 | day_of_week=None, hour=None, minute=None, second=None, timezone="UTC", 35 | args=None, kwargs=None): 36 | """ 37 | Register a function to be run at specified times. 38 | 39 | func - function or method to be called 40 | year,month,day,week,day_of_week,hour,minute,second - 41 | cron-like specification of when the function should be called, 42 | see docs of apscheduler.triggers.cron for details 43 | https://apscheduler.readthedocs.io/en/latest/modules/triggers/cron.html 44 | timezone - Timezone for time specification (default is UTC). 45 | args, kwargs - arguments passed to func 46 | 47 | Return job ID (integer). 48 | """ 49 | self.last_job_id += 1 50 | trigger = CronTrigger(year, month, day, week, day_of_week, hour, minute, 51 | second, timezone=timezone) 52 | self.sched.add_job(func, trigger, args, kwargs, coalesce=True, max_instances=1, id=str(self.last_job_id)) 53 | self.log.debug("Registered function {0} to be called at {1}".format(func.__qualname__, trigger)) 54 | return self.last_job_id 55 | 56 | def pause_job(self, id): 57 | """Pause job with given ID""" 58 | self.sched.pause_job(str(id)) 59 | 60 | def resume_job(self, id): 61 | """Resume previously paused job with given ID""" 62 | self.sched.resume_job(str(id)) 63 | 64 | -------------------------------------------------------------------------------- /NERDd/modules/test_module.py: -------------------------------------------------------------------------------- 1 | """NERD module for testing UpdateManager""" 2 | 3 | from core.basemodule import NERDModule 4 | import time 5 | 6 | class TestModule (NERDModule): 7 | """ 8 | Testing NERD module. 9 | 10 | Event flow specification: 11 | !NEW -> setType -> type, type_desc 12 | !NEW -> setA -> A 13 | A -> setB -> B 14 | A, B -> setC -> C 15 | !sleep -> sleep -> None 16 | """ 17 | 18 | def __init__(self): 19 | g.um.register_handler( 20 | self.setB, # function (or bound method) to call 21 | ('A',), # tuple/list/set of attributes to watch (their update triggers call of the registered method) 22 | ('B',) # tuple/list/set of attributes the method may change 23 | ) 24 | g.um.register_handler( 25 | self.setA, 'ip', ('!NEW',), ('A',) 26 | ) 27 | g.um.register_handler( 28 | self.setType, 'ip', ('!NEW',), ('type','type_desc') 29 | ) 30 | g.um.register_handler( 31 | self.setC, 'ip', ('A','B'), ('C') 32 | ) 33 | g.um.register_handler( 34 | self.sleep, 'ip', ('!sleep',), None 35 | ) 36 | 37 | def setB(self, ekey, rec, updates): 38 | """ 39 | Set a 'B' attribute to the value of an 'A' attribute concatenated with 40 | 'extended by B'. 41 | 42 | Arguments: 43 | etype, key -- entity type and key (e.g. 'ip', '192.0.2.42') 44 | rec -- record currently assigned to the key 45 | updates -- specification of updates that triggerd this call 46 | 3-tuple (op, attr, val) or ('event', name, param) 47 | 48 | Returns: 49 | List of 3-tuples describing requested attribute updates or events. 50 | """ 51 | return [('set', 'B', rec['A'] + ' extended by B')] 52 | 53 | def setA(self, ekey, rec, updates): 54 | return [('set', 'A', 'Value of A')] 55 | 56 | def setType(self, ekey, rec, updates): 57 | return [ 58 | ('set', 'type', ekey[0]), 59 | ('set', 'type_desc', 'This is record of type {}'.format(ekey[0])), 60 | ] 61 | 62 | def setC(self, ekey, rec, updates): 63 | update_reqs = [] 64 | for op,key,val in updates: 65 | update_reqs.append(('add_to_set', 'C', '{} changed'.format(key))) 66 | return update_reqs 67 | 68 | def sleep(self, ekey, rec, updates): 69 | for event in updates: 70 | assert(event[1] == '!sleep') 71 | print() 72 | print("Sleeping ...") 73 | print("zZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZ") 74 | print() 75 | time.sleep(event[2]) 76 | return None 77 | -------------------------------------------------------------------------------- /install/configure_apache.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Configure Apache to serve NERDweb 3 | # Pass base location without trailing slash (e.g. "/nerd" or "/") as parameter 4 | # (default is "/") 5 | # Add "-d" parameter to install in debug mode 6 | 7 | BASEDIR=$(dirname $0) 8 | . $BASEDIR/common.sh 9 | 10 | echob "=============== Configure Apache ===============" 11 | 12 | # (in case base_loc is server root, httpd needs "/", while nerd needs "") 13 | base_loc_httpd="/" # location without trailing slash (except when "/") 14 | base_loc_httpd_slash="/" # location with trailing slash 15 | base_loc_nerd="" 16 | debug=0 17 | 18 | while getopts "d" opt; do 19 | case "$opt" in 20 | d) 21 | debug=1 22 | ;; 23 | *) 24 | echo "Unknown parameter" 25 | exit 1 26 | ;; 27 | esac 28 | done 29 | shift $((OPTIND-1)) 30 | 31 | # If base_loc is set and not root directory (which is default and treated specially) 32 | if [ -n "$1" -a "$1" != "/" ] ; then 33 | base_loc_httpd="$1" 34 | base_loc_httpd_slash="$1/" 35 | base_loc_nerd="$1" 36 | fi 37 | 38 | echob "Base location: $base_loc_httpd" 39 | if [ "$debug" = 1 ] ; then 40 | echor "WARNING: SETTING UP DEBUG MODE!" 41 | fi 42 | 43 | echob "** Installing Apache and WSGI **" 44 | yum install -q -y httpd httpd-devel mod_wsgi 45 | pip3 -q install mod_wsgi 46 | 47 | # Replace the stock mod_wsgi.so with the one from Python36 48 | rm -f /usr/lib64/httpd/modules/mod_wsgi.so 49 | path="$(pip3 show mod_wsgi 2>/dev/null | sed -n '/Location: / s/Location: //p')" 50 | if [ -z "$path" ] ; then 51 | echor "ERROR: Can't find the path to mod_wsgi python package, can't create symlink to it. Apache won't start." >&2 52 | else 53 | ln -s "$path"/mod_wsgi/server/mod_wsgi-py36.*.so /usr/lib64/httpd/modules/mod_wsgi.so 54 | fi 55 | 56 | echob "** Setting up configuration files **" 57 | if [ "$debug" = 1 ] ; then 58 | cp $BASEDIR/httpd/nerd-debug.conf /etc/httpd/conf.d/nerd.conf 59 | else 60 | cp $BASEDIR/httpd/nerd.conf /etc/httpd/conf.d/nerd.conf 61 | fi 62 | # Set up base loc in both apache conf and NERD conf 63 | sed -i -E "s|^Define\s+NERDBaseLoc\s+.*$|Define NERDBaseLoc $base_loc_httpd|" /etc/httpd/conf.d/nerd.conf 64 | sed -i -E "s|^Define\s+NERDBaseLocS\s+.*$|Define NERDBaseLocS $base_loc_httpd_slash|" /etc/httpd/conf.d/nerd.conf 65 | sed -i -E "s|^base_url:.*$|base_url: \"$base_loc_nerd\"|" /etc/nerd/nerdweb.yml 66 | 67 | # Set up random "secret" number for Flask 68 | secret=$(head -c 24 /dev/urandom | base64) 69 | sed -i -E "s|^secret_key: \"!!! CHANGE THIS !!!\"|secret_key: \"$secret\"|" /etc/nerd/nerdweb.yml 70 | 71 | echob "** Setting up firewall (allow port 80, 443) **" 72 | iptables -I INPUT 1 -p TCP --dport 80 -j ACCEPT 73 | iptables -I INPUT 1 -p TCP --dport 443 -j ACCEPT 74 | iptables-save > /etc/sysconfig/iptables 75 | 76 | echob "** Starting Apache **" 77 | systemctl enable httpd 78 | systemctl restart httpd 79 | -------------------------------------------------------------------------------- /NERDweb/shodan_rpc_client.py: -------------------------------------------------------------------------------- 1 | import pika 2 | import uuid 3 | import time 4 | 5 | from common.config import read_config 6 | 7 | TIMEOUT = 10 # how many seconds to wait for RPC reply 8 | 9 | DEFAULT_RMQ_SETTINGS = { 10 | 'host': "localhost", 11 | 'port': 5672, 12 | 'virtual_host': "/", 13 | 'username': "guest", 14 | 'password': "guest" 15 | } 16 | 17 | # config - load nerd.yml 18 | config = read_config("/etc/nerd/nerd.yml") 19 | rmq_settings = DEFAULT_RMQ_SETTINGS 20 | rmq_settings.update(config.get('rabbitmq', {})) 21 | 22 | 23 | class ShodanRpcClient(object): 24 | def __init__(self): 25 | rmq_creds = pika.PlainCredentials(rmq_settings['username'], rmq_settings['password']) 26 | rmq_params = pika.ConnectionParameters(rmq_settings['host'], rmq_settings['port'], rmq_settings['virtual_host'], 27 | rmq_creds) 28 | self.connection = pika.BlockingConnection(rmq_params) 29 | self.channel = self.connection.channel() 30 | 31 | result = self.channel.queue_declare(queue='', arguments={'x-message-ttl' : 30000}, exclusive=True) 32 | self.callback_queue = result.method.queue 33 | self.response = None 34 | self.corr_id = None 35 | self.channel.basic_consume(queue=self.callback_queue, on_message_callback=self.on_response, auto_ack=True) 36 | 37 | def __del__(self): 38 | if not self.channel.connection.is_closed: 39 | try: 40 | self.channel.close() 41 | except (pika.exceptions.ConnectionClosed, pika.exceptions.StreamLostError): # for case it's been closed by the server 42 | pass 43 | 44 | def on_response(self, ch, method, props, body): 45 | if self.corr_id == props.correlation_id: 46 | self.response = body 47 | 48 | def call(self, ip): 49 | #print("(shodan_rpc_client) Request for {} received".format(str(ip))) 50 | self.response = None 51 | self.corr_id = str(uuid.uuid4()) 52 | self.channel.basic_publish(exchange='', 53 | routing_key='shodan_rpc_queue', 54 | properties=pika.BasicProperties( 55 | reply_to=self.callback_queue, 56 | correlation_id=self.corr_id, 57 | ), 58 | body=str(ip)) 59 | #print("(shodan_rpc_client) Request sent to RabbitMQ, waiting for response...") 60 | 61 | start = time.time() 62 | while self.response is None: 63 | self.connection.process_data_events(time_limit=2) 64 | if time.time() - start > TIMEOUT: 65 | return '{"error": "timeout"}' 66 | 67 | #print("(shodan_rpc_client) Response received, returning to web client") 68 | return str(self.response.decode('utf8')) 69 | -------------------------------------------------------------------------------- /NERDweb/static/status-box.js: -------------------------------------------------------------------------------- 1 | /* JS code for status-box (normally only available to admins) */ 2 | 3 | /**** Show/hide & enable/disable *****/ 4 | 5 | var refresh_status_enable = false; // Enable/disable refresh status 6 | var refreshing_status = false; // Whether refresh is in progress (to avoid multiple parallel calls it they are very slow and refresh interval is small) 7 | 8 | function toggle_status_box() { 9 | var div = $('#status-block > div'); 10 | var a = $('#status_box_toggle'); 11 | if (a.text() == "▲") { 12 | div.hide(); 13 | a.text("▼"); 14 | refresh_status_enable = false; 15 | $("#status_refresh_toggle").text("disabled"); 16 | } 17 | else { 18 | div.show(); 19 | a.text("▲"); 20 | } 21 | } 22 | 23 | function toggle_status_refresh() { 24 | if (refresh_status_enable) { 25 | refresh_status_enable=false; 26 | $("#status_refresh_toggle").text("disabled"); 27 | } 28 | else { 29 | refresh_status_enable=true; 30 | $("#status_refresh_toggle").text("enabled"); 31 | } 32 | } 33 | 34 | /***** AJAX request to get NERD status information *****/ 35 | function refresh_status(force=false) { 36 | if (!force && (!refresh_status_enable || refreshing_status)) { 37 | return; 38 | } 39 | refreshing_status = true; 40 | $("#status-block .refresh-spinner").css('visibility', 'visible'); 41 | $.getJSON( 42 | URL_GET_STATUS, 43 | function(data) { 44 | $("#status-cnt-ip").text(data.cnt_ip); 45 | $("#status-cnt-bgppref").text(data.cnt_bgppref); 46 | $("#status-cnt-asn").text(data.cnt_asn); 47 | $("#status-cnt-ipblock").text(data.cnt_ipblock); 48 | $("#status-cnt-org").text(data.cnt_org); 49 | $("#status-updates").text(data.updates_processed); 50 | $("#status-disk-usage").text(data.disk_usage); 51 | $("#status-idea-queue").text(data.idea_queue); 52 | // Set width of bar and its color 53 | var bar_width = (data.idea_queue * 100 / 10000); 54 | $("#status-idea-queue-bar div").css('width', bar_width + "%"); 55 | if (bar_width < 50) { 56 | $("#status-idea-queue-bar div").css('background-color', '#0c0'); 57 | } 58 | else if (bar_width < 75) { 59 | $("#status-idea-queue-bar div").css('background-color', '#ea0'); 60 | } 61 | else { 62 | $("#status-idea-queue-bar div").css('background-color', '#c00'); 63 | } 64 | } 65 | ) 66 | .always(function() { 67 | refreshing_status = false; 68 | $("#status-block .refresh-spinner").css('visibility', 'hidden'); 69 | }); 70 | } 71 | 72 | // Set automatic refresh every 2s 73 | var status_refresh_timer = window.setInterval(refresh_status, 2000); 74 | // Run refresh once on load 75 | $(function() { refresh_status(true) }); 76 | -------------------------------------------------------------------------------- /NERDweb/static/main.js: -------------------------------------------------------------------------------- 1 | /* NERD web - main JS code, common for whole web */ 2 | 3 | function create_event_table(data) { /* data are "dataset" field of a DOM node with "data-" attributes set */ 4 | if (data.table == "") { 5 | return "No events"; 6 | } 7 | var cats = data.cats.split(","); 8 | var dates = data.dates.split(","); 9 | var table = []; 10 | var table_rows = data.table.split(";"); 11 | for (i = 0; i < table_rows.length; i++) { 12 | table.push(table_rows[i].split(",")); 13 | } 14 | var nodes = data.nodes.split(","); 15 | 16 | var content = ""; 19 | for (i = 0; i < dates.length; i++) { 20 | content += ""; 23 | } 24 | content += "
"; 17 | content += cats.join(""); 18 | content += "
"+dates[i]+""; 21 | content += table[i].join(""); 22 | content += "
"; 25 | content += "Nodes (" + nodes.length + "): " + nodes.join(", "); 26 | return content; 27 | } 28 | 29 | $(function() { 30 | /* jQuery UI tooltip at: 31 | - country flags (with name of the country) 32 | - "events" table cells 33 | - AS number 34 | - download button 35 | - "help" notes (a span with "help" class and "title" attribute) 36 | */ 37 | $( document ).tooltip({ 38 | items: ".country [title], .asn [title], .tag[title], .help[title]", 39 | track: false, 40 | show: false, 41 | hide: false, 42 | position: {my: "left bottom", at: "left-7px top-2px", collision: "flipfit"}, 43 | content: function() { 44 | return format_dates_in_tooltip($(this).attr('title')); 45 | } 46 | }); 47 | /* jQuery UI tooltip at "events" cell with event table */ 48 | $( ".events" ).tooltip({ 49 | items: ".events", 50 | track: false, 51 | show: false, 52 | hide: false, 53 | position: {my: "left bottom", at: "left-7px top-2px", collision: "flipfit"}, 54 | content: function() { return create_event_table(this.dataset) }, /*$(".tooltip_event_table", this).html(); },*/ 55 | classes: { 56 | "ui-tooltip": "events_tooltip" 57 | } 58 | }); 59 | /* jQuery UI tooltip at times with "timeago" */ 60 | $( ".time" ).tooltip({ 61 | items: ".time", 62 | track: false, 63 | show: false, 64 | hide: false, 65 | content: function() { 66 | timestamp = $(this).data("time"); 67 | date_obj = new Date(timestamp*1000); 68 | return moment(date_obj).fromNow() 69 | }, 70 | position: {my: "left bottom", at: "left-7px top-2px", collision: "flipfit"} 71 | }); 72 | /* jQuery UI tooltip for download button and UTC switch (show title after a small delay) */ 73 | $( "button[title], #utc-switch" ).tooltip({ 74 | items: "button[title], #utc-switch", 75 | track: false, 76 | show: {"delay": 500, "duration": 0}, 77 | hide: false, 78 | position: {my: "left top", at: "left-7px bottom+2px", collision: "flipfit"}, 79 | content: function() { 80 | return $(this).attr('title'); 81 | } /* This is needed to allow HTML in tooltip text */ 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /scripts/put_task.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Script to put a single task (aka update_request) to the main NERD Task Queue.""" 3 | 4 | import os 5 | import sys 6 | import argparse 7 | import logging 8 | import json 9 | 10 | # Add to path the "one directory above the current file location" to find modules from "common" 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))) 12 | 13 | from common.config import read_config 14 | from common.task_queue import TaskQueueWriter 15 | 16 | LOGFORMAT = "%(asctime)-15s,%(name)s [%(levelname)s] %(message)s" 17 | LOGDATEFORMAT = "%Y-%m-%dT%H:%M:%S" 18 | logging.basicConfig(level=logging.INFO, format=LOGFORMAT, datefmt=LOGDATEFORMAT) 19 | 20 | logger = logging.getLogger('PutTask') 21 | 22 | # parse arguments 23 | parser = argparse.ArgumentParser( 24 | prog="put_task.py", 25 | description="Put a single task (aka update_request) to the main NERD Task Queue." 26 | ) 27 | parser.add_argument('-c', '--config', metavar='FILENAME', default='/etc/nerd/nerd.yml', 28 | help='Path to main NERD configuration file (default: /etc/nerd/nerd.yml)') 29 | parser.add_argument("-v", dest="verbose", action="store_true", help="Verbose mode") 30 | parser.add_argument("etype", metavar="TYPE", help="Entity type (e.g. 'ip', 'asn')") 31 | parser.add_argument("eid", metavar="ID", help="Entity ID (e.g. '1.2.3.4')") 32 | parser.add_argument("requests", metavar="UPDATE_SPEC", nargs='+', help="An update request as a JSON-encoded array, e.g. '[\"set\",\"test\",1]' or '[\"event\", \"!refresh_tags\"]'") 33 | parser.add_argument("-s", '--source', metavar="SOURCE_NAME", default="", help="Source name (e.g. 'blacklists', 'misp_receiver', 'otx_receiver', 'updater', 'warden_receiver', 'updater_manager', 'web', 'misp_updater')") 34 | args = parser.parse_args() 35 | 36 | if args.verbose: 37 | logger.setLevel("DEBUG") 38 | 39 | # Load configuration 40 | logger.debug("Loading config file {}".format(args.config)) 41 | config = read_config(args.config) 42 | 43 | rabbit_params = config.get('rabbitmq', {}) 44 | num_processes = config.get('worker_processes') 45 | 46 | # Parse the task 47 | requested_changes = [] 48 | for req in args.requests: 49 | try: 50 | req_parsed = json.loads(req) 51 | except ValueError as e: 52 | logger.error("Invalid UPDATE_SPEC: {}".format(e)) 53 | sys.exit(1) 54 | if not isinstance(req_parsed, list) or len(req_parsed) < 2: 55 | logger.error("Invalid UPDATE_SPEC: It must be a list with at least two items (operation and attr/event name)") 56 | sys.exit(1) 57 | requested_changes.append(req_parsed) 58 | 59 | # Create connection to task queue (RabbitMQ) 60 | tqw = TaskQueueWriter(num_processes, rabbit_params) 61 | if args.verbose: 62 | tqw.log.setLevel("DEBUG") 63 | tqw.connect() 64 | 65 | # Put task 66 | logger.debug("Sending task for {}/{}: {}".format(args.etype, args.eid, requested_changes)) 67 | tqw.put_task(args.etype, args.eid, requested_changes, args.source) 68 | 69 | # Close connection 70 | tqw.disconnect() 71 | -------------------------------------------------------------------------------- /NERDd/modules/dns.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module resolving hostnames of IP addresses using reverse DNS queries. 3 | 4 | Requirements: 5 | - "dnspython" package 6 | """ 7 | 8 | from core.basemodule import NERDModule 9 | import g 10 | 11 | import logging 12 | from dns import resolver,reversename 13 | from dns.exception import * 14 | 15 | 16 | class DNSResolver(NERDModule): 17 | """ 18 | DNS resolver module. 19 | 20 | Reoslves newly added IP addresses to hostnames using reverse DNS queries 21 | (PTR records). 22 | 23 | Event flow specification: 24 | !NEW -> get_hostname -> hostname 25 | """ 26 | 27 | def __init__(self): 28 | self.log = logging.getLogger("DNSResolver") 29 | self._resolver = resolver.Resolver() 30 | self._resolver.timeout = g.config.get('dns.timeout', 1) 31 | self._resolver.lifetime = 3 # Socket is open up to 3 seconds and will perform up to 3 queries in case of 1 second timeout occurence. 32 | 33 | g.um.register_handler( 34 | self.get_hostname, # function (or bound method) to call 35 | 'ip', # entity type 36 | ('!NEW','!refresh_hostname','!every1w'), # tuple/list/set of attributes to watch (their update triggers call of the registered method) 37 | ('hostname',) # tuple/list/set of attributes the method may change 38 | ) 39 | 40 | 41 | def get_hostname(self, ekey, rec, updates): 42 | """ 43 | Set a 'hostname' attribute as a result of DNS PTR query on the IP 44 | address (key). 45 | If the hostname cannot be resolved (due to NXDOMAIN, timeout or other 46 | error), None is stored to 'hostname' attribute. 47 | 48 | Arguments: 49 | ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 50 | rec -- record currently assigned to the key 51 | updates -- list of all attributes whose update triggered this call and 52 | their new values (or events and their parameters) as a list of 53 | 2-tuples: [(attr, val), (!event, param), ...] 54 | 55 | Returns: 56 | List of update requests (3-tuples describing requested attribute updates 57 | or events). 58 | In particular, the following update is requested: 59 | ('set', 'hostname', hostname_or_none) 60 | """ 61 | etype, key = ekey 62 | if etype != 'ip': 63 | return None 64 | 65 | addr = reversename.from_address(key) # create .in-addr.arpa address 66 | try: 67 | answer = self._resolver.query(addr,"PTR") 68 | result = str(answer.rrset[0]) # get first (it should be only) answer 69 | if result[-1] == '.': 70 | result = result[:-1] # trim trailing '.' 71 | except Timeout as e: 72 | self.log.debug("PTR query for {} timed out".format(key)) 73 | result = None 74 | except DNSException as e: 75 | result = None # set result to None if NXDOMAIN, Timeout or other error 76 | 77 | return [('set', 'hostname', result)] 78 | 79 | -------------------------------------------------------------------------------- /NERDd/modules/geolocation.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module getting geographical location of an IP address using MaxMind's 3 | GeoLite2 database. 4 | 5 | Requirements: 6 | - "geolite2" package 7 | 8 | Acknowledgment: 9 | This product includes GeoLite2 data created by MaxMind, available from 10 | http://www.maxmind.com. 11 | """ 12 | import logging 13 | 14 | from core.basemodule import NERDModule 15 | import g 16 | 17 | import geoip2.database 18 | import geoip2.errors 19 | 20 | 21 | class Geolocation(NERDModule): 22 | """ 23 | Geolocation module. 24 | 25 | Queries newly added IP addresses in MaxMind's GeoLite2 database to get its 26 | (approximate) geographical location. 27 | Stores the following attributes: 28 | geo.ctry # Country (2-letter ISO code) 29 | geo.city # City (English name) 30 | geo.tz # Timezone (Text specification, e.g. 'Europe/Prague') 31 | 32 | Event flow specification: 33 | !NEW -> geoloc -> geo.{ctry,city,tz} 34 | """ 35 | 36 | def __init__(self): 37 | self.log = logging.getLogger("Geolocation") 38 | 39 | # Get DB path 40 | db_path = g.config.get('geolocation.geolite2_db_path') 41 | 42 | # Instantiate DB reader (i.e. open GeoLite database) 43 | try: 44 | self._reader = geoip2.database.Reader(db_path) 45 | except OSError as e: 46 | self.log.error(f"Can't open GeoLite2 DB file ({e}). Geolocation module will be disabled!") 47 | return 48 | 49 | g.um.register_handler( 50 | self.geoloc, 51 | 'ip', 52 | ('!NEW','!refresh_geo'), 53 | ('geo.ctry','geo.city','geo.tz') 54 | ) 55 | 56 | def geoloc(self, ekey, rec, updates): 57 | """ 58 | Query GeoLite2 DB to get country, city and timezone of the IP address. 59 | If address isn't found, don't set anything. 60 | 61 | Arguments: 62 | ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 63 | rec -- record currently assigned to the key 64 | updates -- list of all attributes whose update triggerd this call and 65 | their new values (or events and their parameters) as a list of 66 | 2-tuples: [(attr, val), (!event, param), ...] 67 | 68 | 69 | Returns: 70 | List of update requests. 71 | """ 72 | etype, key = ekey 73 | if etype != 'ip': 74 | return None 75 | 76 | try: 77 | result = self._reader.city(key) 78 | except geoip2.errors.AddressNotFoundError: 79 | return None 80 | 81 | # print(result.country) 82 | # print(result.city) 83 | # print(result.location) 84 | ctry = result.country.iso_code 85 | city = result.city.names.get('en', None) 86 | tz = result.location.time_zone 87 | #lon = result.location.longitude 88 | #lat = result.location.latitude 89 | 90 | return [ 91 | ('set', 'geo.ctry', ctry), 92 | ('set', 'geo.city', city), 93 | ('set', 'geo.tz', tz), 94 | ] 95 | 96 | -------------------------------------------------------------------------------- /install/httpd/nerd.conf: -------------------------------------------------------------------------------- 1 | # NERDweb (Flask app) configuration for Apache 2 | 3 | Define NERDBaseLoc /nerd 4 | Define NERDBaseLocS /nerd/ 5 | Define NERDBaseDir /nerd/NERDweb 6 | 7 | # Uncomment this to return maintenance message instead of the web and API 8 | #Define MAINTENANCE 9 | 10 | # Set up WSGI script 11 | WSGIDaemonProcess nerd_wsgi python-path=${NERDBaseDir} 12 | WSGIScriptAlias ${NERDBaseLoc} ${NERDBaseDir}/wsgi.py 13 | 14 | # Error document path needs to be aliased for the variable expansion to work 15 | Alias /error403 ${NERDBaseDir}/static/403.html 16 | 17 | 18 | WSGIProcessGroup nerd_wsgi 19 | # Deny access from blacklisted IPs 20 | 21 | Require all granted 22 | IncludeOptional /etc/nerd/http_blocklist.conf 23 | 24 | ErrorDocument 403 /error403 25 | 26 | 27 | 28 | 29 | Require all granted 30 | 31 | # Redirect all requests to error 503 page and set it to a simple text message 32 | ErrorDocument 503 "Sorry, NERD is temporarily down due to maintenance and/or upgrade." 33 | RewriteEngine on 34 | # Exception for local connections - used for testing 35 | RewriteCond %{REMOTE_ADDR} !=127.0.0.1 36 | RewriteCond %{REMOTE_ADDR} !=::1 37 | RewriteRule ^ - [R=503,L] 38 | 39 | 40 | 41 | 42 | # Static files must be served directly by Apache, not by Flask 43 | Alias ${NERDBaseLocS}static/ ${NERDBaseDir}/static/ 44 | 45 | Require all granted 46 | # Remove timestamps from .css and .js files, which are added to the 47 | # filenames to force refresh of the files whenever they're modified. 48 | # (URLs are generated to point to e.g. /static/style.1234567890.css, 49 | # where the number is file modification time) 50 | RewriteEngine on 51 | RewriteBase ${NERDBaseLocS}static/ 52 | RewriteRule ^(.*)\.[\d]{10}\.(css|js)$ $1.$2 [L] 53 | #LogLevel alert rewrite:trace3 54 | 55 | 56 | # Authentication using local accounts 57 | 58 | AuthType basic 59 | AuthName "NERD web" 60 | AuthUserFile "/etc/nerd/htpasswd" 61 | Require valid-user 62 | 63 | 64 | # Authentication using Shibboleth 65 | # 66 | # AuthType shibboleth 67 | # ShibRequestSetting requireSession 1 68 | # Require shib-session 69 | # 70 | 71 | # API handlers 72 | 73 | # Pass Authorization header 74 | WSGIPassAuthorization On 75 | # Return JSON-formatted error message in case something goes wrong. 76 | ErrorDocument 500 "{\"err_n\": 500, \"error\": \"Internal Server Error\"}" 77 | ErrorDocument 403 "{\"err_n\": 403, \"error\": \"Your IP address has been permanently blocked due to excessive amount of queries or another abuse of the service. If you think this is an error or you need a special access to the service for legitimate reasons, contact admins at 'nerd@cesnet.cz'.\"}" 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /NERDd/modules/reputation.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module summarizing all information about an entity into its reputation 3 | score. (first prototype version) 4 | 5 | Should be triggered at least once a day for every address. 6 | """ 7 | 8 | from core.basemodule import NERDModule 9 | import g 10 | 11 | import datetime 12 | 13 | 14 | def nonlin(val, coef=0.5, max=20): 15 | """Nonlinear transformation of [0,inf) to [0,1)""" 16 | if val > max: 17 | return 1.0 18 | else: 19 | return (1 - coef**val) 20 | 21 | 22 | class Reputation(NERDModule): 23 | """ 24 | Module estimating reputation score of IPs. 25 | 26 | TODO better description 27 | 28 | Event flow specification: 29 | !every1d -> estimate_reputation -> rep 30 | """ 31 | 32 | def __init__(self): 33 | g.um.register_handler( 34 | self.estimate_reputation, # function (or bound method) to call 35 | 'ip', # entity type 36 | ('events_meta.total','!every1d',), # tuple/list/set of attributes to watch (their update triggers call of the registered method) 37 | ('rep',) # tuple/list/set of attributes the method may change 38 | ) 39 | 40 | 41 | def estimate_reputation(self, ekey, rec, updates): 42 | """ 43 | Handler function to compute the reputation. 44 | 45 | Simple method (first prototype): 46 | - take list of events from last 14 days 47 | - compute a "daily reputation" for each day as: 48 | - nonlin(num_of_events) * nonlin(number_of_nodes) 49 | - where nonlin is a nonlinear transformation: 1 - 1/2^x 50 | - get total reputation as weighted average of all "daily" ones with 51 | linearly decreasing weight (weight = (14-n)/14 for n=0..13) 52 | """ 53 | etype, key = ekey 54 | if etype != 'ip': 55 | return None 56 | 57 | if 'events' not in rec: 58 | return None # No Warden event, nothing to do 59 | 60 | today = datetime.datetime.utcnow().date() 61 | DATE_RANGE = 14 62 | 63 | # Get total number of events and list of nodes for each day 64 | # (index 'd' of arrays is 'number of days before today') 65 | num_events = [0 for _ in range(DATE_RANGE)] 66 | set_nodes = [set() for _ in range(DATE_RANGE)] 67 | for evtrec in rec['events']: 68 | date = evtrec['date'] 69 | date = datetime.date(int(date[0:4]), int(date[5:7]), int(date[8:10])) 70 | d = (today - date).days 71 | if d >= DATE_RANGE: 72 | continue 73 | num_events[d] += evtrec['n'] 74 | set_nodes[d].add(evtrec['node']) 75 | 76 | # Compute reputation score 77 | sum_weight = 0 78 | rep = 0 79 | for d in range(0,DATE_RANGE): 80 | # reputation at day 'd' 81 | daily_rep = nonlin(num_events[d]) * nonlin(len(set_nodes[d])) 82 | # total reputation as weighted average with linearly decreasing weight 83 | weight = float(DATE_RANGE - d) / DATE_RANGE 84 | sum_weight += weight 85 | rep += daily_rep * weight 86 | rep /= sum_weight 87 | return [('set', 'rep', rep)] 88 | 89 | -------------------------------------------------------------------------------- /etc/nerdweb.yml: -------------------------------------------------------------------------------- 1 | # NERD config (for NERDweb only) 2 | --- 3 | # Paths to other configuration files (relative to this file or absolute) 4 | common_config: nerd.yml 5 | acl_config: acl 6 | 7 | # Secret key used by Flask for various security purposes 8 | # Set this to an arbitrary long-enough string, use e.g.: 9 | # head -c 24 /dev/urandom | base64 10 | secret_key: "!!! CHANGE THIS !!!" 11 | 12 | # URL of NERDweb root (without trailing slash, e.g. "/nerd") 13 | # All NERDweb pages are relative to this 14 | # Keep empty if NERDweb resides in web server's root directory 15 | base_url: "/nerd" 16 | 17 | # Show "BETA" label over logo in web header 18 | beta_label: false 19 | 20 | # Items of main menu in the top-left corner of each page (map: URL path -> menu item text) 21 | # (Remove item from the list or set it to empty string to remove from menu) 22 | menu_items: 23 | ips: "IP search" 24 | ip: "IP detail" 25 | #asn: "ASN detail" 26 | data: "Data" 27 | #map: "IP map" 28 | 29 | # Make the logo a hyperlink (e.g. to main page of this NERD deployment) 30 | #logo_link: "https://www.example.com/" 31 | 32 | # Email to system administrator (shown to users as part of some error messages, optional) 33 | #admin_email: "nerd@example.com" 34 | 35 | data_disk_path: "/data" 36 | 37 | login: 38 | methods: 39 | local: 40 | display: Local account 41 | display_order: 2 42 | loc: "/login/basic" 43 | id_field: REMOTE_USER 44 | # Path to .htpasswd file with usernames and passwords (relative to NERD's "etc" dir) 45 | # Note that the file must be accessible by web server for both read and write (to allow password change) 46 | htpasswd_file: "htpasswd" 47 | 48 | # shibboleth: 49 | # display: EduGAIN 50 | # display_order: 1 51 | # loc: "/login/shibboleth" 52 | # id_field: eppn 53 | # name_field: 54 | # - displayName 55 | # - cn 56 | # - givenName+sn 57 | # email_field: mail 58 | # logout_path: "/Shibboleth.sso/Logout?return=/nerd/" 59 | 60 | rate-limit: 61 | # Rate-limiter default parameters (applied unless overridden for specific user). 62 | # Token bucket algorithm is used. Bucket size: maximum number of tokens per 63 | # user (how big burst of requests is allowed until rate-limitng applies). 64 | # Tokens per second: rate of bucket refilling (maximum long-term rate of 65 | # requests) 66 | # Default: 1 token/sec, bucket size of 60 67 | tokens-per-sec: 1 68 | bucket-size: 60 69 | # Selection of Redis instance and DB index (default: localhost:6379/1) 70 | redis: 71 | # host: localhost 72 | # port: 6379 73 | db-index: 1 74 | 75 | # Connection parameters for IPVisualizator backend (used in map.html), which runs as a standalone service. 76 | # See https://github.com/CESNET/IPVisualizator for details about IPVisualizator 77 | #ipmap: 78 | #url: https://example.com/ipvis 79 | #token: "" 80 | 81 | # (Optional) URL to find alert of an IP in Mentat ('$IP' is replaced by the IP) 82 | # Link is shown to users with "mentat" permission only. 83 | mentat_url: https://mentat-hub.cesnet.cz/mentat/events/search?source_addrs=$IP&submit=Search 84 | 85 | # (Optional) Message to show on the top of the MISP event page to explain where 86 | # the data comes from. 87 | #misp_message: "The MISP events shown in NERD are taken from [TODO]. Only messages with TLP=white (i.e. public information) are shown, unless you have an account with higher privileges." 88 | 89 | # (Optional) Link to Munin graphs 90 | # Shown in "status box" (visible to administrators only) 91 | munin_link: "/munin/nerd-day.html" 92 | -------------------------------------------------------------------------------- /NERDd/modules/reserved_ip.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module checks whether the ip address is reserved or not, based on the list: 3 | https://en.wikipedia.org/wiki/Reserved_IP_addresses 4 | """ 5 | 6 | import logging 7 | import re 8 | 9 | from core.basemodule import NERDModule 10 | import g 11 | 12 | 13 | class ReservedIPTags(NERDModule): 14 | # these prefixes stands for 0.0.0.0 - 0.255.255.255, 10.0.0.0 - 10.255.255.255, ... 15 | # https://en.wikipedia.org/wiki/Reserved_IP_addresses 16 | reserved_ip_prefix_list = ["0.", "10.", "127.", "169.254.", "192.0.0.", "192.0.2.", "192.168.", "198.51.100.", 17 | "203.0.113.", "255.255.255.255"] 18 | 19 | # list of regular expressions for more complicated ip ranges. The first one is: 20 | # 100.64.0.0 - 100.127.255.255 21 | # 100. 64-69|70-99|100-119|120-127 .0-255.0-255 22 | # The rest is in the same format, so only ranges are in the comment. The end of range (0-255 = \d{1,3}) is not 23 | # absolutely correct part of the regular expression, but should be totally fine in this use case. 24 | reserved_ip_re_list = [re.compile("100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\.\d{1,3}\.\d{1,3}"), 25 | # 172.16.0.0 - 172.31.255.255 26 | re.compile("172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}"), 27 | # 198.18.0.0 - 198.19.255.255 28 | re.compile("198\.1[8-9]\.\d{1,3}\.\d{1,3}]"), 29 | # 224.0.0.0 - 255.255.255.255 30 | re.compile("2(2[4-9]|[3-4][0-9]|5[0-5])\.\d{1,3}\.\d{1,3}\.\d{1,3}")] 31 | 32 | def __init__(self): 33 | # self.logger = logging.getLogger("ReservedIPTags") 34 | g.um.register_handler( 35 | self.is_reserved, 36 | 'ip', 37 | ('!NEW', ), 38 | ('reserved_range', ) 39 | ) 40 | 41 | def is_reserved(self, ekey, rec, updates): 42 | """ 43 | Checks if IP address is reserved IP address or not. If the IP address is reserved, then sets 'reserved_range' 44 | attribute to 1, otherwise sets 'reserved range' to 0 45 | 46 | Arguments: 47 | ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 48 | rec -- record currently assigned to the key 49 | updates -- list of all attributes whose update triggered this call and 50 | their new values (or events and their parameters) as a list of 51 | 2-tuples: [(attr, val), (!event, param), ...] 52 | 53 | Return: 54 | List of update requests. 55 | """ 56 | 57 | etype, key = ekey 58 | if etype != 'ip': 59 | return None 60 | 61 | # Go through all the prefixes and try to match the IP (key) with prefix. When match found, then tag it. If no 62 | # match found, try the rest of reserved ranges (regular expressions) 63 | for ip_prefix in ReservedIPTags.reserved_ip_prefix_list: 64 | if key.startswith(ip_prefix): 65 | # tag it, set 1 as True, because IP is in reserved range 66 | return [('set', 'reserved_range', 1)] 67 | else: 68 | for re_ip in ReservedIPTags.reserved_ip_re_list: 69 | if re_ip.search(key): 70 | # tag it, set 1 as True, because IP is in reserved range 71 | return [('set', 'reserved_range', 1)] 72 | 73 | # set 0 as False, because IP is not in reserved range 74 | return [('set', 'reserved_range', 0)] 75 | -------------------------------------------------------------------------------- /NERDd/modules/bgp_rank.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module fetching BGP ranking of ASNs from CIRCL's server. 3 | 4 | Official API client is not used, since it's just a simple 'requests' wrapper. We rather construct requests ourselves. 5 | """ 6 | 7 | from core.basemodule import NERDModule 8 | import g 9 | 10 | import logging 11 | import requests 12 | 13 | BASE_URL = "https://bgpranking-ng.circl.lu/" 14 | QUERY_URL = BASE_URL + "json/asn" 15 | 16 | class CIRCL_BGPRank(NERDModule): 17 | """ 18 | BGP Rank module. 19 | 20 | Module for getting BGP rank of ASN entities using BGP Ranking API (bgpranking_web). 21 | """ 22 | 23 | def __init__(self): 24 | self.log = logging.getLogger('CIRCL_BGPRank') 25 | self.log.setLevel("DEBUG") 26 | self.requests_session = requests.session() 27 | g.um.register_handler( 28 | self.set_bgprank, # function (or bound method) to call 29 | 'asn', # entity type 30 | ('!NEW', '!every1d'), # tuple/list/set of attributes to watch (their update triggers call of the registered method) 31 | ('circl_bgprank',) # tuple/list/set of attributes the method may change 32 | ) 33 | 34 | def set_bgprank(self, ekey, rec, updates): 35 | """ 36 | Set a 'circl_bgprank' attribute as a result of BGP Ranking query on the ASN. 37 | 38 | Arguments: 39 | ekey -- two-tuple of entity type and key, e.g. ('asn', 1234) 40 | rec -- record currently assigned to the key 41 | updates -- list of all attributes whose update triggered this call and 42 | their new values (or events and their parameters) as a list of 43 | 2-tuples: [(attr, val), (!event, param), ...] 44 | 45 | Returns: 46 | List of update requests (3-tuples describing requested attribute updates 47 | or events). 48 | None in case of error. 49 | In particular, the following update is requested 50 | ('set', 'circl_bgprank', RANK_NUM) 51 | """ 52 | etype, key = ekey 53 | 54 | if etype != 'asn': 55 | return None 56 | 57 | #query = json.dumps({'asn': key, 'address_family': 'v4'}) 58 | query = '{"asn": ' + str(key) + ', "address_family": "v4"}' 59 | try: 60 | # the return format is: 61 | # {'meta': {'asn': integer, 'address_family': 'v4'}, 62 | # 'response': {'asn_description': 'xxx', 63 | # 'ranking': {'rank': double, 64 | # 'position': integer, 65 | # 'total_known_asns': integer 66 | # } 67 | # } 68 | # } 69 | reply = self.requests_session.post(QUERY_URL, data=query, timeout=(1,3)) 70 | reply = reply.json() 71 | 72 | # when ASN is not found (or request is completely wrong), server returns the same response format with 73 | # empty asn_description, rank equal to 0.0 and position is None 74 | rank = reply['response']['ranking']['rank'] 75 | pos = reply['response']['ranking']['position'] 76 | if not reply['response']['asn_description'] and rank == 0.0 and pos is None: 77 | self.log.info("ASN {} not found in BGP ranking database".format(key)) 78 | self.log.debug("Setting BGPRank of ASN {} to {}".format(key, rank)) 79 | except Exception as e: 80 | self.log.error("Can't get BGPRank of ASN {}: {}".format(key, str(e))) 81 | return None # could be connection error etc. 82 | 83 | return [('set', 'circl_bgprank', rank)] 84 | -------------------------------------------------------------------------------- /install/supervisord.conf: -------------------------------------------------------------------------------- 1 | ; NERD default supervisor config file. 2 | ; 3 | ; For more information on the config file, please see: 4 | ; http://supervisord.org/configuration.html 5 | ; 6 | ; Notes: 7 | ; - Shell expansion ("~" or "$HOME") is not supported. Environment 8 | ; variables can be expanded using this syntax: "%(ENV_HOME)s". 9 | ; - Quotes around values are not supported, except in the case of 10 | ; the environment= options as shown below. 11 | ; - Comments must have a leading space: "a=b ;comment" not "a=b;comment". 12 | ; - Command will be truncated if it looks like a config file comment, e.g. 13 | ; "command=bash -c 'foo ; bar'" will truncate to "command=bash -c 'foo ". 14 | 15 | [unix_http_server] 16 | file=/var/run/nerd/supervisord.sock ; the path to the socket file 17 | chmod=0770 ; socket file mode (default 0700) 18 | chown=nerd:nerd ; socket file uid:gid owner 19 | ;username=user ; default is no username (open server) 20 | ;password=123 ; default is no password (open server) 21 | 22 | [inet_http_server] ; inet (TCP) server disabled by default 23 | port=localhost:9001 ; ip_address:port specifier, *:port for all iface 24 | ;username=nerd ; default is no username (open server) 25 | ;password=123 ; default is no password (open server) 26 | 27 | [supervisord] 28 | logfile=/var/log/nerd/supervisord.log ; main log file; default $CWD/supervisord.log 29 | logfile_maxbytes=50MB ; max main logfile bytes b4 rotation; default 50MB 30 | logfile_backups=10 ; # of main logfile backups; 0 means none, default 10 31 | loglevel=info ; log level; default info; others: debug,warn,trace 32 | pidfile=/var/run/nerd/supervisord.pid ; supervisord pidfile; default supervisord.pid 33 | nodaemon=false ; start in foreground if true; default false 34 | minfds=1024 ; min. avail startup file descriptors; default 1024 35 | minprocs=200 ; min. avail process descriptors;default 200 36 | ;umask=022 ; process file creation umask; default 022 37 | user=nerd ; default is current user, required if root 38 | identifier=nerd_supervisor ; supervisord identifier, default is 'supervisor' 39 | directory=/nerd ; default is not to cd during start 40 | nocleanup=true ; don't clean up tempfiles at start; default false 41 | childlogdir=/var/log/nerd ; 'AUTO' child log dir, default $TEMP 42 | ;environment=KEY="value" ; key value pairs to add to environment 43 | ;strip_ansi=false ; strip ansi escape codes in logs; def. false 44 | 45 | ; The rpcinterface:supervisor section must remain in the config file for 46 | ; RPC (supervisorctl/web interface) to work. Additional interfaces may be 47 | ; added by defining them in separate [rpcinterface:x] sections. 48 | 49 | [rpcinterface:supervisor] 50 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 51 | 52 | ; The supervisorctl section configures how supervisorctl will connect to 53 | ; supervisord. configure it match the settings in either the unix_http_server 54 | ; or inet_http_server section. 55 | 56 | [supervisorctl] 57 | serverurl=unix:///var/run/nerd/supervisord.sock ; use a unix:// URL for a unix socket 58 | ;serverurl=http://127.0.0.1:9001 ; use an http:// url to specify an inet socket 59 | ;username=chris ; should be same as in [*_http_server] if set 60 | ;password=123 ; should be same as in [*_http_server] if set 61 | ;prompt=mysupervisor ; cmd line prompt (default "supervisor") 62 | ;history_file=~/.sc_history ; use readline history if available 63 | 64 | 65 | [include] 66 | files = supervisord.conf.d/*.ini 67 | -------------------------------------------------------------------------------- /NERDd/modules/ttl_updater.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module for management of TTL of IP records. 3 | """ 4 | 5 | from core.basemodule import NERDModule 6 | import g 7 | 8 | import logging 9 | from datetime import datetime, timedelta 10 | 11 | # minimum number of events, where IP address has to occur in last 7 days, to be marked as highly active 12 | DEFAULT_HIGHLY_ACTIVE_THRESHOLD = 1000 13 | # TTL in days of highly active IP address 14 | DEFAULT_HIGHLY_ACTIVE_TTL = 30 15 | # number of days, which IP address has to be in NERD, to be marked as long active 16 | DEFAULT_LONG_ACTIVE_THRESHOLD = 30 17 | # TTL in days of long active IP address 18 | DEFAULT_LONG_ACTIVE_TTL = 30 19 | 20 | class TTLUpdater(NERDModule): 21 | """ 22 | Module for updating TTL in highly active or long active IP address records. 23 | """ 24 | def __init__(self): 25 | self.log = logging.getLogger('TTLUpdater') 26 | self.log.setLevel("DEBUG") 27 | 28 | # minimum number of events, where IP address has to occur in last 7 days, to be marked as highly active 29 | self.highly_active_threshold = g.config.get('record_life_threshold.highly_active', DEFAULT_HIGHLY_ACTIVE_THRESHOLD) 30 | # TTL in days of highly active IP address 31 | self.highly_active_ttl = g.config.get('record_life_length.highly_active', DEFAULT_HIGHLY_ACTIVE_TTL) 32 | 33 | # number of days, which IP address has to be in NERD, to be marked as long active 34 | self.long_active_threshold = g.config.get('record_life_threshold.long_active', DEFAULT_LONG_ACTIVE_THRESHOLD) 35 | # TTL in days of long active IP address 36 | self.long_active_ttl = g.config.get('record_life_length.long_active', DEFAULT_LONG_ACTIVE_TTL) 37 | 38 | g.um.register_handler( 39 | self.check_ttl, # function (or bound method) to call 40 | 'ip', # entity type 41 | # tuple/list/set of attributes to watch (their update triggers call of the registered method) 42 | ('_ttl.warden', 'events_meta.total7'), 43 | ('_ttl.highly_active', '_ttl.long_active') # tuple/list/set of attributes the method may change 44 | ) 45 | 46 | def check_high_activity(self, new_updates, rec): 47 | try: 48 | if rec['events_meta']['total7'] > self.highly_active_threshold: 49 | record_ttl = datetime.utcnow() + timedelta(days=self.highly_active_ttl) 50 | new_updates.append(('set', '_ttl.highly_active', record_ttl)) 51 | except KeyError: 52 | pass 53 | 54 | def check_long_activity(self, new_updates, rec): 55 | ip_lifetime = (rec['last_activity'] - rec['ts_added']).days 56 | if ip_lifetime > self.long_active_threshold: 57 | record_ttl = rec['last_activity'] + timedelta(days=self.long_active_ttl) 58 | new_updates.append(('set', '_ttl.long_active', record_ttl)) 59 | 60 | def check_ttl(self, ekey, rec, updates): 61 | """ 62 | Check if IP address is 'highly active' or 'long active' and if so, then set corresponding TTL 63 | :param ekey: two-tuple of entity type and key, e.g. ('ip', '212.227.17.11') 64 | :param rec: record currently assigned to the key 65 | :param updates: list of all attributes whose update triggered this call and their new values (or events and 66 | their parameters) as a list of 2-tuples: [(attr, val), (!event, param), ...] 67 | :return: List of update requests (3-tuples describing requested attribute updates or events). 68 | """ 69 | etype, key = ekey 70 | 71 | if etype != "ip": 72 | return None 73 | 74 | new_updates = [] 75 | self.check_high_activity(new_updates, rec) 76 | self.check_long_activity(new_updates, rec) 77 | return new_updates 78 | -------------------------------------------------------------------------------- /NERDweb/static/format_date.js: -------------------------------------------------------------------------------- 1 | // This code handles the UTC-local time switch. 2 | // When the switch is toggled, all times on the page are reformatted to show time in UTC or browser's local time. 3 | // Each element containing time must have ".time" class and "data-time" attribute with timestamp as integer in UTC. 4 | // The state of the switch is stored in localStorage, so it persists traversal of various pages. 5 | 6 | function is_utc_on() { 7 | return $("#utc-togBtn").is(":checked"); 8 | } 9 | 10 | function on_utc_switch_clicked() { 11 | // reformat all dates to reflect the change and store current state to localStorage, so it persists on various pages 12 | reformatAllDates(); 13 | window.localStorage.setItem("utc-switch", (is_utc_on() ? "true" : "false")); 14 | } 15 | 16 | function formatDate(rawDate){ 17 | if(is_utc_on()) { 18 | var year = '' + rawDate.getUTCFullYear(), 19 | // month values are 0-11 20 | month = '' + (rawDate.getUTCMonth() + 1), 21 | day = '' + rawDate.getUTCDate(), 22 | hour = '' + rawDate.getUTCHours(), 23 | minute = '' + rawDate.getUTCMinutes(), 24 | second = '' + rawDate.getUTCSeconds(); 25 | } 26 | else{ 27 | var year = '' + rawDate.getFullYear(), 28 | // month values are 0-11 29 | month = '' + (rawDate.getMonth() + 1), 30 | day = '' + rawDate.getDate(), 31 | hour = '' + rawDate.getHours(), 32 | minute = '' + rawDate.getMinutes(), 33 | second = '' + rawDate.getSeconds(); 34 | } 35 | 36 | if (month.length < 2) month = '0' + month; 37 | if (day.length < 2) day = '0' + day; 38 | if (hour.length < 2) hour = '0' + hour; 39 | if (minute.length < 2) minute = '0' + minute; 40 | if (second.length < 2) second = '0' + second; 41 | return '' + year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second; 42 | } 43 | 44 | function format_dates_in_tooltip(tooltip_str){ 45 | if(is_utc_on()) { 46 | // date is always in UTC by default in tooltips 47 | return tooltip_str 48 | } 49 | 50 | // find all dates in tooltip and replace them with local time 51 | var date_regex = new RegExp('\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}(:\\d{2}(.\\d\\+)?)?', 'g'); 52 | return tooltip_str.replace(date_regex, function (date) { return formatDate(new Date(date + "Z")); } ); // TODO set precision (sec/msec) based on existence of regexp groups 1 and 2. 53 | } 54 | 55 | function reformatAllDates(){ 56 | // format every time value properly 57 | $(".time").each(function () { 58 | if (!$(this).get(0).className.includes("duration")) { 59 | var datetime = $(this).data("time"); 60 | var rawDate = new Date(datetime * 1000); 61 | $(this).text(formatDate(rawDate)); 62 | } 63 | }); 64 | } 65 | 66 | document.addEventListener("DOMContentLoaded", function () { 67 | // on load - get saved state from localStorage, set/unset the checkbox and reformat all dates 68 | if (window.localStorage.getItem("utc-switch") == "true") { 69 | $("#utc-togBtn").prop("checked", true); 70 | } 71 | else { // false or not set = local 72 | $("#utc-togBtn").prop("checked", false); 73 | } 74 | reformatAllDates(); 75 | $("#utc-switch").css("display", "inline-block"); // show the switch, which is hidden by default (otherwise it may load in one state and then, after this code runs, switch to the other state, which looks weird) 76 | $("#timezone-label").css("display", "block"); 77 | 78 | // Set title with explanation and actual "local" timezone. 79 | var tz = Intl.DateTimeFormat().resolvedOptions().timeZone; 80 | var title="Switch whether all time information on the page should be displayed in UTC or your local timezone ("+tz+")."; 81 | $("#utc-switch").prop('title', title); 82 | }); 83 | -------------------------------------------------------------------------------- /NERDweb/templates/map.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block scripts %} 3 | {# additional scripts and stylesheets in the page head section #} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% endblock %} 13 | {% block body %} 14 |

Visualization of IPv4 address space

15 | 16 |

The visualization uses Hilbert curve to display IPv4 address space. Each pixel on the plane represents a certain network, its value (and color) is the sum of reputation scores of IP addresses in that network.

17 | 18 | {% if ipvis_url and ipvis_token %} 19 |
20 | 21 | 95 | {% else %} 96 |

IP visualization backend not configured.

97 | {% endif %} 98 | 99 | {% endblock %} 100 | -------------------------------------------------------------------------------- /NERDweb/static/jquery.multiselect.css: -------------------------------------------------------------------------------- 1 | .ms-options-wrap, 2 | .ms-options-wrap * { 3 | box-sizing: border-box; 4 | } 5 | 6 | .ms-options-wrap > button:focus, 7 | .ms-options-wrap > button { 8 | position: relative; 9 | width: 100%; 10 | text-align: left; 11 | border: 1px solid #aaa; 12 | background-color: #fff; 13 | padding: 5px 20px 5px 5px; 14 | margin-top: 1px; 15 | font-size: 13px; 16 | color: #aaa; 17 | outline-offset: -2px; 18 | white-space: nowrap; 19 | } 20 | 21 | .ms-options-wrap > button > span { 22 | display: inline-block; 23 | } 24 | 25 | .ms-options-wrap > button[disabled] { 26 | background-color: #e5e9ed; 27 | color: #808080; 28 | opacity: 0.6; 29 | } 30 | 31 | .ms-options-wrap > button:after { 32 | content: ' '; 33 | height: 0; 34 | position: absolute; 35 | top: 50%; 36 | right: 5px; 37 | width: 0; 38 | border: 6px solid rgba(0, 0, 0, 0); 39 | border-top-color: #999; 40 | margin-top: -3px; 41 | } 42 | 43 | .ms-options-wrap.ms-has-selections > button { 44 | color: #333; 45 | } 46 | 47 | .ms-options-wrap > .ms-options { 48 | position: absolute; 49 | left: 0; 50 | width: 100%; 51 | margin-top: 1px; 52 | margin-bottom: 20px; 53 | background: white; 54 | z-index: 2000; 55 | border: 1px solid #aaa; 56 | overflow: auto; 57 | visibility: hidden; 58 | } 59 | 60 | .ms-options-wrap.ms-active > .ms-options { 61 | visibility: visible 62 | } 63 | 64 | .ms-options-wrap > .ms-options > .ms-search input { 65 | width: 100%; 66 | padding: 4px 5px; 67 | border: none; 68 | border-bottom: 1px groove; 69 | outline: none; 70 | } 71 | 72 | .ms-options-wrap > .ms-options .ms-selectall { 73 | display: inline-block; 74 | font-size: .9em; 75 | text-transform: lowercase; 76 | text-decoration: none; 77 | } 78 | .ms-options-wrap > .ms-options .ms-selectall:hover { 79 | text-decoration: underline; 80 | } 81 | 82 | .ms-options-wrap > .ms-options > .ms-selectall.global { 83 | margin: 4px 5px; 84 | } 85 | 86 | .ms-options-wrap > .ms-options > ul, 87 | .ms-options-wrap > .ms-options > ul > li.optgroup ul { 88 | list-style-type: none; 89 | padding: 0; 90 | margin: 0; 91 | } 92 | 93 | .ms-options-wrap > .ms-options > ul li.ms-hidden { 94 | display: none; 95 | } 96 | 97 | .ms-options-wrap > .ms-options > ul > li.optgroup { 98 | padding: 5px; 99 | } 100 | .ms-options-wrap > .ms-options > ul > li.optgroup + li.optgroup { 101 | border-top: 1px solid #aaa; 102 | } 103 | 104 | .ms-options-wrap > .ms-options > ul > li.optgroup .label { 105 | display: block; 106 | padding: 5px 0 0 0; 107 | font-weight: bold; 108 | } 109 | 110 | .ms-options-wrap > .ms-options > ul label { 111 | position: relative; 112 | display: inline-block; 113 | width: 100%; 114 | padding: 4px 4px 4px 20px; 115 | margin: 1px 0; 116 | border: 1px dotted transparent; 117 | } 118 | .ms-options-wrap > .ms-options.checkbox-autofit > ul label, 119 | .ms-options-wrap > .ms-options.hide-checkbox > ul label { 120 | padding: 4px; 121 | } 122 | 123 | .ms-options-wrap > .ms-options > ul label.focused, 124 | .ms-options-wrap > .ms-options > ul label:hover { 125 | background-color: #efefef; 126 | border-color: #999; 127 | } 128 | 129 | .ms-options-wrap > .ms-options > ul li.selected label { 130 | background-color: #efefef; 131 | border-color: transparent; 132 | } 133 | 134 | .ms-options-wrap > .ms-options > ul input[type="checkbox"] { 135 | margin: 0 5px 0 0; 136 | position: absolute; 137 | left: 4px; 138 | top: 7px; 139 | } 140 | 141 | .ms-options-wrap > .ms-options.hide-checkbox > ul input[type="checkbox"] { 142 | position: absolute !important; 143 | height: 1px; 144 | width: 1px; 145 | overflow: hidden; 146 | clip: rect(1px 1px 1px 1px); 147 | clip: rect(1px, 1px, 1px, 1px); 148 | } 149 | -------------------------------------------------------------------------------- /scripts/get_iana_assignment_files.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | #-*- encoding: utf-8 -*- 3 | 4 | import subprocess 5 | import ipaddress 6 | import csv 7 | 8 | SPECIAL_PURPOSE_ADDRESS ='\ 9 | 0 16777215 Reserved:arin\n\ 10 | 167772160 184549375 Reserved:arin\n\ 11 | 1681915904 1686110207 Reserved:arin\n\ 12 | 2130706432 2147483647 Reserved:arin\n\ 13 | 2851995648 2852061183 Reserved:arin\n\ 14 | 2886729728 2887778303 Reserved:arin\n\ 15 | 3221225472 3221225727 Reserved:arin\n\ 16 | 3221225984 3221226239 Reserved:arin\n\ 17 | 3223307264 3223307519 Reserved:arin\n\ 18 | 3224682752 3224683007 Reserved:arin\n\ 19 | 3227017984 3227018239 Reserved:arin\n\ 20 | 3232235520 3232301055 Reserved:arin\n\ 21 | 3232706560 3232706815 Reserved:arin\n\ 22 | 3323068416 3323199487 Reserved:arin\n\ 23 | 3325256704 3325256959 Reserved:arin\n\ 24 | 3405803776 3405804031 Reserved:apnic\n\ 25 | 4026531840 4294967295 Reserved\n\ 26 | 4294967295 4294967295 Reserved\n' 27 | 28 | SPECIAL_PURPOSE_ASN ='\ 29 | 0,Reserved:ripe\n\ 30 | 1,arin\n\ 31 | 112,Reserved:arin\n\ 32 | 113,arin\n\ 33 | 23456,Reserved:arin\n\ 34 | 23457,arin\n\ 35 | 64496,Reserved:ripe\n\ 36 | 131072,apnic\n\ 37 | 4200000000,Reserved:ripe\n' 38 | 39 | DOWNLOAD_IP_COMMAND = '\ 40 | loc=("lacnic" "ripe" "arin" "afrinic" "apnic");\ 41 | rirs=("lacnic" "ripencc" "arin" "afrinic" "apnic");\ 42 | for i in ${!rirs[*]};\ 43 | do \ 44 | url="https://ftp."${loc[$i]}".net/pub/stats/"${rirs[$i]}"/delegated-"${rirs[$i]}"-extended-latest";\ 45 | echo "$url";\ 46 | wget -q "$url";\ 47 | cat "delegated-"${rirs[$i]}"-extended-latest" | grep "ipv4" | awk \'BEGIN { FS = "|"} ; {print $4","$5","$1}\' | tail -n +2 >> csv_tmp;\ 48 | rm -f "delegated-"${rirs[$i]}"-extended-latest";\ 49 | done' 50 | 51 | DOWNLOAD_ASN_COMMAND = '\ 52 | wget -q https://www.iana.org/assignments/as-numbers/as-numbers-1.csv;\ 53 | wget -q https://www.iana.org/assignments/as-numbers/as-numbers-2.csv;\ 54 | cat as-numbers-1.csv | tr " " "," | egrep "ARIN|APNIC|RIPE|AFRINIC|LACNIC" | awk \'BEGIN { FS = ","} ; {print $1","tolower($4)}\' >> asn_tmp;\ 55 | cat as-numbers-2.csv | tr " " "," | egrep "ARIN|APNIC|RIPE|AFRINIC|LACNIC|Unallocated" | awk \'BEGIN { FS = ","} ; {if ($2 == "Unallocated")print $1","$2; else print $1","tolower($4);}\' >> asn_tmp;\ 56 | rm -f as-numbers-1.csv as-numbers-2.csv' 57 | 58 | SORT_UNIQ_COMMAND_IPV4 = 'cat trans_tmp | sort -n -k 1,1 | tr " " "," > nerd-whois-ipv4.csv' 59 | SORT_UNIQ_COMMAND_ASN = 'cat asn_tmp2 | tr "," " " | sort -n -k1,1 | uniq -f 1 | tr " " "," > nerd-whois-asn.csv' 60 | 61 | CLEANUP_COMMAND = 'rm -f csv_tmp trans_tmp asn_tmp asn_tmp2' 62 | 63 | print("Downloading list of IP block allocations from FTP servers...") 64 | 65 | subprocess.call(DOWNLOAD_IP_COMMAND, shell=True, executable='/bin/bash') 66 | 67 | r = open('csv_tmp', 'r') 68 | w = open('trans_tmp', 'w') 69 | datareader = csv.reader(r, delimiter=',') 70 | 71 | print("Converting IP representation to long uint...") 72 | 73 | for row in datareader: 74 | rir = 'ripe' if row[2] == "ripencc" else row[2] 75 | first_ip = int(ipaddress.ip_address(row[0])) 76 | last_ip = first_ip + int(row[1]) - 1 77 | w.write(str(first_ip) + ' ' + str(last_ip) + ' ' + rir + '\n') 78 | 79 | w.write(SPECIAL_PURPOSE_ADDRESS) 80 | r.close() 81 | w.close() 82 | 83 | print("Removing duplicities...") 84 | 85 | subprocess.call(SORT_UNIQ_COMMAND_IPV4, shell=True, executable='/bin/bash') 86 | 87 | print("Downloading ASN allocation tables from IANA...") 88 | 89 | subprocess.call(DOWNLOAD_ASN_COMMAND, shell=True, executable='/bin/bash') 90 | 91 | r = open('asn_tmp', 'r') 92 | w = open('asn_tmp2', 'w') 93 | datareader = csv.reader(r, delimiter=',') 94 | 95 | for row in datareader: 96 | asn = row[0].split('-') 97 | w.write(asn[0] + ',' + row[1] + '\n') 98 | 99 | w.write(SPECIAL_PURPOSE_ASN) 100 | r.close() 101 | w.close() 102 | 103 | print("Cleaning up temporary files...") 104 | 105 | subprocess.call(SORT_UNIQ_COMMAND_ASN, shell=True, executable='/bin/bash') 106 | subprocess.call(CLEANUP_COMMAND, shell=True, executable='/bin/bash') 107 | 108 | print('Done!') 109 | -------------------------------------------------------------------------------- /etc/event_logging.yml: -------------------------------------------------------------------------------- 1 | # Configuration of a EventCountLogger system, which allows to count arbitrary events across multiple processes 2 | # (using shared counters in Redis) and in various time intervals. 3 | redis: 4 | host: localhost 5 | port: 6379 6 | db: 2 # Index of Redis DB used for the counters (it shouldn't be used for anything else) 7 | 8 | # Each "group" specifies a set of "events" which are handled together. 9 | groups: 10 | rec_ops: 11 | # Number of tasks processed by entity type and record operation. 12 | # Each task is counted as exactly one event: 13 | # - updated = normal task, one or more attributes of an existing record were updated 14 | # - created = task resulted in creation of a new record 15 | # - removed = task resulted in deletion of a record 16 | # - noop = task with no operations (or only weak operations and the record doesn't exist), no change in DB 17 | events: 18 | - ip_updated 19 | - ip_created 20 | - ip_removed 21 | - ip_noop 22 | - asn_updated 23 | - asn_created 24 | - asn_removed 25 | - asn_noop 26 | - bgppref_updated 27 | - bgppref_created 28 | - bgppref_removed 29 | - bgppref_noop 30 | - ipblock_updated 31 | - ipblock_created 32 | - ipblock_removed 33 | - ipblock_noop 34 | - org_updated 35 | - org_created 36 | - org_removed 37 | - org_noop 38 | auto_declare_events: true 39 | intervals: [ "5s", "5m" ] 40 | sync-interval: 1 41 | 42 | # Number of processed tasks by their source (TODO) 43 | tasks_by_src: 44 | events: 45 | - blacklists 46 | - misp_receiver 47 | - otx_receiver 48 | - updater 49 | - warden_receiver 50 | - updater_manager 51 | - web 52 | - misp_updater 53 | - dshield 54 | auto_declare_events: true 55 | intervals: ["5s", "5m"] 56 | sync-interval: 1 57 | 58 | # Logging of various errors (currently only errors in modules) 59 | errors: 60 | events: ["error_in_module"] 61 | auto_declare_events: true 62 | intervals: ["5m"] 63 | 64 | # Web - access to individual endpoints 65 | web_endpoints: 66 | events: 67 | - '/' 68 | - '/noaccount' 69 | - '/account' 70 | - '/set_effective_groups' 71 | - '/ips' 72 | - '/ips_count' 73 | - '/ips_download' 74 | - '/ip' 75 | - '/ajax/fetch_ip_data' 76 | - '/ajax/is_ip_prepared' 77 | - '/ajax/ip_events' 78 | - '/misp_event' 79 | - '/asn' 80 | - '/ipblock' 81 | - '/org' 82 | - '/bgppref' 83 | - '/status' 84 | - '/iplist' 85 | - '/map' 86 | - '/data' 87 | - '/data/ip_rep_csv' 88 | - '/data/bad_ips_txt' 89 | - '/data/bad_ips_med_conf_txt' 90 | - '/api/user_info' 91 | - '/api/ip' 92 | - '/api/ip/rep' 93 | - '/api/ip/fmp' 94 | # - '/api/ip/test' 95 | - '/api/ip/full' 96 | - '/api/search/ip' 97 | - '/api/prefix' 98 | - '/api/bad_prefixes' 99 | - '/api/ip/bulk' 100 | - '/pdns/ip' 101 | - '/api/shodan-info' 102 | # 5 min interval for Munin 103 | intervals: ["5m"] 104 | # Cache counts locally, push to Redis every 5 seconds 105 | sync-interval: 5 106 | 107 | # Web - error responses 108 | web_errors: 109 | events: 110 | - 400_bad_request 111 | - 403_no_auth_header 112 | - 403_invalid_token 113 | - 403_unauthorized 114 | - 404_api_bad_path 115 | - 404_api_ip_not_found 116 | - 429_rate_limit_api 117 | - 429_rate_limit_web 118 | - 503_db_error 119 | - 5xx_other 120 | # 5 min interval for Munin 121 | intervals: [ "5m" ] 122 | # Cache counts locally, push to Redis every 5 seconds 123 | sync-interval: 5 124 | 125 | 126 | # Shodan InternetDB module 127 | shodan: 128 | events: 129 | - add_or_update_data 130 | - no_data 131 | - remove_old_data 132 | - skipped 133 | - rate_limit 134 | - unexpected_reply 135 | # 5 min interval for Munin 136 | intervals: [ "5m" ] 137 | sync-interval: 5 138 | -------------------------------------------------------------------------------- /scripts/shodan_requester.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import requests 6 | import argparse 7 | import json 8 | from datetime import datetime 9 | import logging 10 | 11 | from cachetools import TTLCache 12 | import pika 13 | 14 | # Add to path the "one directory above the current file location" to find modules from "common" 15 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))) 16 | 17 | from common.config import read_config 18 | 19 | LOGFORMAT = "%(asctime)-15s,%(name)s [%(levelname)s] %(message)s" 20 | LOGDATEFORMAT = "%Y-%m-%dT%H:%M:%S" 21 | logging.basicConfig(level=logging.INFO, format=LOGFORMAT, datefmt=LOGDATEFORMAT) 22 | 23 | logger = logging.getLogger('Shodan_requester') 24 | 25 | # parse arguments 26 | parser = argparse.ArgumentParser( 27 | prog="shodan_requester.py", 28 | description="NERD standalone, which will get info about IP from Shodan service when requested." 29 | ) 30 | parser.add_argument('-c', '--config', metavar='FILENAME', default='/etc/nerd/nerd.yml', 31 | help='Path to configuration file (default: /etc/nerd/nerd.yml)') 32 | parser.add_argument("-v", dest="verbose", action="store_true", help="Verbose mode") 33 | args = parser.parse_args() 34 | 35 | if args.verbose: 36 | logger.setLevel("DEBUG") 37 | 38 | # config - load nerd.yml 39 | logger.info("Loading config file {}".format(args.config)) 40 | config = read_config(args.config) 41 | 42 | api_key = config.get('shodan_api_key') 43 | rmq_settings = config.get('rabbitmq') 44 | if not api_key: 45 | logger.error("Cannot load Shodan API key, make sure it is properly configured in {}.".format(args.config)) 46 | sys.exit(1) 47 | 48 | try: 49 | rmq_creds = pika.PlainCredentials(rmq_settings['username'], rmq_settings['password']) 50 | rmq_params = pika.ConnectionParameters(rmq_settings['host'], rmq_settings['port'], rmq_settings['virtual_host'], 51 | rmq_creds) 52 | except KeyError: 53 | logger.error("RabbitMQ settings are not configured properly, make sure it is properly configured in {}.".format( 54 | args.config)) 55 | sys.exit(1) 56 | 57 | connection = pika.BlockingConnection(rmq_params) 58 | channel = connection.channel() 59 | channel.queue_declare(queue='shodan_rpc_queue', arguments={'x-message-ttl' : 30000}) # set ttl of messages to 30 sec 60 | 61 | # dictionary in format: { 'ipaddr': {ttl: time, data: data}} 62 | cache = TTLCache(maxsize=128, ttl=3600) 63 | 64 | 65 | def get_shodan_data(ip): 66 | if ip in cache: 67 | logger.debug("cache hit for {}".format(ip)) 68 | data = cache[ip] 69 | else: 70 | url = 'https://api.shodan.io/shodan/host/{ip}?key={api_key}'.format(ip=ip, api_key=api_key) 71 | resp = requests.get(url) 72 | if resp.status_code == 200: 73 | data = resp.content 74 | else: 75 | if resp.status_code != 404: 76 | logger.error("Error response for url: {}\n{}".format(url, resp.content)) 77 | try: 78 | response_dict = json.loads(resp.text) 79 | except Exception: 80 | return json.dumps({"error": "Shodan returned invalid response"}) 81 | data = json.dumps({"error": response_dict["error"] if "error" in response_dict else "Unknown error"}) 82 | 83 | if resp.status_code == 200 or resp.status_code == 404: 84 | # store returned data (or info that there's no data) to cache 85 | cache[ip] = data 86 | 87 | return data 88 | 89 | 90 | def on_request(ch, method, props, body): 91 | ip = str(body, 'utf-8') 92 | logger.debug("Received request for '{}'".format(ip)) 93 | response = get_shodan_data(ip) 94 | ch.basic_publish(exchange='', 95 | routing_key=props.reply_to, 96 | properties=pika.BasicProperties(correlation_id=props.correlation_id), 97 | body=response) 98 | ch.basic_ack(delivery_tag=method.delivery_tag) 99 | logger.debug("Sent response for '{}'".format(ip)) 100 | 101 | 102 | channel.basic_qos(prefetch_count=1) 103 | channel.basic_consume(queue='shodan_rpc_queue', on_message_callback=on_request) 104 | 105 | logger.info("*** Shodan request handler started, awaiting RPC requests ***") 106 | channel.start_consuming() 107 | -------------------------------------------------------------------------------- /common/eventdb_mentat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Proxy to event database in external Mentat instance. 3 | 4 | Provides MentatEventDatabase class -- a proxy for reading data from Mentat API. 5 | Mentat is supposed to receive the same data as NERD, but via its own channel. 6 | This module only reads the data, storage is not implemented. 7 | """ 8 | from __future__ import print_function 9 | 10 | import json 11 | import logging 12 | import datetime 13 | import requests 14 | 15 | from common.utils import parse_rfc_time 16 | 17 | 18 | class BadEntityType(ValueError): 19 | pass 20 | 21 | class NotConfigured(RuntimeError): 22 | pass 23 | 24 | class GatewayError(RuntimeError): 25 | pass 26 | 27 | class MentatEventDBProxy: 28 | """ 29 | Event database reading IDEA messages from external Mentat via its API. 30 | """ 31 | 32 | def __init__(self, config): 33 | """ 34 | Initialize all internal structures as necessary. 35 | """ 36 | self.log = logging.getLogger('MentatEventDBProxy') 37 | #self.log.setLevel('DEBUG') 38 | 39 | # Load URL and API key from config 40 | self.base_url = config.get('eventdb_mentat.url', None) # should point to Mentat base API (e.g. https://example.com/mentat/) 41 | self.api_key = config.get('eventdb_mentat.api_key', None) 42 | 43 | # Check presence and validity of config (only print error if invalid) 44 | if not self.base_url: 45 | self.log.error("Mentat API used but URL not configured ('eventdb_mentat.url' config entry is missing)") 46 | elif not (self.base_url.startswith("https://") or self.base_url.startswith("http://")): 47 | self.log.error("Invalid URL of Mentat API") 48 | self.base_url = None 49 | elif not self.base_url.endswith("/"): 50 | self.base_url += "/" # ensure base_url ends with a slash 51 | if not self.api_key: 52 | self.log.error("Mentat API used but api_key not configured ('eventdb_mentat.api_key' config entry is missing)") 53 | 54 | def get(self, etype, key, limit=None, dt_from=None): 55 | """ 56 | Return all events where given IP is among Sources. 57 | 58 | Arguments: 59 | etype entity type (str), must be 'ip' 60 | key entity identifier (str), e.g. '192.0.2.42' 61 | limit max number of returned events 62 | dt_from minimal value of DetectTime (datetime) 63 | 64 | Return a list of IDEA messages (strings). 65 | 66 | Raise BadEntityType if etype is not 'ip'. 67 | """ 68 | if etype != 'ip': 69 | raise BadEntityType("etype must be 'ip'") 70 | 71 | if not self.base_url or not self.api_key: 72 | raise NotConfigured("Mentat DB connection not properly configured") 73 | 74 | # Prepare request to Mentat API 75 | url = self.base_url + "api/events/search?submit=Search" 76 | url += "&source_addrs="+key 77 | url += "&limit=" + (str(limit) if limit is not None else "100") 78 | if dt_from: 79 | url += "&dt_from=" + dt_from.strftime("%Y-%m-%d %H:%M:%S") 80 | data = {"api_key": self.api_key} 81 | # Send request 82 | try: 83 | resp = requests.post(url, data) 84 | except Exception as e: 85 | raise GatewayError("Can't get data from Mentat database: " + str(e)) 86 | # Parse response 87 | if resp.status_code == 400 and "search query quota" in resp.text: 88 | raise GatewayError("Search query quota exceeded, try again later.") 89 | try: 90 | result = resp.json()['items'] 91 | except (ValueError, KeyError): 92 | #self.log.error("Invalid data received from Mentat database: req. URL: '{}', req. body: '{}', resp. code: {}, resp. body: '{}'".format(resp.request.url, resp.request.body, resp.status_code, resp.text)) 93 | raise GatewayError("Invalid data received from Mentat database") 94 | 95 | return result 96 | 97 | 98 | def put(self, ideas): 99 | """ 100 | Does nothing. Implemented only for compatibility with other EventDB layers. 101 | 102 | Arguments: 103 | ideas list of IDEA message parsed into Python-native structures 104 | """ 105 | return 106 | -------------------------------------------------------------------------------- /NERDd/modules/event_type_counter.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module determines main types of attack going from IP according to given parameters. 3 | """ 4 | 5 | from core.basemodule import NERDModule 6 | 7 | import g 8 | 9 | from datetime import datetime, timedelta 10 | import logging 11 | import os 12 | import re 13 | 14 | date_regex = re.compile("[0-9]{4}-[0-9]{2}-[0-9]{2}") 15 | 16 | class EventTypeCounter(NERDModule): 17 | """ 18 | EventTypeCounter module updates main event types for IP which triggered this module. 19 | Behavior of this module can be parameterized by time period, minimal total 20 | number of events and minimal required percentage per type (threshold) for 21 | event type determination. 22 | 23 | Event flow specification: 24 | [ip] 'events_meta.total' -> count_type() -> 'events_meta.types' 25 | """ 26 | 27 | def __init__(self): 28 | self.log = logging.getLogger("EventTypeCounter") 29 | self.event_days = g.config.get("event_type_counter.days", None) 30 | self.event_threshold = g.config.get("event_type_counter.threshold", 5) 31 | self.event_min = g.config.get("event_type_counter.min_num_of_events", 0) 32 | 33 | g.um.register_handler( 34 | self.count_type, 35 | 'ip', 36 | ('events_meta.total',), 37 | ('events_meta.types',) 38 | ) 39 | 40 | 41 | def count_type(self, ekey, rec, updates): 42 | """ 43 | Counts number of events for each event type for given time period (or counts all 44 | event if time period is not set). 45 | If total number of events is equal or greater than minimal required number of events and 46 | percentage of specific event type is equal or greater than threshold, this specific 47 | event type extends list of main event type for IP which triggered this module. 48 | 49 | Arguments: 50 | ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 51 | rec -- record currently assigned to the key 52 | updates -- list of all attributes whose update triggered this call and 53 | their new values (or events and their parameters) as a list of 54 | 2-tuples: [(attr, val), (!event, param), ...] 55 | 56 | Return: 57 | List of update requests. 58 | """ 59 | 60 | etype, key = ekey 61 | if etype != 'ip': 62 | return None 63 | 64 | if 'events' not in rec: 65 | return None # No Warden event, nothing to do 66 | 67 | ret = [] 68 | total_events = 0 69 | types = {} # Map: event_type -> count 70 | 71 | # Count number of events per type in last "event_days" days 72 | if self.event_days is None: 73 | minday = None 74 | else: 75 | minday = datetime.utcnow().date() - timedelta(days=self.event_days) 76 | # Let's compare days as strings - it works thanks to ISO format 77 | # and it's faster than to convert all string keys in DB to datetime 78 | minday = minday.strftime("%Y-%m-%d") 79 | for evtrec in rec['events']: 80 | if minday and evtrec['date'] < minday: 81 | continue 82 | cat = evtrec['cat'] 83 | n = evtrec['n'] 84 | total_events += n 85 | if cat not in types: 86 | types[cat] = n 87 | else: 88 | types[cat] += n 89 | 90 | if total_events < self.event_min: 91 | # if self.event_days is not None: 92 | # self.log.debug("In last {} days only {} events happened for IP {} (minimal number of events for classification is {}).".format(self.event_days, total_events, key, self.event_min)) 93 | # else: 94 | # self.log.debug("Only {} events happened for IP {} (minimal number of events for classification is {}).".format(total_events, key, self.event_min)) 95 | return [('set', 'events_meta.types', [])] 96 | 97 | for event_type in types: 98 | if (types[event_type]/total_events*100) >= self.event_threshold: 99 | # self.log.debug("Event type {} exceed {}% threshold for IP {} ({} events from {}).".format(event_type, self.event_threshold, key, types[event_type], total_events)) 100 | ret.append(event_type) 101 | # else: 102 | # self.log.debug("Event type {} doesn't exceed {}% threshold for IP {} ({} events from {}).".format(event_type, self.event_threshold, key, types[event_type], total_events)) 103 | 104 | return [('set', 'events_meta.types', ret)] 105 | -------------------------------------------------------------------------------- /scripts/fix_ref_cnt.js: -------------------------------------------------------------------------------- 1 | // This script recomputes all references between deifferent entities in NERD 2 | // database and set "_ref_cnt" fields accordingly. 3 | // If some record has no reference (an inconsistency which shouldn't normally 4 | // happen) it's removed. 5 | // 6 | // IMPORTANT: Stop NERDd before running the script! Database must not be 7 | // changed while the script is running. 8 | // 9 | // Note: JavaScript (and MongoShell) treats all numbers as floats. To store int 10 | // it must be written as NumberInt(0). 11 | 12 | 13 | // ** ip -> bgppref ** 14 | // Set _ref_cnt in "bgppref" records to number of IPs pointing to it from "ip" records 15 | // Reset _ref_cnt to 0 in all records 16 | db.bgppref.update({}, {$set: {_ref_cnt: NumberInt(0)}}, {multi: true}); 17 | // Count references and set _ref_cnt 18 | db.ip.aggregate([ 19 | {$match: {bgppref: {$exists: true}}}, 20 | {$project: {_id: 1, bgppref: 1}}, 21 | {$group: {_id: "$bgppref", cnt: {$sum: NumberInt(1)}}} 22 | ]).forEach( function(x) { 23 | db.bgppref.update({_id: x._id}, {$set: {_ref_cnt: NumberInt(x.cnt)}}) 24 | }); 25 | // Delete records with _ref_cnt = 0 (shouldn't normally happen) 26 | res = db.bgppref.remove({_ref_cnt: 0}); 27 | if (res["nRemoved"] > 0) { 28 | print("NOTICE: Removed " + res["nRemoved"] + " 'bgppref' records with no reference."); 29 | } 30 | 31 | 32 | // ** ip -> ipblock ** 33 | // Set _ref_cnt in "ipblock" records to number of IPs pointing to it from "ip" records 34 | // Reset _ref_cnt to 0 in all records 35 | db.ipblock.update({}, {$set: {_ref_cnt: NumberInt(0)}}, {multi: true}); 36 | // Count references and set _ref_cnt 37 | db.ip.aggregate([ 38 | {$match: {ipblock: {$exists: true}}}, 39 | {$project: {_id: 1, ipblock: 1}}, 40 | {$group: {_id: "$ipblock", cnt: {$sum: NumberInt(1)}}} 41 | ]).forEach( function(x) { 42 | db.ipblock.update({_id: x._id}, {$set: {_ref_cnt: NumberInt(x.cnt)}}) 43 | }); 44 | // Delete records with _ref_cnt = 0 (shouldn't normally happen) 45 | res = db.ipblock.remove({_ref_cnt: 0}); 46 | if (res["nRemoved"] > 0) { 47 | print("NOTICE: Removed " + res["nRemoved"] + " 'ipblock' records with no reference."); 48 | } 49 | 50 | 51 | // ** bgppref <-> asn ** 52 | // Reset array of pointers in "asn" to [] 53 | db.asn.update({}, {$set: {bgppref: []}}, {multi: true}); 54 | // For each "asn", set its list of pointers to all "bgpprefs" which point to the "asn" 55 | db.bgppref.aggregate([ 56 | {$match: {asn: {$exists: true}}}, 57 | {$project: {_id: 1, asn: 1}}, 58 | {$unwind: "$asn"}, 59 | {$group: {_id: "$asn", bgppref: {$push: "$_id"}}} 60 | ]).forEach( function(x) { 61 | db.asn.update({_id: x._id}, {$set: {bgppref: x.bgppref}}) 62 | }); 63 | // Delete records with empty list of pointers (shouldn't normally happen) 64 | res = db.asn.remove({bgppref: {$size: 0}}); 65 | if (res["nRemoved"] > 0) { 66 | print("NOTICE: Removed " + res["nRemoved"] + " 'asn' records with no reference."); 67 | } 68 | 69 | // Reset array of pointers in "bgppref" to [] 70 | db.bgppref.update({}, {$set: {asn: []}}, {multi: true}); 71 | // For each "bgppref", set its list of pointers to all "asns" which point to the "bgppref" 72 | db.asn.aggregate([ 73 | {$match: {bgppref: {$exists: true}}}, 74 | {$project: {_id: 1, bgppref: 1}}, 75 | {$unwind: "$bgppref"}, 76 | {$group: {_id: "$bgppref", asn: {$push: "$_id"}}} 77 | ]).forEach( function(x) { 78 | db.bgppref.update({_id: x._id}, {$set: {asn: x.asn.map(n => NumberInt(n))}}) 79 | }); 80 | // Delete records with empty list of pointers (shouldn't normally happen) 81 | res = db.bgppref.remove({asn: {$size: 0}}); 82 | if (res["nRemoved"] > 0) { 83 | print("NOTICE: Removed " + res["nRemoved"] + " 'bgppref' records with no reference."); 84 | } 85 | 86 | 87 | // ** asn/ipblock -> org ** 88 | // Set _ref_cnt in "org" records to number of IPs pointing to it from "asn" and "ipblock" records 89 | // Reset _ref_cnt to 0 in all records 90 | db.org.update({}, {$set: {_ref_cnt: NumberInt(0)}}, {multi: true}); 91 | db.asn.aggregate([ 92 | {$match: {org: {$exists: true}}}, 93 | {$project: {_id: 1, org: 1}}, 94 | {$group: {_id: "$org", cnt: {$sum: NumberInt(1)}}} 95 | ]).forEach( function(x) { 96 | db.org.update({_id: x._id}, {$inc: {_ref_cnt: NumberInt(x.cnt)}}) 97 | }); 98 | db.ipblock.aggregate([ 99 | {$match: {org: {$exists: true}}}, 100 | {$project: {_id: 1, org: 1}}, 101 | {$group: {_id: "$org", cnt: {$sum: NumberInt(1)}}} 102 | ]).forEach( function(x) { 103 | db.org.update({_id: x._id}, {$inc: {_ref_cnt: NumberInt(x.cnt)}}) 104 | }); 105 | // Delete records with _ref_cnt = 0 (shouldn't normally happen) 106 | res = db.org.remove({_ref_cnt: 0}); 107 | if (res["nRemoved"] > 0) { 108 | print("NOTICE: Removed " + res["nRemoved"] + " 'org' records with no reference."); 109 | } 110 | -------------------------------------------------------------------------------- /NERDd/modules/hostname.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module classifies type of service associated to given IP according to hostname. 3 | """ 4 | 5 | from core.basemodule import NERDModule 6 | 7 | import requests 8 | import re 9 | 10 | import g 11 | 12 | import datetime 13 | import logging 14 | import os 15 | 16 | class HostnameClass(NERDModule): 17 | """ 18 | HostnameClass module. 19 | Classifies IP according to hostname and given list of known domains and regular expressions 20 | 21 | Event flow specification: 22 | [ip] 'hostname' -> hostname_classify() -> 'hostname_class' 23 | """ 24 | 25 | def __init__(self): 26 | self.log = logging.getLogger("hostname_class") 27 | #self.log.setLevel("DEBUG") 28 | self.regex_hostname = g.config.get("hostname_tagging.regex_tagging", []) 29 | self.regex_ip_in_hostname = g.config.get("hostname_tagging.regex_tagging_ip_in_hostname", []) 30 | self.regex_hostname = [(re.compile(regex, flags=re.ASCII), tag) for regex,tag in self.regex_hostname] 31 | self.known_domains = self.convert_domain_list_to_dict(g.config.get("hostname_tagging.known_domains", [])) 32 | 33 | g.um.register_handler( 34 | self.hostname_classify, 35 | 'ip', 36 | ('hostname','!refresh_hostname_class'), # !refresh_hostname_class is called only manually, e.g. after change of tag configuration 37 | ('hostname_class',) 38 | ) 39 | 40 | def convert_domain_list_to_dict(self, domain_list): 41 | """ 42 | Converts list of domains and types of service to dictionary because of better time complexity of search operation. 43 | 44 | Arguments: 45 | domain_list -- list of lists of known domains and their type of service 46 | 47 | Return: 48 | Dicitonary with known domain as a key and type of service as a value 49 | """ 50 | 51 | ret = {} 52 | for domain in domain_list: 53 | ret[domain[0]] = domain[1] 54 | return ret 55 | 56 | def hostname_classify(self, ekey, rec, updates): 57 | """ 58 | Searches each hostname portion in known domain dictionary and sets type of service if match found. 59 | Tries to match hostname with regular expression and sets type of service if matches. 60 | 61 | Arguments: 62 | ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 63 | rec -- record currently assigned to the key 64 | updates -- list of all attributes whose update triggered this call and 65 | their new values (or events and their parameters) as a list of 66 | 2-tuples: [(attr, val), (!event, param), ...] 67 | 68 | Returns: 69 | List of update requests. 70 | """ 71 | 72 | etype, key = ekey 73 | if etype != 'ip': 74 | return None 75 | 76 | hostname = rec["hostname"] 77 | 78 | if hostname is None: 79 | self.log.debug("Hostname attribute is not filled for IP ({}).".format(key)) 80 | return None 81 | 82 | tags = [] 83 | 84 | dot_count = hostname.count(".") 85 | 86 | for i in range(0,dot_count): 87 | portion = hostname.split(".",i)[-1] 88 | if portion in self.known_domains: 89 | tag = self.known_domains[portion] 90 | self.log.debug("Hostname ({}) ends with domain {} and has been classified as {}.".format(hostname, portion, tag)) 91 | if tag not in tags: 92 | tags.append(tag) 93 | break 94 | 95 | # second simple implementation of ip in host check 96 | ip_in_host_prob = 0 97 | for octet in set(key.split(".")): 98 | if octet in hostname: 99 | ip_in_host_prob += 25 100 | if ip_in_host_prob >= 50: 101 | tags.append("ip_in_hostname") 102 | 103 | for regex in self.regex_hostname: 104 | search = regex[0].search(hostname) 105 | if search: 106 | tag = regex[1] 107 | if tag == "ip_in_hostname": 108 | # check if captured ip address is really ip address of this hostname 109 | ip_adress_matched = all([group in key for group in search.groups() if group]) 110 | if not ip_adress_matched: 111 | continue 112 | 113 | self.log.debug("Hostname ({}) matches regex {} and has been classified as {}.".format(hostname, regex[0].pattern, tag)) 114 | if tag not in tags: 115 | tags.append(tag) 116 | 117 | if tags: 118 | return [('set', 'hostname_class', tags)] 119 | else: 120 | # If hostname_class existed previously but no rule matches now, remove the key 121 | return [('remove', 'hostname_class')] 122 | -------------------------------------------------------------------------------- /NERDd/modules/event_counter.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module summarizing number of events in last day, week and month (30 days). 3 | 4 | Should be triggered at least once a day for every address. 5 | """ 6 | 7 | from core.basemodule import NERDModule 8 | import g 9 | 10 | import datetime 11 | 12 | EWMA_ALPHA = 0.25 # a parameter (there's no strong reason for the value selected, I just feel that 0.25 gives reasonable weights for the 7 day long period) 13 | EWMA_WEIGHTS = [(EWMA_ALPHA * (1 - EWMA_ALPHA)**i) for i in range(7)] 14 | 15 | # TODO - (re)compute sets of nodes for 1, 7 and 30 days as well (or do it in frontend?) 16 | 17 | class EventCounter(NERDModule): 18 | """ 19 | Module counting number of recent events. 20 | 21 | Periodically updates number of events in the last 1, 7 and 30 days. 22 | 23 | It's always the number of events in the current day (since 00:00 UTC) plus 24 | number of events in 1, 7 or 30 previous days. 25 | Therefore, "total1" contains number of events in previous 24 to 48 hours, 26 | depending on the time this function was triggered. 27 | 28 | The !refresh_event_count should be triggered when no event was added to 29 | the IP for more than 24 hours. 30 | 31 | Event flow specification: 32 | events.total -> count_events -> events_meta.{total1,total7,total30} 33 | !every1d -> count_events -> events_meta.{total1,total7,total30} 34 | 35 | # TODO: for now this hooks on events.total which is updated by event_receiver on every new event 36 | since it can't be hooked on events. because date is changing. 37 | It would be better to even allow to hook on "events.*" 38 | or to issue an "!NEW_EVENT" update request when a new event is added, and hook this to it instead. 39 | """ 40 | 41 | def __init__(self): 42 | g.um.register_handler( 43 | self.count_events, # function (or bound method) to call 44 | 'ip', # entity type 45 | ('events_meta.total','!every1d'), # tuple/list/set of attributes to watch (their update triggers call of the registered method) 46 | ('events_meta.total1','events_meta.total7','events_meta.total30', 47 | 'events_meta.nodes_1d','events_meta.nodes_7d','events_meta.nodes_30d', 48 | 'ewma','bin_ewma') # tuple/list/set of attributes the method may change 49 | ) 50 | 51 | 52 | def count_events(self, ekey, rec, updates): 53 | """ 54 | Count total number of events in last 1, 7 adn 30 days for given IP. 55 | 56 | Arguments: 57 | ekey -- two-tuple of entity type and key, e.g. ('ip', '192.0.2.42') 58 | rec -- record currently assigned to the key 59 | updates -- list of all attributes whose update triggered this call and 60 | their new values (or events and their parameters) as a list of 61 | 2-tuples: [(attr, val), (!event, param), ...] 62 | 63 | Returns: 64 | List of update requests (3-tuples describing requested attribute updates 65 | or events). 66 | In particular, the following updates are requested: 67 | ('set', 'events_meta.total{1,7,30}', number_of_events) 68 | ('set', 'events_meta.nodes_{1,7,30}d', number_of_unique_nodes) 69 | """ 70 | etype, key = ekey 71 | if etype != 'ip': 72 | return None 73 | 74 | if 'events' not in rec: 75 | return None # No Warden event, nothing to do 76 | 77 | today = datetime.datetime.utcnow().date() 78 | 79 | total1 = 0 80 | total7 = 0 81 | total30 = 0 82 | nodes1 = set() 83 | nodes7 = set() 84 | nodes30 = set() 85 | 86 | alerts_per_day = [0]*7; 87 | 88 | for evtrec in rec['events']: 89 | n = evtrec['n'] 90 | date = evtrec['date'] 91 | date = datetime.date(int(date[0:4]), int(date[5:7]), int(date[8:10])) 92 | days_diff = (today - date).days 93 | 94 | if days_diff <= 1: 95 | total1 += n 96 | nodes1.add(evtrec['node']) 97 | if days_diff <= 7: 98 | total7 += n 99 | nodes7.add(evtrec['node']) 100 | if days_diff <= 30: 101 | total30 += n 102 | nodes30.add(evtrec['node']) 103 | 104 | if days_diff < 7: 105 | alerts_per_day[days_diff] += n; 106 | 107 | return [ 108 | ('set', 'events_meta.total1', total1), 109 | ('set', 'events_meta.total7', total7), 110 | ('set', 'events_meta.total30', total30), 111 | ('set', 'events_meta.nodes_1d', len(nodes1)), 112 | ('set', 'events_meta.nodes_7d', len(nodes7)), 113 | ('set', 'events_meta.nodes_30d', len(nodes30)), 114 | ('set', 'events_meta.ewma', sum(n*w for n,w in zip(alerts_per_day, EWMA_WEIGHTS))), 115 | ('set', 'events_meta.bin_ewma', sum((w if n else 0) for n,w in zip(alerts_per_day, EWMA_WEIGHTS))) 116 | ] 117 | 118 | -------------------------------------------------------------------------------- /NERDd/dshield.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | import os 4 | import logging 5 | from datetime import datetime, timedelta 6 | import urllib.request 7 | from apscheduler.schedulers.background import BlockingScheduler 8 | import signal 9 | 10 | # Add to path the "one directory above the current file location" to find modules from "common" 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))) 12 | 13 | from common.config import read_config 14 | from common.task_queue import TaskQueueWriter 15 | 16 | # parse arguments 17 | parser = argparse.ArgumentParser( 18 | prog="dshield.py", 19 | description=""" 20 | NERD module for getting data from DShield daily sources. 21 | Every night (at 5:00 UTC) it downloads the daily feed from https://isc.sans.edu/feeds/daily_sources 22 | and creates/updates a record in NERD for each IP address there (with at least "min_reports" and "min_targets" 23 | from configuration). 24 | """ 25 | ) 26 | parser.add_argument('-c', '--config', metavar='FILENAME', default='/etc/nerd/nerd.yml', 27 | help='Path to configuration file (default: /etc/nerd/nerd.yml)') 28 | parser.add_argument('--now', action='store_true', 29 | help='Download and process data immediately after start (otherwise just schedule to download it every night') 30 | 31 | args = parser.parse_args() 32 | 33 | # Load config 34 | config = read_config(args.config) 35 | rabbit_config = config.get("rabbitmq") 36 | 37 | dshield_feed_url = config.get('dshield.url') 38 | min_reports = config.get('dshield.min_reports') 39 | min_targets = config.get('dshield.min_targets') 40 | dshield_ttl_days = config.get('record_life_length.dshield', 7) 41 | 42 | # rabbitMQ 43 | num_processes = config.get('worker_processes') 44 | tq_writer = TaskQueueWriter(num_processes, rabbit_config) 45 | tq_writer.connect() 46 | 47 | # Logging 48 | LOGFORMAT = "%(asctime)-15s,%(name)s [%(levelname)s] %(message)s" 49 | LOGDATEFORMAT = "%Y-%m-%dT%H:%M:%S" 50 | logging.basicConfig(level=logging.INFO, format=LOGFORMAT, datefmt=LOGDATEFORMAT) 51 | 52 | logger = logging.getLogger('DShield') 53 | 54 | scheduler = BlockingScheduler(timezone='UTC') 55 | 56 | # Signal handler to stop scheduler gracefully 57 | def sigint_handler(signum, frame): 58 | logger.info("Signal {} received, going to stop".format({signal.SIGINT: "SIGINT", signal.SIGTERM: "SIGTERM"}.get(signum, signum))) 59 | scheduler.shutdown(wait=True) 60 | 61 | def process_feed(feed_data): 62 | logger.info("Processing the feed ...") 63 | data_date = datetime.utcnow() - timedelta(days=1) # Downloaded data dump comes from the previous day 64 | date_str = data_date.strftime("%Y-%m-%d") 65 | ttl_date = data_date + timedelta(days=dshield_ttl_days) 66 | ips = {} 67 | for record in feed_data: 68 | record_data = record.split('\t') 69 | # parse IP addresses with leading zeros 70 | ip_addr_splitted = record_data[0].split('.') 71 | ip_addr = "" 72 | for number in ip_addr_splitted: 73 | number = number.lstrip('0') 74 | if number == "": 75 | number = '0' 76 | ip_addr += number + '.' 77 | ip_addr = ip_addr.rstrip('.') 78 | 79 | reports = int(record_data[3]) 80 | targets = int(record_data[4]) 81 | if ip_addr in ips: 82 | ips[ip_addr]["reports"] += reports 83 | ips[ip_addr]["targets"] += targets 84 | else: 85 | ips[ip_addr] = {"reports" : reports, "targets" : targets} 86 | 87 | logger.info(f"Creating tasks to update {len(ips)} IPs ...") 88 | for ip_addr in ips: 89 | if (ips[ip_addr]["reports"] < min_reports) or (ips[ip_addr]["targets"] < min_targets): 90 | continue 91 | tq_writer.put_task('ip', ip_addr, [ 92 | ('array_upsert', 'dshield', {'date' : date_str}, 93 | [('set', 'reports', ips[ip_addr]["reports"]), 94 | ('set', 'targets', ips[ip_addr]["targets"])]), 95 | ('setmax', '_ttl.dshield', ttl_date), 96 | ], "dshield") 97 | logger.info("Tasks created") 98 | 99 | def download_feed(): 100 | logger.info("Downloading feed ...") 101 | feed = urllib.request.urlopen(dshield_feed_url) 102 | if feed.getcode() == 200: 103 | feed_data = [line.decode() for line in feed.readlines() if not line.decode().startswith('#')] 104 | else: 105 | logger.error("Cannot download feed. Response status code: {}".format(feed.getcode())) 106 | sys.exit(1) 107 | logger.info(f"Feed successfully downloaded. {len(feed_data)} records found.") 108 | process_feed(feed_data) 109 | 110 | if __name__ == "__main__": 111 | if args.now: 112 | download_feed() 113 | # Start scheduler to get new feed every day, register signal handler 114 | scheduler.add_job(download_feed, 'cron', hour='5') 115 | signal.signal(signal.SIGINT, sigint_handler) 116 | signal.signal(signal.SIGTERM, sigint_handler) 117 | signal.signal(signal.SIGABRT, sigint_handler) 118 | scheduler.start() 119 | logger.info("Stopped") 120 | 121 | -------------------------------------------------------------------------------- /NERDd/modules/caida_as_class.py: -------------------------------------------------------------------------------- 1 | """ 2 | NERD module tries to classify IPs according to their business type. It uses ASN and caida classification list . 3 | """ 4 | 5 | from core.basemodule import NERDModule 6 | 7 | import g 8 | 9 | import datetime 10 | import logging 11 | import os 12 | 13 | class CaidaASclass(NERDModule): 14 | """ 15 | CaidaASclass module. 16 | Parses Caida AS classification list of ASN and determines bussiness usage of IP. 17 | 18 | Event flow specification: 19 | [asn] '!NEW' -> determine_type() -> 'caida_as_class.v' and 'caida_as_class.c' 20 | """ 21 | 22 | def __init__(self): 23 | self.log = logging.getLogger("CaidaASclass") 24 | self.caida = g.config.get("caida", None) 25 | if not self.caida or not self.caida.get("caida_file", False): 26 | self.log.warning("Configuration for CaidaASclass module not found - module is disabled.") 27 | return 28 | 29 | self.caida_dict = self.parse_list(self.caida.get("caida_file")) 30 | if not self.caida_dict: 31 | return 32 | 33 | g.um.register_handler( 34 | self.determine_type, 35 | 'asn', 36 | ('!NEW',), 37 | ('caida_as_class.v', 'caida_as_class.c') 38 | ) 39 | 40 | def parse_list(self, path): 41 | """ 42 | Parses caida list from given file and returns it as a dictionary. 43 | 44 | Arguments: 45 | path -- path of file with list of ASes, their sources and classes 46 | 47 | Return: 48 | Dictionary with AS number as a key and dictionary with source and class (class name from configuration file is used if is set) as a value 49 | """ 50 | 51 | self.log.debug("Start parsing Caida list stored at path {}.".format(path)) 52 | ASN_dictionary = {} 53 | 54 | try: 55 | with open(path) as f : 56 | for line in f: 57 | if not line.startswith("#"): 58 | line = line.strip() 59 | data = line.split("|") 60 | ASN_data = {} 61 | if "classes" in self.caida and data[2] in self.caida["classes"] and "value" in self.caida["classes"][data[2]]: 62 | ASN_data = {"source": data[1] , "class": self.caida["classes"][data[2]]["value"]} 63 | else: 64 | ASN_data = {"source": data[1] , "class": data[2]} 65 | try: 66 | asn_num = int(data[0]) 67 | ASN_dictionary[asn_num] = ASN_data 68 | except ValueError: 69 | self.log.error("Can't parse line starting with '{}' - it's not number.".format(data[0])) 70 | except Exception as e: 71 | self.log.error("Can't parse Caida list file ({}): {} .".format(path, str(e))) 72 | return {} 73 | 74 | self.log.info("Parsed Caida ASN list (path: {}) as dictionary with {} ASNs.".format(path, len(ASN_dictionary))) 75 | return ASN_dictionary 76 | 77 | 78 | def search_in_dict(self, asn): 79 | """ 80 | Searches given AS number in dictionary and returns source, class and confidence 81 | 82 | Arguments: 83 | asn -- AS number 84 | 85 | Return: 86 | Dictionary with source, class and confidence (can be specified in configuration file for each class- otherwise confidence set to 1) for AS 87 | returns None if AS is not found in dict 88 | """ 89 | 90 | if asn in self.caida_dict: 91 | res = self.caida_dict[asn] 92 | if "sources" in self.caida and res["source"] in self.caida["sources"] and "confidence" in self.caida["sources"][res["source"]]: 93 | res["confidence"] = self.caida["sources"][res["source"]]["confidence"] 94 | return res 95 | else: 96 | res["confidence"] = 1 97 | return res 98 | return None 99 | 100 | def determine_type(self, ekey, rec, updates): 101 | """ 102 | Classifies IP according to its AS number 103 | 104 | Arguments: 105 | ekey -- two-tuple of entity type and key, e.g. ('asn', 2582) 106 | rec -- record currently assigned to the key 107 | updates -- list of all attributes whose update triggered this call and 108 | their new values (or events and their parameters) as a list of 109 | 2-tuples: [(attr, val), (!event, param), ...] 110 | 111 | Return: 112 | List of update requests. 113 | """ 114 | etype, key = ekey 115 | if etype != 'asn': 116 | return None 117 | 118 | res = self.search_in_dict(key) 119 | if res is not None: 120 | self.log.debug("ASN: {} has class: {} (source: {}, confidence: {}) according to CAIDA.".format(key, res["class"], res["source"], res["confidence"])) 121 | ret = [('set', 'caida_as_class.v', res["class"])] 122 | if res["confidence"] != 1: 123 | ret.append(('set', 'caida_as_class.c', res["confidence"])) 124 | else: 125 | ret = [('set', 'caida_as_class.v', 'unknown')] 126 | return ret 127 | --------------------------------------------------------------------------------