Merge branch 'feature/support-subnet-in-config' into feature/detect-cross-segment-traffic
This commit is contained in:
commit
c6881e6147
|
@ -4,6 +4,8 @@
|
|||
* [ ] Have you added an explanation of what your changes do and why you'd like to include them?
|
||||
* [ ] Have you successfully tested your changes locally?
|
||||
|
||||
* Example screenshot/log transcript of the feature working
|
||||
|
||||
## Changes
|
||||
-
|
||||
-
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 153 KiB |
Binary file not shown.
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
12
README.md
12
README.md
|
@ -6,13 +6,15 @@ Infection Monkey
|
|||
|
||||
Welcome to the Infection Monkey!
|
||||
|
||||
The Infection Monkey is an open source security tool for testing a data center's resiliency to perimeter breaches and internal server infection. The Monkey uses various methods to self propagate across a data center and reports success to a centralized Command and Control(C&C) server.
|
||||
The Infection Monkey is an open source security tool for testing a data center's resiliency to perimeter breaches and internal server infection. The Monkey uses various methods to self propagate across a data center and reports success to a centralized Monkey Island server.
|
||||
|
||||
![Infection Monkey map](.github/map-full.png)
|
||||
<img src=".github/map-full.png" >
|
||||
|
||||
<img src=".github/Security-overview.png" width="800" height="500">
|
||||
|
||||
The Infection Monkey is comprised of two parts:
|
||||
* Monkey - A tool which infects other machines and propagates to them
|
||||
* Monkey Island - A C&C server with a dedicated UI to visualize the Chaos Monkey's progress inside the data center
|
||||
* Monkey Island - A dedicated server to control and visualize the Infection Monkey's progress inside the data center
|
||||
|
||||
To read more about the Monkey, visit http://infectionmonkey.com
|
||||
|
||||
|
@ -24,7 +26,7 @@ The Infection Monkey uses the following techniques and exploits to propagate to
|
|||
* Multiple propagation techniques:
|
||||
* Predefined passwords
|
||||
* Common logical exploits
|
||||
* Password stealing using mimikatz
|
||||
* Password stealing using Mimikatz
|
||||
* Multiple exploit methods:
|
||||
* SSH
|
||||
* SMB
|
||||
|
@ -43,7 +45,7 @@ Check out the [Setup](https://github.com/guardicore/monkey/wiki/setup) page in t
|
|||
Building the Monkey from source
|
||||
-------------------------------
|
||||
If you want to build the monkey from source, see [Setup](https://github.com/guardicore/monkey/wiki/setup)
|
||||
and follow the instructions at the readme files under [chaos_monkey](chaos_monkey) and [monkey_island](monkey_island).
|
||||
and follow the instructions at the readme files under [infection_monkey](infection_monkey) and [monkey_island](monkey_island).
|
||||
|
||||
|
||||
License
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
import random
|
||||
import socket
|
||||
import struct
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import ipaddress
|
||||
|
||||
from model.host import VictimHost
|
||||
|
||||
__author__ = 'itamar'
|
||||
|
||||
|
||||
class NetworkRange(object):
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
def __init__(self, base_address, shuffle=True):
|
||||
self._base_address = base_address
|
||||
self._shuffle = shuffle
|
||||
self._config = __import__('config').WormConfiguration
|
||||
|
||||
@staticmethod
|
||||
def _ip_to_number(address):
|
||||
return struct.unpack(">L", socket.inet_aton(address))[0]
|
||||
|
||||
@staticmethod
|
||||
def _number_to_ip(num):
|
||||
return socket.inet_ntoa(struct.pack(">L", num))
|
||||
|
||||
@abstractmethod
|
||||
def _get_range(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def __iter__(self):
|
||||
base_range = self._get_range()
|
||||
if self._shuffle:
|
||||
random.shuffle(base_range)
|
||||
|
||||
for x in base_range:
|
||||
yield VictimHost(self._number_to_ip(self._base_address + x))
|
||||
|
||||
|
||||
class ClassCRange(NetworkRange):
|
||||
def __init__(self, base_address, shuffle=True):
|
||||
base_address = struct.unpack(">L", socket.inet_aton(base_address))[0] & 0xFFFFFF00
|
||||
super(ClassCRange, self).__init__(base_address, shuffle=shuffle)
|
||||
|
||||
def __repr__(self):
|
||||
return "<ClassCRange %s-%s>" % (self._number_to_ip(self._base_address + 1),
|
||||
self._number_to_ip(self._base_address + 254))
|
||||
|
||||
def _get_range(self):
|
||||
return range(1, 254)
|
||||
|
||||
|
||||
class RelativeRange(NetworkRange):
|
||||
def __init__(self, base_address, shuffle=True):
|
||||
base_address = struct.unpack(">L", socket.inet_aton(base_address))[0]
|
||||
super(RelativeRange, self).__init__(base_address, shuffle=shuffle)
|
||||
self._size = 1
|
||||
|
||||
def __repr__(self):
|
||||
return "<RelativeRange %s-%s>" % (self._number_to_ip(self._base_address - self._size),
|
||||
self._number_to_ip(self._base_address + self._size))
|
||||
|
||||
def _get_range(self):
|
||||
lower_end = -(self._size / 2)
|
||||
higher_end = lower_end + self._size
|
||||
return range(lower_end, higher_end + 1)
|
||||
|
||||
|
||||
class FixedRange(NetworkRange):
|
||||
def __init__(self, fixed_addresses, shuffle=True):
|
||||
base_address = 0
|
||||
super(FixedRange, self).__init__(base_address, shuffle=shuffle)
|
||||
self._fixed_addresses = fixed_addresses
|
||||
|
||||
def __repr__(self):
|
||||
return "<FixedRange %s>" % (",".join(self._fixed_addresses))
|
||||
|
||||
@staticmethod
|
||||
def _cidr_range_to_ip_list(address_str):
|
||||
return [FixedRange._ip_to_number(str(x)) for x in ipaddress.ip_network(unicode(address_str), strict=False)]
|
||||
|
||||
@staticmethod
|
||||
def _ip_range_to_ip_list(address_str):
|
||||
addresses = address_str.split('-')
|
||||
if len(addresses) != 2:
|
||||
raise ValueError('Illegal address format: %s' % address_str)
|
||||
lower_end, higher_end = [FixedRange._ip_to_number(x.strip()) for x in addresses]
|
||||
if higher_end < lower_end:
|
||||
raise ValueError('Illegal address range: %s' % address_str)
|
||||
return range(lower_end, higher_end + 1)
|
||||
|
||||
@staticmethod
|
||||
def _parse_address_str(address_str):
|
||||
address_str = address_str.strip()
|
||||
if not address_str: # Empty string
|
||||
return []
|
||||
if -1 != address_str.find('-'):
|
||||
return FixedRange._ip_range_to_ip_list(address_str)
|
||||
if -1 != address_str.find('/'):
|
||||
return FixedRange._cidr_range_to_ip_list(address_str)
|
||||
return [FixedRange._ip_to_number(address_str)]
|
||||
|
||||
def _get_range(self):
|
||||
ip_list = list(reduce(
|
||||
lambda x, y: x.union(y),
|
||||
[set(self._parse_address_str(z)) for z in self._fixed_addresses]))
|
||||
return [x for x in ip_list if (x & 0xFF != 0)] # remove broadcast ips
|
|
@ -8,7 +8,6 @@ from itertools import product
|
|||
from exploit import WmiExploiter, Ms08_067_Exploiter, SmbExploiter, RdpExploiter, SSHExploiter, ShellShockExploiter, \
|
||||
SambaCryExploiter, ElasticGroovyExploiter
|
||||
from network import TcpScanner, PingScanner, SMBFinger, SSHFinger, HTTPFinger, MySQLFinger, ElasticFinger
|
||||
from network.range import FixedRange
|
||||
|
||||
__author__ = 'itamar'
|
||||
|
||||
|
@ -182,7 +181,6 @@ class Configuration(object):
|
|||
# Auto detect and scan local subnets
|
||||
local_network_scan = True
|
||||
|
||||
range_class = FixedRange
|
||||
range_fixed = ['', ]
|
||||
inaccessible_subnet_groups = []
|
||||
|
|
@ -25,7 +25,7 @@ class ControlClient(object):
|
|||
|
||||
@staticmethod
|
||||
def wakeup(parent=None, default_tunnel=None, has_internet_access=None):
|
||||
LOG.debug("Trying to wake up with C&C servers list: %r" % WormConfiguration.command_servers)
|
||||
LOG.debug("Trying to wake up with Monkey Island servers list: %r" % WormConfiguration.command_servers)
|
||||
if parent or default_tunnel:
|
||||
LOG.debug("parent: %s, default_tunnel: %s" % (parent, default_tunnel))
|
||||
hostname = gethostname()
|
|
@ -7,7 +7,6 @@
|
|||
"www.google.com"
|
||||
],
|
||||
"keep_tunnel_open_time": 60,
|
||||
"range_class": "RelativeRange",
|
||||
"range_fixed": [
|
||||
""
|
||||
],
|
|
@ -11,7 +11,7 @@ import traceback
|
|||
from config import WormConfiguration, EXTERNAL_CONFIG_FILE
|
||||
from dropper import MonkeyDrops
|
||||
from model import MONKEY_ARG, DROPPER_ARG
|
||||
from monkey import ChaosMonkey
|
||||
from monkey import InfectionMonkey
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
@ -80,7 +80,7 @@ def main():
|
|||
if MONKEY_ARG == monkey_mode:
|
||||
log_path = os.path.expandvars(
|
||||
WormConfiguration.monkey_log_path_windows) if sys.platform == "win32" else WormConfiguration.monkey_log_path_linux
|
||||
monkey_cls = ChaosMonkey
|
||||
monkey_cls = InfectionMonkey
|
||||
elif DROPPER_ARG == monkey_mode:
|
||||
log_path = os.path.expandvars(
|
||||
WormConfiguration.dropper_log_path_windows) if sys.platform == "win32" else WormConfiguration.dropper_log_path_linux
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 232 KiB |
|
@ -19,7 +19,7 @@ __author__ = 'itamar'
|
|||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChaosMonkey(object):
|
||||
class InfectionMonkey(object):
|
||||
def __init__(self, args):
|
||||
self._keep_running = False
|
||||
self._exploited_machines = set()
|
|
@ -8,6 +8,7 @@ import itertools
|
|||
import netifaces
|
||||
from subprocess import check_output
|
||||
from random import randint
|
||||
from range import CidrRange
|
||||
|
||||
|
||||
def get_host_subnets():
|
||||
|
@ -129,7 +130,7 @@ def check_internet_access(services):
|
|||
return False
|
||||
|
||||
|
||||
def get_ips_from_interfaces():
|
||||
def get_interfaces_ranges():
|
||||
"""
|
||||
Returns a list of IPs accessible in the host in each network interface, in the subnet.
|
||||
Limits to a single class C if the network is larger
|
||||
|
@ -138,15 +139,14 @@ def get_ips_from_interfaces():
|
|||
res = []
|
||||
ifs = get_host_subnets()
|
||||
for net_interface in ifs:
|
||||
address_str = unicode(net_interface['addr'])
|
||||
netmask_str = unicode(net_interface['netmask'])
|
||||
host_address = ipaddress.ip_address(address_str)
|
||||
address_str = net_interface['addr']
|
||||
netmask_str = net_interface['netmask']
|
||||
ip_interface = ipaddress.ip_interface(u"%s/%s" % (address_str, netmask_str))
|
||||
# limit subnet scans to class C only
|
||||
if ip_interface.network.num_addresses > 255:
|
||||
ip_interface = ipaddress.ip_interface(u"%s/24" % address_str)
|
||||
addrs = [str(addr) for addr in ip_interface.network.hosts() if addr != host_address]
|
||||
res.extend(addrs)
|
||||
res.append(CidrRange(cidr_range="%s/24" % (address_str, )))
|
||||
else:
|
||||
res.append(CidrRange(cidr_range="%s/%s" % (address_str, netmask_str)))
|
||||
return res
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import logging
|
|||
import time
|
||||
|
||||
from config import WormConfiguration
|
||||
from info import local_ips, get_ips_from_interfaces
|
||||
from info import local_ips, get_interfaces_ranges
|
||||
from range import *
|
||||
from . import HostScanner
|
||||
|
||||
|
@ -20,9 +20,8 @@ class NetworkScanner(object):
|
|||
|
||||
def initialize(self):
|
||||
"""
|
||||
Set up scanning based on configuration
|
||||
FixedRange -> Reads from range_fixed field in configuration
|
||||
otherwise, takes a range from every IP address the current host has.
|
||||
Set up scanning.
|
||||
based on configuration: scans local network and/or scans fixed list of IPs/subnets.
|
||||
:return:
|
||||
"""
|
||||
# get local ip addresses
|
||||
|
@ -33,13 +32,9 @@ class NetworkScanner(object):
|
|||
|
||||
LOG.info("Found local IP addresses of the machine: %r", self._ip_addresses)
|
||||
# for fixed range, only scan once.
|
||||
if WormConfiguration.range_class is FixedRange:
|
||||
self._ranges = [WormConfiguration.range_class(fixed_addresses=WormConfiguration.range_fixed)]
|
||||
else:
|
||||
self._ranges = [WormConfiguration.range_class(ip_address)
|
||||
for ip_address in self._ip_addresses]
|
||||
self._ranges = [NetworkRange.get_range_obj(address_str=x) for x in WormConfiguration.range_fixed]
|
||||
if WormConfiguration.local_network_scan:
|
||||
self._ranges += [FixedRange([ip_address for ip_address in get_ips_from_interfaces()])]
|
||||
self._ranges += get_interfaces_ranges()
|
||||
LOG.info("Base local networks to scan are: %r", self._ranges)
|
||||
|
||||
def get_victim_machines(self, scan_type, max_find=5, stop_callback=None):
|
|
@ -0,0 +1,146 @@
|
|||
import random
|
||||
import socket
|
||||
import struct
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import ipaddress
|
||||
|
||||
from model.host import VictimHost
|
||||
|
||||
__author__ = 'itamar'
|
||||
|
||||
|
||||
class NetworkRange(object):
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
def __init__(self, base_address, shuffle=True):
|
||||
self._base_address = base_address
|
||||
self._shuffle = shuffle
|
||||
self._config = __import__('config').WormConfiguration
|
||||
|
||||
def get_range(self):
|
||||
return [x for x in self._get_range() if (x & 0xFF != 0)] # remove broadcast ips
|
||||
|
||||
def __iter__(self):
|
||||
base_range = self.get_range()
|
||||
if self._shuffle:
|
||||
random.shuffle(base_range)
|
||||
|
||||
for x in base_range:
|
||||
yield VictimHost(self._number_to_ip(self._base_address + x))
|
||||
|
||||
@abstractmethod
|
||||
def _get_range(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def get_range_obj(address_str):
|
||||
address_str = address_str.strip()
|
||||
if not address_str: # Empty string
|
||||
return None
|
||||
if -1 != address_str.find('-'):
|
||||
return IpRange(ip_range=address_str)
|
||||
if -1 != address_str.find('/'):
|
||||
return CidrRange(cidr_range=address_str)
|
||||
return SingleIpRange(ip_address=address_str)
|
||||
|
||||
@staticmethod
|
||||
def _ip_to_number(address):
|
||||
return struct.unpack(">L", socket.inet_aton(address))[0]
|
||||
|
||||
@staticmethod
|
||||
def _number_to_ip(num):
|
||||
return socket.inet_ntoa(struct.pack(">L", num))
|
||||
|
||||
|
||||
class ClassCRange(NetworkRange):
|
||||
def __init__(self, base_address, shuffle=True):
|
||||
base_address = struct.unpack(">L", socket.inet_aton(base_address))[0] & 0xFFFFFF00
|
||||
super(ClassCRange, self).__init__(base_address, shuffle=shuffle)
|
||||
|
||||
def __repr__(self):
|
||||
return "<ClassCRange %s-%s>" % (self._number_to_ip(self._base_address + 1),
|
||||
self._number_to_ip(self._base_address + 254))
|
||||
|
||||
def _get_range(self):
|
||||
return range(1, 254)
|
||||
|
||||
|
||||
class RelativeRange(NetworkRange):
|
||||
def __init__(self, base_address, shuffle=True):
|
||||
base_address = struct.unpack(">L", socket.inet_aton(base_address))[0]
|
||||
super(RelativeRange, self).__init__(base_address, shuffle=shuffle)
|
||||
self._size = 1
|
||||
|
||||
def __repr__(self):
|
||||
return "<RelativeRange %s-%s>" % (self._number_to_ip(self._base_address - self._size),
|
||||
self._number_to_ip(self._base_address + self._size))
|
||||
|
||||
def _get_range(self):
|
||||
lower_end = -(self._size / 2)
|
||||
higher_end = lower_end + self._size
|
||||
return range(lower_end, higher_end + 1)
|
||||
|
||||
|
||||
class CidrRange(NetworkRange):
|
||||
def __init__(self, cidr_range, shuffle=True):
|
||||
base_address = 0
|
||||
super(CidrRange, self).__init__(base_address, shuffle=shuffle)
|
||||
self._cidr_range = cidr_range.strip()
|
||||
self._ip_network = ipaddress.ip_network(unicode(self._cidr_range), strict=False)
|
||||
|
||||
def __repr__(self):
|
||||
return "<CidrRange %s>" % (self._cidr_range, )
|
||||
|
||||
def is_in_range(self, ip_address):
|
||||
return ipaddress.ip_address(ip_address) in self._ip_network
|
||||
|
||||
def _get_range(self):
|
||||
return [CidrRange._ip_to_number(str(x)) for x in self._ip_network]
|
||||
|
||||
|
||||
class IpRange(NetworkRange):
|
||||
def __init__(self, ip_range=None, lower_end_ip=None, higher_end_ip=None, shuffle=True):
|
||||
base_address = 0
|
||||
super(IpRange, self).__init__(base_address, shuffle=shuffle)
|
||||
if ip_range is not None:
|
||||
addresses = ip_range.split('-')
|
||||
if len(addresses) != 2:
|
||||
raise ValueError('Illegal IP range format: %s' % ip_range)
|
||||
self._lower_end_ip, self._higher_end_ip = [x.strip() for x in addresses]
|
||||
if self._higher_end_ip < self._lower_end_ip:
|
||||
raise ValueError('Higher end IP is smaller than lower end IP: %s' % ip_range)
|
||||
elif (lower_end_ip is not None) and (higher_end_ip is not None):
|
||||
self._lower_end_ip = lower_end_ip
|
||||
self._higher_end_ip = higher_end_ip
|
||||
else:
|
||||
raise ValueError('Illegal IP range: %s' % ip_range)
|
||||
|
||||
self._lower_end_ip_num = IpRange._ip_to_number(self._lower_end_ip)
|
||||
self._higher_end_ip_num = IpRange._ip_to_number(self._higher_end_ip)
|
||||
|
||||
def __repr__(self):
|
||||
return "<IpRange %s-%s>" % (self._lower_end_ip, self._higher_end_ip)
|
||||
|
||||
def is_in_range(self, ip_address):
|
||||
return self._lower_end_ip_num <= IpRange._ip_to_number(ip_address) <= self._higher_end_ip_num
|
||||
|
||||
def _get_range(self):
|
||||
return range(self._lower_end_ip_num, self._higher_end_ip_num + 1)
|
||||
|
||||
|
||||
class SingleIpRange(NetworkRange):
|
||||
def __init__(self, ip_address, shuffle=True):
|
||||
base_address = 0
|
||||
super(SingleIpRange, self).__init__(base_address, shuffle=shuffle)
|
||||
self._ip_address = ip_address
|
||||
|
||||
def __repr__(self):
|
||||
return "<SingleIpRange %s>" % (self._ip_address,)
|
||||
|
||||
def is_in_range(self, ip_address):
|
||||
return self._ip_address == ip_address
|
||||
|
||||
def _get_range(self):
|
||||
return [SingleIpRange._ip_to_number(self._ip_address)]
|
||||
|
|
@ -28,13 +28,13 @@ The monkey is composed of three separate parts.
|
|||
64bit: http://www.microsoft.com/en-us/download/details.aspx?id=13523
|
||||
6. Download the dependent python packages using
|
||||
pip install -r requirements.txt
|
||||
7. Download and extract UPX binary to [source-path]\monkey\chaos_monkey\bin\upx.exe:
|
||||
7. Download and extract UPX binary to [source-path]\monkey\infection_monkey\bin\upx.exe:
|
||||
https://github.com/upx/upx/releases/download/v3.94/upx394w.zip
|
||||
8. Build/Download Sambacry and Mimikatz binaries
|
||||
a. Build/Download according to sections at the end of this readme.
|
||||
b. Place the binaries under [code location]\chaos_monkey\bin
|
||||
b. Place the binaries under [code location]\infection_monkey\bin
|
||||
9. To build the final exe:
|
||||
cd [code location]/chaos_monkey
|
||||
cd [code location]/infection_monkey
|
||||
build_windows.bat
|
||||
output is placed under dist\monkey.exe
|
||||
|
||||
|
@ -46,13 +46,13 @@ Tested on Ubuntu 16.04 and 17.04.
|
|||
sudo apt-get update
|
||||
sudo apt-get install python-pip python-dev libffi-dev upx libssl-dev libc++1
|
||||
Install the python packages listed in requirements.txt using pip
|
||||
cd [code location]/chaos_monkey
|
||||
cd [code location]/infection_monkey
|
||||
pip install -r requirements.txt
|
||||
2. Build Sambacry binaries
|
||||
a. Build/Download according to sections at the end of this readme.
|
||||
b. Place the binaries under [code location]\chaos_monkey\bin
|
||||
b. Place the binaries under [code location]\infection_monkey\bin
|
||||
3. To build, run in terminal:
|
||||
cd [code location]/chaos_monkey
|
||||
cd [code location]/infection_monkey
|
||||
chmod +x build_linux.sh
|
||||
./build_linux.sh
|
||||
output is placed under dist/monkey
|
||||
|
@ -63,11 +63,11 @@ Sambacry requires two standalone binaries to execute remotely.
|
|||
1. Install gcc-multilib if it's not installed
|
||||
sudo apt-get install gcc-multilib
|
||||
2. Build the binaries
|
||||
cd [code location]/chaos_monkey/monkey_utils/sambacry_monkey_runner
|
||||
cd [code location]/infection_monkey/monkey_utils/sambacry_monkey_runner
|
||||
./build.sh
|
||||
|
||||
-- Mimikatz --
|
||||
|
||||
Mimikatz is required for the Monkey to be able to steal credentials on Windows. It's possible to either compile from sources (requires Visual Studio 2013 and up) or download the binaries from
|
||||
https://github.com/guardicore/mimikatz/releases/tag/1.0.0
|
||||
Download both 32 and 64 bit DLLs and place them under [code location]\chaos_monkey\bin
|
||||
Download both 32 and 64 bit DLLs and place them under [code location]\infection_monkey\bin
|
|
@ -1,5 +1,5 @@
|
|||
# -*- coding: UTF-8 -*-
|
||||
# NOTE: Launch all tests with `nosetests` command from chaos_monkey dir.
|
||||
# NOTE: Launch all tests with `nosetests` command from infection_monkey dir.
|
||||
|
||||
import json
|
||||
import unittest
|
|
@ -1,22 +1,26 @@
|
|||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import bson
|
||||
from bson.json_util import dumps
|
||||
from flask import Flask, send_from_directory, redirect, make_response
|
||||
import flask_restful
|
||||
from bson.json_util import dumps
|
||||
from flask import Flask, send_from_directory, make_response
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from cc.auth import init_jwt
|
||||
from cc.database import mongo
|
||||
from cc.environment.environment import env
|
||||
from cc.resources.client_run import ClientRun
|
||||
from cc.resources.monkey import Monkey
|
||||
from cc.resources.edge import Edge
|
||||
from cc.resources.local_run import LocalRun
|
||||
from cc.resources.telemetry import Telemetry
|
||||
from cc.resources.monkey import Monkey
|
||||
from cc.resources.monkey_configuration import MonkeyConfiguration
|
||||
from cc.resources.monkey_download import MonkeyDownload
|
||||
from cc.resources.netmap import NetMap
|
||||
from cc.resources.edge import Edge
|
||||
from cc.resources.node import Node
|
||||
from cc.resources.report import Report
|
||||
from cc.resources.root import Root
|
||||
from cc.resources.telemetry import Telemetry
|
||||
from cc.resources.telemetry_feed import TelemetryFeed
|
||||
from cc.services.config import ConfigService
|
||||
|
||||
|
@ -70,6 +74,12 @@ def init_app(mongo_url):
|
|||
api.representations = {'application/json': output_json}
|
||||
|
||||
app.config['MONGO_URI'] = mongo_url
|
||||
|
||||
app.config['SECRET_KEY'] = os.urandom(32)
|
||||
app.config['JWT_AUTH_URL_RULE'] = '/api/auth'
|
||||
app.config['JWT_EXPIRATION_DELTA'] = env.get_auth_expiration_time()
|
||||
|
||||
init_jwt(app)
|
||||
mongo.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
from functools import wraps
|
||||
|
||||
from flask import current_app, abort
|
||||
from flask_jwt import JWT, _jwt_required, JWTError
|
||||
from werkzeug.security import safe_str_cmp
|
||||
|
||||
from cc.environment.environment import env
|
||||
|
||||
__author__ = 'itay.mizeretz'
|
||||
|
||||
|
||||
class User(object):
|
||||
def __init__(self, id, username, secret):
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.secret = secret
|
||||
|
||||
def __str__(self):
|
||||
return "User(id='%s')" % self.id
|
||||
|
||||
|
||||
def init_jwt(app):
|
||||
users = env.get_auth_users()
|
||||
username_table = {u.username: u for u in users}
|
||||
userid_table = {u.id: u for u in users}
|
||||
|
||||
def authenticate(username, secret):
|
||||
user = username_table.get(username, None)
|
||||
if user and safe_str_cmp(user.secret.encode('utf-8'), secret.encode('utf-8')):
|
||||
return user
|
||||
|
||||
def identity(payload):
|
||||
user_id = payload['identity']
|
||||
return userid_table.get(user_id, None)
|
||||
|
||||
if env.is_auth_enabled():
|
||||
JWT(app, authenticate, identity)
|
||||
|
||||
|
||||
def jwt_required(realm=None):
|
||||
def wrapper(fn):
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
if env.is_auth_enabled():
|
||||
try:
|
||||
_jwt_required(realm or current_app.config['JWT_DEFAULT_REALM'])
|
||||
except JWTError:
|
||||
abort(401)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return decorator
|
||||
|
||||
return wrapper
|
|
@ -0,0 +1,33 @@
|
|||
import abc
|
||||
from datetime import timedelta
|
||||
|
||||
__author__ = 'itay.mizeretz'
|
||||
|
||||
|
||||
class Environment(object):
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
_ISLAND_PORT = 5000
|
||||
_MONGO_URL = "mongodb://localhost:27017/monkeyisland"
|
||||
_DEBUG_SERVER = False
|
||||
_AUTH_EXPIRATION_TIME = timedelta(hours=1)
|
||||
|
||||
def get_island_port(self):
|
||||
return self._ISLAND_PORT
|
||||
|
||||
def get_mongo_url(self):
|
||||
return self._MONGO_URL
|
||||
|
||||
def is_debug(self):
|
||||
return self._DEBUG_SERVER
|
||||
|
||||
def get_auth_expiration_time(self):
|
||||
return self._AUTH_EXPIRATION_TIME
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_auth_enabled(self):
|
||||
return
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_auth_users(self):
|
||||
return
|
|
@ -0,0 +1,24 @@
|
|||
import urllib2
|
||||
|
||||
import cc.auth
|
||||
from cc.environment import Environment
|
||||
|
||||
__author__ = 'itay.mizeretz'
|
||||
|
||||
|
||||
class AwsEnvironment(Environment):
|
||||
def __init__(self):
|
||||
super(AwsEnvironment, self).__init__()
|
||||
self._instance_id = AwsEnvironment._get_instance_id()
|
||||
|
||||
@staticmethod
|
||||
def _get_instance_id():
|
||||
return urllib2.urlopen('http://169.254.169.254/latest/meta-data/instance-id').read()
|
||||
|
||||
def is_auth_enabled(self):
|
||||
return True
|
||||
|
||||
def get_auth_users(self):
|
||||
return [
|
||||
cc.auth.User(1, 'monkey', self._instance_id)
|
||||
]
|
|
@ -0,0 +1,23 @@
|
|||
import json
|
||||
import standard
|
||||
import aws
|
||||
|
||||
ENV_DICT = {
|
||||
'standard': standard.StandardEnvironment,
|
||||
'aws': aws.AwsEnvironment
|
||||
}
|
||||
|
||||
|
||||
def load_env_from_file():
|
||||
with open('server_config.json', 'r') as f:
|
||||
config_content = f.read()
|
||||
config_json = json.loads(config_content)
|
||||
return config_json['server_config']
|
||||
|
||||
|
||||
try:
|
||||
__env_type = load_env_from_file()
|
||||
env = ENV_DICT[__env_type]()
|
||||
except Exception:
|
||||
print('Failed initializing environment: %s' % __env_type)
|
||||
raise
|
|
@ -0,0 +1,12 @@
|
|||
from cc.environment import Environment
|
||||
|
||||
__author__ = 'itay.mizeretz'
|
||||
|
||||
|
||||
class StandardEnvironment(Environment):
|
||||
|
||||
def is_auth_enabled(self):
|
||||
return False
|
||||
|
||||
def get_auth_users(self):
|
||||
return []
|
|
@ -1,5 +0,0 @@
|
|||
__author__ = 'itay.mizeretz'
|
||||
|
||||
ISLAND_PORT = 5000
|
||||
DEFAULT_MONGO_URL = "mongodb://localhost:27017/monkeyisland"
|
||||
DEBUG_SERVER = False
|
|
@ -11,7 +11,7 @@ if BASE_PATH not in sys.path:
|
|||
|
||||
from cc.app import init_app
|
||||
from cc.utils import local_ip_addresses
|
||||
from cc.island_config import DEFAULT_MONGO_URL, ISLAND_PORT, DEBUG_SERVER
|
||||
from cc.environment.environment import env
|
||||
from cc.database import is_db_server_up
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -19,20 +19,20 @@ if __name__ == '__main__':
|
|||
from tornado.httpserver import HTTPServer
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
mongo_url = os.environ.get('MONGO_URL', DEFAULT_MONGO_URL)
|
||||
mongo_url = os.environ.get('MONGO_URL', env.get_mongo_url())
|
||||
|
||||
while not is_db_server_up(mongo_url):
|
||||
print('Waiting for MongoDB server')
|
||||
time.sleep(1)
|
||||
|
||||
app = init_app(mongo_url)
|
||||
if DEBUG_SERVER:
|
||||
if env.is_debug():
|
||||
app.run(host='0.0.0.0', debug=True, ssl_context=('server.crt', 'server.key'))
|
||||
else:
|
||||
http_server = HTTPServer(WSGIContainer(app),
|
||||
ssl_options={'certfile': os.environ.get('SERVER_CRT', 'server.crt'),
|
||||
'keyfile': os.environ.get('SERVER_KEY', 'server.key')})
|
||||
http_server.listen(ISLAND_PORT)
|
||||
print('Monkey Island C&C Server is running on https://{}:{}'.format(local_ip_addresses()[0], ISLAND_PORT))
|
||||
http_server.listen(env.get_island_port())
|
||||
print('Monkey Island Server is running on https://{}:{}'.format(local_ip_addresses()[0], env.get_island_port()))
|
||||
IOLoop.instance().start()
|
||||
|
||||
|
|
|
@ -6,8 +6,8 @@ import sys
|
|||
from flask import request, jsonify, make_response
|
||||
import flask_restful
|
||||
|
||||
from cc.environment.environment import env
|
||||
from cc.resources.monkey_download import get_monkey_executable
|
||||
from cc.island_config import ISLAND_PORT
|
||||
from cc.services.node import NodeService
|
||||
from cc.utils import local_ip_addresses
|
||||
|
||||
|
@ -36,7 +36,7 @@ def run_local_monkey():
|
|||
|
||||
# run the monkey
|
||||
try:
|
||||
args = ['"%s" m0nk3y -s %s:%s' % (target_path, local_ip_addresses()[0], ISLAND_PORT)]
|
||||
args = ['"%s" m0nk3y -s %s:%s' % (target_path, local_ip_addresses()[0], env.get_island_port())]
|
||||
if sys.platform == "win32":
|
||||
args = "".join(args)
|
||||
pid = subprocess.Popen(args, shell=True).pid
|
||||
|
|
|
@ -15,23 +15,20 @@ __author__ = 'Barak'
|
|||
|
||||
|
||||
class Monkey(flask_restful.Resource):
|
||||
|
||||
# Used by monkey. can't secure.
|
||||
def get(self, guid=None, **kw):
|
||||
NodeService.update_dead_monkeys() # refresh monkeys status
|
||||
if not guid:
|
||||
guid = request.args.get('guid')
|
||||
timestamp = request.args.get('timestamp')
|
||||
|
||||
if guid:
|
||||
monkey_json = mongo.db.monkey.find_one_or_404({"guid": guid})
|
||||
return monkey_json
|
||||
else:
|
||||
result = {'timestamp': datetime.now().isoformat()}
|
||||
find_filter = {}
|
||||
if timestamp is not None:
|
||||
find_filter['modifytime'] = {'$gt': dateutil.parser.parse(timestamp)}
|
||||
result['objects'] = [x for x in mongo.db.monkey.find(find_filter)]
|
||||
return result
|
||||
|
||||
return {}
|
||||
|
||||
# Used by monkey. can't secure.
|
||||
def patch(self, guid):
|
||||
monkey_json = json.loads(request.data)
|
||||
update = {"$set": {'modifytime': datetime.now()}}
|
||||
|
@ -51,6 +48,7 @@ class Monkey(flask_restful.Resource):
|
|||
|
||||
return mongo.db.monkey.update({"_id": monkey["_id"]}, update, upsert=False)
|
||||
|
||||
# Used by monkey. can't secure.
|
||||
def post(self, **kw):
|
||||
monkey_json = json.loads(request.data)
|
||||
monkey_json['creds'] = []
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import json
|
||||
|
||||
from flask import request, jsonify
|
||||
import flask_restful
|
||||
from flask import request, jsonify
|
||||
|
||||
from cc.database import mongo
|
||||
from cc.auth import jwt_required
|
||||
from cc.services.config import ConfigService
|
||||
|
||||
__author__ = 'Barak'
|
||||
|
||||
|
||||
class MonkeyConfiguration(flask_restful.Resource):
|
||||
@jwt_required()
|
||||
def get(self):
|
||||
return jsonify(schema=ConfigService.get_config_schema(), configuration=ConfigService.get_config())
|
||||
|
||||
@jwt_required()
|
||||
def post(self):
|
||||
config_json = json.loads(request.data)
|
||||
if config_json.has_key('reset'):
|
||||
|
@ -20,4 +22,3 @@ class MonkeyConfiguration(flask_restful.Resource):
|
|||
else:
|
||||
ConfigService.update_config(config_json)
|
||||
return self.get()
|
||||
|
||||
|
|
|
@ -47,9 +47,12 @@ def get_monkey_executable(host_os, machine):
|
|||
|
||||
|
||||
class MonkeyDownload(flask_restful.Resource):
|
||||
|
||||
# Used by monkey. can't secure.
|
||||
def get(self, path):
|
||||
return send_from_directory('binaries', path)
|
||||
|
||||
# Used by monkey. can't secure.
|
||||
def post(self):
|
||||
host_json = json.loads(request.data)
|
||||
host_os = host_json.get('os')
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import flask_restful
|
||||
|
||||
from cc.auth import jwt_required
|
||||
from cc.services.edge import EdgeService
|
||||
from cc.services.node import NodeService
|
||||
from cc.database import mongo
|
||||
|
@ -8,6 +9,7 @@ __author__ = 'Barak'
|
|||
|
||||
|
||||
class NetMap(flask_restful.Resource):
|
||||
@jwt_required()
|
||||
def get(self, **kw):
|
||||
monkeys = [NodeService.monkey_to_net_node(x) for x in mongo.db.monkey.find({})]
|
||||
nodes = [NodeService.node_to_net_node(x) for x in mongo.db.node.find({})]
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
from flask import request
|
||||
import flask_restful
|
||||
|
||||
from cc.auth import jwt_required
|
||||
from cc.services.node import NodeService
|
||||
|
||||
__author__ = 'Barak'
|
||||
|
||||
|
||||
class Node(flask_restful.Resource):
|
||||
@jwt_required()
|
||||
def get(self):
|
||||
node_id = request.args.get('id')
|
||||
if node_id:
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import flask_restful
|
||||
|
||||
from cc.auth import jwt_required
|
||||
from cc.services.report import ReportService
|
||||
|
||||
__author__ = "itay.mizeretz"
|
||||
|
||||
|
||||
class Report(flask_restful.Resource):
|
||||
|
||||
@jwt_required()
|
||||
def get(self):
|
||||
return ReportService.get_report()
|
||||
|
|
|
@ -3,6 +3,7 @@ from datetime import datetime
|
|||
import flask_restful
|
||||
from flask import request, make_response, jsonify
|
||||
|
||||
from cc.auth import jwt_required
|
||||
from cc.database import mongo
|
||||
from cc.services.config import ConfigService
|
||||
from cc.services.node import NodeService
|
||||
|
@ -13,6 +14,8 @@ __author__ = 'Barak'
|
|||
|
||||
|
||||
class Root(flask_restful.Resource):
|
||||
|
||||
@jwt_required()
|
||||
def get(self, action=None):
|
||||
if not action:
|
||||
action = request.args.get('action')
|
||||
|
|
|
@ -7,6 +7,7 @@ import dateutil
|
|||
import flask_restful
|
||||
from flask import request
|
||||
|
||||
from cc.auth import jwt_required
|
||||
from cc.database import mongo
|
||||
from cc.services.config import ConfigService
|
||||
from cc.services.edge import EdgeService
|
||||
|
@ -16,6 +17,7 @@ __author__ = 'Barak'
|
|||
|
||||
|
||||
class Telemetry(flask_restful.Resource):
|
||||
@jwt_required()
|
||||
def get(self, **kw):
|
||||
monkey_guid = request.args.get('monkey_guid')
|
||||
telem_type = request.args.get('telem_type')
|
||||
|
@ -36,6 +38,7 @@ class Telemetry(flask_restful.Resource):
|
|||
result['objects'] = self.telemetry_to_displayed_telemetry(mongo.db.telemetry.find(find_filter))
|
||||
return result
|
||||
|
||||
# Used by monkey. can't secure.
|
||||
def post(self):
|
||||
telemetry_json = json.loads(request.data)
|
||||
telemetry_json['timestamp'] = datetime.now()
|
||||
|
|
|
@ -5,6 +5,7 @@ import flask_restful
|
|||
from flask import request
|
||||
import flask_pymongo
|
||||
|
||||
from cc.auth import jwt_required
|
||||
from cc.database import mongo
|
||||
from cc.services.node import NodeService
|
||||
|
||||
|
@ -12,6 +13,7 @@ __author__ = 'itay.mizeretz'
|
|||
|
||||
|
||||
class TelemetryFeed(flask_restful.Resource):
|
||||
@jwt_required()
|
||||
def get(self, **kw):
|
||||
timestamp = request.args.get('timestamp')
|
||||
if "null" == timestamp or timestamp is None: # special case to avoid ugly JS code...
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"server_config": "standard"
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
from cc.database import mongo
|
||||
from jsonschema import Draft4Validator, validators
|
||||
|
||||
from cc.island_config import ISLAND_PORT
|
||||
from cc.environment.environment import env
|
||||
from cc.utils import local_ip_addresses
|
||||
|
||||
__author__ = "itay.mizeretz"
|
||||
|
@ -205,25 +205,8 @@ SCHEMA = {
|
|||
"title": "Network range",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"range_class": {
|
||||
"title": "Range class",
|
||||
"type": "string",
|
||||
"default": "FixedRange",
|
||||
"enum": [
|
||||
"FixedRange",
|
||||
"ClassCRange"
|
||||
],
|
||||
"enumNames": [
|
||||
"Fixed Range",
|
||||
"Class C Range"
|
||||
],
|
||||
"description":
|
||||
"Determines which class to use to determine scan range."
|
||||
" Fixed Range will scan only specific IPs listed under Fixed range IP list."
|
||||
" Class C Range will scan machines in the Class C network the monkey's on."
|
||||
},
|
||||
"range_fixed": {
|
||||
"title": "Fixed range IP/subnet list",
|
||||
"title": "Scan IP/subnet list",
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"items": {
|
||||
|
@ -232,8 +215,7 @@ SCHEMA = {
|
|||
"default": [
|
||||
],
|
||||
"description":
|
||||
"List of IPs/subnets to include when using FixedRange"
|
||||
" (Only relevant for Fixed Range)."
|
||||
"List of IPs/subnets the monkey should scan."
|
||||
" Examples: \"192.168.0.1\", \"192.168.0.5-192.168.0.20\", \"192.168.0.5/24\""
|
||||
},
|
||||
"inaccessible_subnet_groups": {
|
||||
|
@ -551,7 +533,7 @@ SCHEMA = {
|
|||
}
|
||||
},
|
||||
"cnc": {
|
||||
"title": "C&C",
|
||||
"title": "Monkey Island",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"servers": {
|
||||
|
@ -908,8 +890,8 @@ class ConfigService:
|
|||
@staticmethod
|
||||
def set_server_ips_in_config(config):
|
||||
ips = local_ip_addresses()
|
||||
config["cnc"]["servers"]["command_servers"] = ["%s:%d" % (ip, ISLAND_PORT) for ip in ips]
|
||||
config["cnc"]["servers"]["current_server"] = "%s:%d" % (ips[0], ISLAND_PORT)
|
||||
config["cnc"]["servers"]["command_servers"] = ["%s:%d" % (ip, env.get_island_port()) for ip in ips]
|
||||
config["cnc"]["servers"]["current_server"] = "%s:%d" % (ips[0], env.get_island_port())
|
||||
|
||||
@staticmethod
|
||||
def save_initial_config_if_needed():
|
||||
|
|
|
@ -315,8 +315,6 @@ class ReportService:
|
|||
|
||||
@staticmethod
|
||||
def get_config_ips():
|
||||
if ConfigService.get_config_value(['basic_network', 'network_range', 'range_class'], True) != 'FixedRange':
|
||||
return []
|
||||
return ConfigService.get_config_value(['basic_network', 'network_range', 'range_fixed'], True)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -65,6 +65,8 @@
|
|||
"core-js": "^2.5.1",
|
||||
"fetch": "^1.1.0",
|
||||
"js-file-download": "^0.4.1",
|
||||
"json-loader": "^0.5.7",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"normalize.css": "^4.0.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"rc-progress": "^2.2.5",
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
class AuthComponent extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.auth = new AuthService();
|
||||
this.authFetch = this.auth.authFetch;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthComponent;
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import {NavLink, Route, BrowserRouter as Router} from 'react-router-dom';
|
||||
import {BrowserRouter as Router, NavLink, Redirect, Route} from 'react-router-dom';
|
||||
import {Col, Grid, Row} from 'react-bootstrap';
|
||||
import {Icon} from 'react-fa';
|
||||
|
||||
|
@ -11,6 +11,8 @@ import TelemetryPage from 'components/pages/TelemetryPage';
|
|||
import StartOverPage from 'components/pages/StartOverPage';
|
||||
import ReportPage from 'components/pages/ReportPage';
|
||||
import LicensePage from 'components/pages/LicensePage';
|
||||
import AuthComponent from 'components/AuthComponent';
|
||||
import LoginPageComponent from 'components/pages/LoginPage';
|
||||
|
||||
require('normalize.css/normalize.css');
|
||||
require('react-data-components/css/table-twbs.css');
|
||||
|
@ -22,21 +24,10 @@ let logoImage = require('../images/monkey-icon.svg');
|
|||
let infectionMonkeyImage = require('../images/infection-monkey.svg');
|
||||
let guardicoreLogoImage = require('../images/guardicore-logo.png');
|
||||
|
||||
class AppComponent extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
completedSteps: {
|
||||
run_server: true,
|
||||
run_monkey: false,
|
||||
infection_done: false,
|
||||
report_done: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class AppComponent extends AuthComponent {
|
||||
updateStatus = () => {
|
||||
fetch('/api')
|
||||
if (this.auth.loggedIn()){
|
||||
this.authFetch('/api')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
// This check is used to prevent unnecessary re-rendering
|
||||
|
@ -51,8 +42,37 @@ class AppComponent extends React.Component {
|
|||
this.setState({completedSteps: res['completed_steps']});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderRoute = (route_path, page_component, is_exact_path = false) => {
|
||||
let render_func = (props) => {
|
||||
if (this.auth.loggedIn()) {
|
||||
return page_component;
|
||||
} else {
|
||||
return <Redirect to={{pathname: '/login'}}/>;
|
||||
}
|
||||
};
|
||||
|
||||
if (is_exact_path) {
|
||||
return <Route exact path={route_path} render={render_func}/>;
|
||||
} else {
|
||||
return <Route path={route_path} render={render_func}/>;
|
||||
}
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
completedSteps: {
|
||||
run_server: true,
|
||||
run_monkey: false,
|
||||
infection_done: false,
|
||||
report_done: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateStatus();
|
||||
this.interval = setInterval(this.updateStatus, 2000);
|
||||
|
@ -77,7 +97,7 @@ class AppComponent extends React.Component {
|
|||
<li>
|
||||
<NavLink to="/" exact={true}>
|
||||
<span className="number">1.</span>
|
||||
Run C&C Server
|
||||
Run Monkey Island Server
|
||||
{this.state.completedSteps.run_server ?
|
||||
<Icon name="check" className="pull-right checkmark text-success"/>
|
||||
: ''}
|
||||
|
@ -136,14 +156,15 @@ class AppComponent extends React.Component {
|
|||
</div>
|
||||
</Col>
|
||||
<Col sm={9} md={10} smOffset={3} mdOffset={2} className="main">
|
||||
<Route exact path="/" render={(props) => ( <RunServerPage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path="/configure" render={(props) => ( <ConfigurePage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path="/run-monkey" render={(props) => ( <RunMonkeyPage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path="/infection/map" render={(props) => ( <MapPage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path="/infection/telemetry" render={(props) => ( <TelemetryPage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path="/start-over" render={(props) => ( <StartOverPage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path="/report" render={(props) => ( <ReportPage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path="/license" render={(props) => ( <LicensePage onStatusChange={this.updateStatus} /> )} />
|
||||
<Route path='/login' render={(props) => (<LoginPageComponent onStatusChange={this.updateStatus}/>)}/>
|
||||
{this.renderRoute('/', <RunServerPage onStatusChange={this.updateStatus}/>, true)}
|
||||
{this.renderRoute('/configure', <ConfigurePage onStatusChange={this.updateStatus}/>)}
|
||||
{this.renderRoute('/run-monkey', <RunMonkeyPage onStatusChange={this.updateStatus}/>)}
|
||||
{this.renderRoute('/infection/map', <MapPage onStatusChange={this.updateStatus}/>)}
|
||||
{this.renderRoute('/infection/telemetry', <TelemetryPage onStatusChange={this.updateStatus}/>)}
|
||||
{this.renderRoute('/start-over', <StartOverPage onStatusChange={this.updateStatus}/>)}
|
||||
{this.renderRoute('/report', <ReportPage onStatusChange={this.updateStatus}/>)}
|
||||
{this.renderRoute('/license', <LicensePage onStatusChange={this.updateStatus}/>)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Grid>
|
||||
|
|
|
@ -2,8 +2,9 @@ import React from 'react';
|
|||
import {Icon} from 'react-fa';
|
||||
import Toggle from 'react-toggle';
|
||||
import {OverlayTrigger, Tooltip} from 'react-bootstrap';
|
||||
import AuthComponent from '../../AuthComponent';
|
||||
|
||||
class PreviewPaneComponent extends React.Component {
|
||||
class PreviewPaneComponent extends AuthComponent {
|
||||
|
||||
generateToolTip(text) {
|
||||
return (
|
||||
|
@ -64,7 +65,7 @@ class PreviewPaneComponent extends React.Component {
|
|||
forceKill(event, asset) {
|
||||
let newConfig = asset.config;
|
||||
newConfig['alive'] = !event.target.checked;
|
||||
fetch('/api/monkey/' + asset.guid,
|
||||
this.authFetch('/api/monkey/' + asset.guid,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
|
|
@ -2,8 +2,9 @@ import React from 'react';
|
|||
import Form from 'react-jsonschema-form';
|
||||
import {Col, Nav, NavItem} from 'react-bootstrap';
|
||||
import fileDownload from 'js-file-download';
|
||||
import AuthComponent from '../AuthComponent';
|
||||
|
||||
class ConfigurePageComponent extends React.Component {
|
||||
class ConfigurePageComponent extends AuthComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -23,7 +24,7 @@ class ConfigurePageComponent extends React.Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetch('/api/configuration')
|
||||
this.authFetch('/api/configuration')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
let sections = [];
|
||||
|
@ -43,7 +44,7 @@ class ConfigurePageComponent extends React.Component {
|
|||
onSubmit = ({formData}) => {
|
||||
this.currentFormData = formData;
|
||||
this.updateConfigSection();
|
||||
fetch('/api/configuration',
|
||||
this.authFetch('/api/configuration',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
@ -82,7 +83,7 @@ class ConfigurePageComponent extends React.Component {
|
|||
};
|
||||
|
||||
resetConfig = () => {
|
||||
fetch('/api/configuration',
|
||||
this.authFetch('/api/configuration',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
@ -126,7 +127,7 @@ class ConfigurePageComponent extends React.Component {
|
|||
};
|
||||
|
||||
updateMonkeysRunning = () => {
|
||||
fetch('/api')
|
||||
this.authFetch('/api')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
// This check is used to prevent unnecessary re-rendering
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import {Col} from 'react-bootstrap';
|
||||
|
||||
import AuthService from '../../services/AuthService'
|
||||
|
||||
class LoginPageComponent extends React.Component {
|
||||
login = () => {
|
||||
this.auth.login(this.username, this.password).then(res => {
|
||||
if (res['result']) {
|
||||
this.redirectToHome();
|
||||
} else {
|
||||
this.setState({failed: true});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updateUsername = (evt) => {
|
||||
this.username = evt.target.value;
|
||||
};
|
||||
|
||||
updatePassword = (evt) => {
|
||||
this.password = evt.target.value;
|
||||
};
|
||||
|
||||
redirectToHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.username = '';
|
||||
this.password = '';
|
||||
this.auth = new AuthService();
|
||||
this.state = {
|
||||
failed: false
|
||||
};
|
||||
if (this.auth.loggedIn()) {
|
||||
this.redirectToHome();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Col xs={12} lg={8}>
|
||||
<h1 className="page-title">Login</h1>
|
||||
<div className="col-sm-6 col-sm-offset-3" style={{'fontSize': '1.2em'}}>
|
||||
<div className="panel panel-default">
|
||||
<div className="panel-heading text-center">
|
||||
<b>Login</b>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<div className="input-group center-block text-center">
|
||||
<input type="text" className="form-control" placeholder="Username"
|
||||
onChange={evt => this.updateUsername(evt)}/>
|
||||
<input type="password" className="form-control" placeholder="Password"
|
||||
onChange={evt => this.updatePassword(evt)}/>
|
||||
<button type="button" className="btn btn-primary btn-lg" style={{margin: '5px'}}
|
||||
onClick={() => {
|
||||
this.login()
|
||||
}}>
|
||||
Login
|
||||
</button>
|
||||
{
|
||||
this.state.failed ?
|
||||
<div className="alert alert-danger" role="alert">Login failed. Bad credentials.</div>
|
||||
:
|
||||
''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LoginPageComponent;
|
|
@ -6,8 +6,9 @@ import PreviewPane from 'components/map/preview-pane/PreviewPane';
|
|||
import {ReactiveGraph} from 'components/reactive-graph/ReactiveGraph';
|
||||
import {ModalContainer, ModalDialog} from 'react-modal-dialog';
|
||||
import {options, edgeGroupToColor} from 'components/map/MapOptions';
|
||||
import AuthComponent from '../AuthComponent';
|
||||
|
||||
class MapPageComponent extends React.Component {
|
||||
class MapPageComponent extends AuthComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
@ -40,7 +41,7 @@ class MapPageComponent extends React.Component {
|
|||
};
|
||||
|
||||
updateMapFromServer = () => {
|
||||
fetch('/api/netmap')
|
||||
this.authFetch('/api/netmap')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
res.edges.forEach(edge => {
|
||||
|
@ -52,7 +53,7 @@ class MapPageComponent extends React.Component {
|
|||
};
|
||||
|
||||
updateTelemetryFromServer = () => {
|
||||
fetch('/api/telemetry-feed?timestamp='+this.state.telemetryLastTimestamp)
|
||||
this.authFetch('/api/telemetry-feed?timestamp='+this.state.telemetryLastTimestamp)
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
let newTelem = this.state.telemetry.concat(res['telemetries']);
|
||||
|
@ -68,7 +69,7 @@ class MapPageComponent extends React.Component {
|
|||
|
||||
selectionChanged(event) {
|
||||
if (event.nodes.length === 1) {
|
||||
fetch('/api/netmap/node?id=' + event.nodes[0])
|
||||
this.authFetch('/api/netmap/node?id=' + event.nodes[0])
|
||||
.then(res => res.json())
|
||||
.then(res => this.setState({selected: res, selectedType: 'node'}));
|
||||
}
|
||||
|
@ -80,7 +81,7 @@ class MapPageComponent extends React.Component {
|
|||
if (displayedEdge['group'] === 'island') {
|
||||
this.setState({selected: displayedEdge, selectedType: 'island_edge'});
|
||||
} else {
|
||||
fetch('/api/netmap/edge?id=' + event.edges[0])
|
||||
this.authFetch('/api/netmap/edge?id=' + event.edges[0])
|
||||
.then(res => res.json())
|
||||
.then(res => this.setState({selected: res.edge, selectedType: 'edge'}));
|
||||
}
|
||||
|
@ -91,7 +92,7 @@ class MapPageComponent extends React.Component {
|
|||
}
|
||||
|
||||
killAllMonkeys = () => {
|
||||
fetch('/api?action=killall')
|
||||
this.authFetch('/api?action=killall')
|
||||
.then(res => res.json())
|
||||
.then(res => this.setState({killPressed: (res.status === 'OK')}));
|
||||
};
|
||||
|
|
|
@ -7,11 +7,12 @@ import {edgeGroupToColor, options} from 'components/map/MapOptions';
|
|||
import StolenPasswords from 'components/report-components/StolenPasswords';
|
||||
import CollapsibleWellComponent from 'components/report-components/CollapsibleWell';
|
||||
import {Line} from 'rc-progress';
|
||||
import AuthComponent from '../AuthComponent';
|
||||
|
||||
let guardicoreLogoImage = require('../../images/guardicore-logo.png');
|
||||
let monkeyLogoImage = require('../../images/monkey-icon.svg');
|
||||
|
||||
class ReportPageComponent extends React.Component {
|
||||
class ReportPageComponent extends AuthComponent {
|
||||
|
||||
Issue =
|
||||
{
|
||||
|
@ -76,7 +77,7 @@ class ReportPageComponent extends React.Component {
|
|||
}
|
||||
|
||||
updateMonkeysRunning = () => {
|
||||
return fetch('/api')
|
||||
return this.authFetch('/api')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
// This check is used to prevent unnecessary re-rendering
|
||||
|
@ -89,7 +90,7 @@ class ReportPageComponent extends React.Component {
|
|||
};
|
||||
|
||||
updateMapFromServer = () => {
|
||||
fetch('/api/netmap')
|
||||
this.authFetch('/api/netmap')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
res.edges.forEach(edge => {
|
||||
|
@ -102,7 +103,7 @@ class ReportPageComponent extends React.Component {
|
|||
|
||||
getReportFromServer(res) {
|
||||
if (res['completed_steps']['run_monkey']) {
|
||||
fetch('/api/report')
|
||||
this.authFetch('/api/report')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
this.setState({
|
||||
|
@ -610,7 +611,7 @@ class ReportPageComponent extends React.Component {
|
|||
The network can probably be segmented. A monkey instance on <span
|
||||
className="label label-primary">{issue.machine}</span> in the
|
||||
networks {this.generateInfoBadges(issue.networks)}
|
||||
could directly access the Monkey Island C&C server in the
|
||||
could directly access the Monkey Island server in the
|
||||
networks {this.generateInfoBadges(issue.server_networks)}.
|
||||
</CollapsibleWellComponent>
|
||||
</li>
|
||||
|
|
|
@ -3,8 +3,9 @@ import {Button, Col, Well, Nav, NavItem, Collapse} from 'react-bootstrap';
|
|||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import {Icon} from 'react-fa';
|
||||
import {Link} from 'react-router-dom';
|
||||
import AuthComponent from '../AuthComponent';
|
||||
|
||||
class RunMonkeyPageComponent extends React.Component {
|
||||
class RunMonkeyPageComponent extends AuthComponent {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -19,14 +20,14 @@ class RunMonkeyPageComponent extends React.Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetch('/api')
|
||||
this.authFetch('/api')
|
||||
.then(res => res.json())
|
||||
.then(res => this.setState({
|
||||
ips: res['ip_addresses'],
|
||||
selectedIp: res['ip_addresses'][0]
|
||||
}));
|
||||
|
||||
fetch('/api/local-monkey')
|
||||
this.authFetch('/api/local-monkey')
|
||||
.then(res => res.json())
|
||||
.then(res =>{
|
||||
if (res['is_running']) {
|
||||
|
@ -36,7 +37,7 @@ class RunMonkeyPageComponent extends React.Component {
|
|||
}
|
||||
});
|
||||
|
||||
fetch('/api/client-monkey')
|
||||
this.authFetch('/api/client-monkey')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (res['is_running']) {
|
||||
|
@ -60,7 +61,7 @@ class RunMonkeyPageComponent extends React.Component {
|
|||
}
|
||||
|
||||
runLocalMonkey = () => {
|
||||
fetch('/api/local-monkey',
|
||||
this.authFetch('/api/local-monkey',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
@ -146,7 +147,7 @@ class RunMonkeyPageComponent extends React.Component {
|
|||
className="btn btn-default btn-lg center-block"
|
||||
disabled={this.state.runningOnIslandState !== 'not_running'}
|
||||
>
|
||||
Run on C&C Server
|
||||
Run on Monkey Island Server
|
||||
{ this.renderIconByState(this.state.runningOnIslandState) }
|
||||
</button>
|
||||
{
|
||||
|
|
|
@ -10,17 +10,19 @@ class RunServerPageComponent extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<Col xs={12} lg={8}>
|
||||
<h1 className="page-title">1. Monkey Island C&C Server</h1>
|
||||
<h1 className="page-title">1. Monkey Island Server</h1>
|
||||
<div style={{'fontSize': '1.2em'}}>
|
||||
<p style={{'marginTop': '30px'}}>Congrats! You have successfully set up the Monkey Island server. 👏 👏</p>
|
||||
<p style={{'marginTop': '30px'}}>Congrats! You have successfully set up the Monkey Island
|
||||
server. 👏 👏</p>
|
||||
<p>
|
||||
The Infection Monkey is an open source security tool for testing a data center's resiliency to perimeter
|
||||
breaches and internal server infections.
|
||||
The Monkey uses various methods to propagate across a data
|
||||
center and reports to this Command and Control (C&C) server.
|
||||
center and reports to this Monkey Island Command and Control server.
|
||||
</p>
|
||||
<p>
|
||||
To read more about the Monkey, visit <a href="http://infectionmonkey.com" target="_blank">infectionmonkey.com</a>
|
||||
To read more about the Monkey, visit <a href="http://infectionmonkey.com"
|
||||
target="_blank">infectionmonkey.com</a>
|
||||
</p>
|
||||
<p>
|
||||
Go ahead and <Link to="/run-monkey">run the monkey</Link>.
|
||||
|
|
|
@ -2,8 +2,9 @@ import React from 'react';
|
|||
import {Col} from 'react-bootstrap';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {ModalContainer, ModalDialog} from 'react-modal-dialog';
|
||||
import AuthComponent from '../AuthComponent';
|
||||
|
||||
class StartOverPageComponent extends React.Component {
|
||||
class StartOverPageComponent extends AuthComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -15,7 +16,7 @@ class StartOverPageComponent extends React.Component {
|
|||
}
|
||||
|
||||
updateMonkeysRunning = () => {
|
||||
fetch('/api')
|
||||
this.authFetch('/api')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
// This check is used to prevent unnecessary re-rendering
|
||||
|
@ -104,7 +105,7 @@ class StartOverPageComponent extends React.Component {
|
|||
this.setState({
|
||||
cleaned: false
|
||||
});
|
||||
fetch('/api?action=reset')
|
||||
this.authFetch('/api?action=reset')
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (res['status'] === 'OK') {
|
||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import {Col} from 'react-bootstrap';
|
||||
import JSONTree from 'react-json-tree'
|
||||
import {DataTable} from 'react-data-components';
|
||||
import AuthComponent from '../AuthComponent';
|
||||
|
||||
const renderJson = (val) => <JSONTree data={val} level={1} theme="eighties" invertTheme={true} />;
|
||||
const renderTime = (val) => val.split('.')[0];
|
||||
|
@ -13,7 +14,7 @@ const columns = [
|
|||
{ title: 'Details', prop: 'data', render: renderJson, width: '40%' }
|
||||
];
|
||||
|
||||
class TelemetryPageComponent extends React.Component {
|
||||
class TelemetryPageComponent extends AuthComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
@ -22,7 +23,7 @@ class TelemetryPageComponent extends React.Component {
|
|||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
fetch('/api/telemetry')
|
||||
this.authFetch('/api/telemetry')
|
||||
.then(res => res.json())
|
||||
.then(res => this.setState({data: res.objects}));
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Infection Monkey C&C</title>
|
||||
<title>Infection Monkey Island Server</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import BaseConfig from './BaseConfig';
|
||||
|
||||
class AwsConfig extends BaseConfig{
|
||||
isAuthEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default AwsConfig;
|
|
@ -0,0 +1,8 @@
|
|||
class BaseConfig {
|
||||
|
||||
isAuthEnabled() {
|
||||
throw new Error('Abstract function');
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseConfig;
|
|
@ -0,0 +1,12 @@
|
|||
import StandardConfig from './StandardConfig';
|
||||
import AwsConfig from './AwsConfig';
|
||||
|
||||
const SERVER_CONFIG_JSON = require('json-loader!../../../server_config.json');
|
||||
|
||||
const CONFIG_DICT =
|
||||
{
|
||||
'standard': StandardConfig,
|
||||
'aws': AwsConfig
|
||||
};
|
||||
|
||||
export const SERVER_CONFIG = new CONFIG_DICT[SERVER_CONFIG_JSON['server_config']]();
|
|
@ -0,0 +1,10 @@
|
|||
import BaseConfig from './BaseConfig';
|
||||
|
||||
class StandardConfig extends BaseConfig {
|
||||
|
||||
isAuthEnabled () {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default StandardConfig;
|
|
@ -0,0 +1,106 @@
|
|||
import decode from 'jwt-decode';
|
||||
import {SERVER_CONFIG} from '../server_config/ServerConfig';
|
||||
|
||||
export default class AuthService {
|
||||
AUTH_ENABLED = SERVER_CONFIG.isAuthEnabled();
|
||||
|
||||
login = (username, password) => {
|
||||
if (this.AUTH_ENABLED) {
|
||||
return this._login(username, password);
|
||||
} else {
|
||||
return {result: true};
|
||||
}
|
||||
};
|
||||
|
||||
authFetch = (url, options) => {
|
||||
if (this.AUTH_ENABLED) {
|
||||
return this._authFetch(url, options);
|
||||
} else {
|
||||
return fetch(url, options);
|
||||
}
|
||||
};
|
||||
|
||||
_login = (username, password) => {
|
||||
return this._authFetch('/api/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password
|
||||
})
|
||||
}).then(response => response.json())
|
||||
.then(res => {
|
||||
if (res.hasOwnProperty('access_token')) {
|
||||
this._setToken(res['access_token']);
|
||||
return {result: true};
|
||||
} else {
|
||||
this._removeToken();
|
||||
return {result: false};
|
||||
}
|
||||
|
||||
})
|
||||
};
|
||||
|
||||
_authFetch = (url, options = {}) => {
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (this.loggedIn()) {
|
||||
headers['Authorization'] = 'JWT ' + this._getToken();
|
||||
}
|
||||
|
||||
if (options.hasOwnProperty('headers')) {
|
||||
for (let header in headers) {
|
||||
options['headers'][header] = headers[header];
|
||||
}
|
||||
} else {
|
||||
options['headers'] = headers;
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
.then(res => {
|
||||
if (res.status === 401) {
|
||||
this._removeToken();
|
||||
}
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
loggedIn() {
|
||||
if (!this.AUTH_ENABLED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = this._getToken();
|
||||
return ((token !== null) && !this._isTokenExpired(token));
|
||||
}
|
||||
|
||||
logout() {
|
||||
if (this.AUTH_ENABLED) {
|
||||
this._removeToken();
|
||||
}
|
||||
}
|
||||
|
||||
_isTokenExpired(token) {
|
||||
try {
|
||||
return decode(token)['exp'] < Date.now() / 1000;
|
||||
}
|
||||
catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_setToken(idToken) {
|
||||
localStorage.setItem('jwt', idToken);
|
||||
}
|
||||
|
||||
_removeToken() {
|
||||
localStorage.removeItem('jwt');
|
||||
}
|
||||
|
||||
_getToken() {
|
||||
return localStorage.getItem('jwt')
|
||||
}
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue