From 44d6eb8570090544620ac6bd174f3f249f47190d Mon Sep 17 00:00:00 2001 From: Terrtia Date: Thu, 11 Jul 2019 13:58:15 +0200 Subject: [PATCH 01/25] chg: [rest API] access: check token user role --- var/www/modules/restApi/Flask_restApi.py | 48 +++++++++++++++++------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 4535da28..f73434de 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -36,7 +36,7 @@ def check_token_format(strg, search=re.compile(r'[^a-zA-Z0-9_-]').search): return not bool(search(strg)) def verify_token(token): - if len(token) != 55: + if len(token) != 41: return False if not check_token_format(token): @@ -47,23 +47,41 @@ def verify_token(token): else: return False +def verify_user_role(role, token): + user_id = r_serv_db.hget('user:tokens', token) + if user_id: + if is_in_role(user_id, role): + return True + else: + return False + else: + return False + +def is_in_role(user_id, role): + if r_serv_db.sismember('user_role:{}'.format(role), user_id): + return True + else: + return False + # ============ DECORATOR ============ -def token_required(funct): - @wraps(funct) - def api_token(*args, **kwargs): - data = authErrors() - if data: - return Response(json.dumps(data[0], indent=2, sort_keys=True), mimetype='application/json'), data[1] - else: - return funct(*args, **kwargs) - return api_token +def token_required(user_role): + def actual_decorator(funct): + @wraps(funct) + def api_token(*args, **kwargs): + data = authErrors(user_role) + if data: + return Response(json.dumps(data[0], indent=2, sort_keys=True), mimetype='application/json'), data[1] + else: + return funct(*args, **kwargs) + return api_token + return actual_decorator def get_auth_from_header(): token = request.headers.get('Authorization').replace(' ', '') # remove space return token -def authErrors(): +def authErrors(user_role): # Check auth if not request.headers.get('Authorization'): return ({'status': 'error', 'reason': 'Authentication needed'}, 401) @@ -76,6 +94,10 @@ def authErrors(): if verify_token(token): authenticated = True + # check user role + if not verify_user_role(user_role, token): + data = ({'status': 'error', 'reason': 'Access Forbidden'}, 403) + if not authenticated: data = ({'status': 'error', 'reason': 'Authentication failed'}, 401) except Exception as e: @@ -98,8 +120,8 @@ def one(): # def api(): # return 'api doc' -@restApi.route("api/items", methods=['POST']) -@token_required +@restApi.route("api/items", methods=['GET', 'POST']) +@token_required('admin') def items(): item = request.args.get('id') From 3a8531cafad319a1076cac47574bb6d68d44700c Mon Sep 17 00:00:00 2001 From: Terrtia Date: Thu, 25 Jul 2019 17:26:32 +0200 Subject: [PATCH 02/25] chg: [API + import] add API format + item_import refractor --- OVERVIEW.md | 15 ++++ bin/packages/Import_helper.py | 34 +++++++++ bin/packages/Tags.py | 61 ++++++++++++++++ bin/submit_paste.py | 3 - var/www/Flask_server.py | 20 +++--- var/www/modules/Flask_config.py | 3 +- .../modules/PasteSubmit/Flask_PasteSubmit.py | 72 ++++++------------- var/www/modules/restApi/Flask_restApi.py | 68 ++++++++++++++++++ 8 files changed, 210 insertions(+), 66 deletions(-) create mode 100755 bin/packages/Import_helper.py create mode 100755 bin/packages/Tags.py diff --git a/OVERVIEW.md b/OVERVIEW.md index 38ac7e7f..ee553848 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -38,6 +38,21 @@ Redis and ARDB overview | failed_login_ip:**ip** | **nb login failed** | TTL | failed_login_user_id:**user_id** | **nb login failed** | TTL +##### Item Import: + +| Key | Value | +| ------ | ------ | +| **uuid**:nb_total | **nb total** | TTL *(if imported)* +| **uuid**:nb_end | **nb** | TTL *(if imported)* +| **uuid**:nb_sucess | **nb success** | TTL *(if imported)* +| **uuid**:end | **0 (in progress) or (item imported)** | TTL *(if imported)* +| **uuid**:processing | **process status: 0 or 1** | TTL *(if imported)* +| **uuid**:error | **error message** | TTL *(if imported)* + +| Set Key | Value | +| ------ | ------ | +| **uuid**:paste_submit_link | **item_path** | TTL *(if imported)* + ## DB0 - Core: ##### Update keys: diff --git a/bin/packages/Import_helper.py b/bin/packages/Import_helper.py new file mode 100755 index 00000000..85a8b0d5 --- /dev/null +++ b/bin/packages/Import_helper.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# -*-coding:UTF-8 -* + +import os +import redis + +import Flask_config + +r_serv_db = Flask_config.r_serv_db +r_serv_log = Flask_config.r_serv_log + +def create_import_queue(ltags, ltagsgalaxies, paste_content, UUID, password, isfile = False): + + # save temp value on disk + r_serv_db.set(UUID + ':ltags', ltags) + r_serv_db.set(UUID + ':ltagsgalaxies', ltagsgalaxies) + r_serv_db.set(UUID + ':paste_content', paste_content) + r_serv_db.set(UUID + ':password', password) + r_serv_db.set(UUID + ':isfile', isfile) + + r_serv_log.set(UUID + ':end', 0) + r_serv_log.set(UUID + ':processing', 0) + r_serv_log.set(UUID + ':nb_total', -1) + r_serv_log.set(UUID + ':nb_end', 0) + r_serv_log.set(UUID + ':nb_sucess', 0) + + # save UUID on disk + r_serv_db.sadd('submitted:uuid', UUID) + return UUID + +def import_text_item(): + res = r_serv_db.smembers('submitted:uuid') + print(res) + return res diff --git a/bin/packages/Tags.py b/bin/packages/Tags.py new file mode 100755 index 00000000..d916a29d --- /dev/null +++ b/bin/packages/Tags.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# -*-coding:UTF-8 -* + +import os +import redis + +import Flask_config + +from pytaxonomies import Taxonomies +from pymispgalaxies import Galaxies, Clusters + +r_serv_tags = Flask_config.r_serv_tags + +def get_taxonomie_from_tag(tag): + return tag.split(':')[0] + +def get_galaxy_from_tag(tag): + galaxy = tag.split(':')[1] + galaxy = galaxy.split('=')[0] + return galaxy + +def get_active_taxonomies(): + return r_serv_tags.smembers('active_taxonomies') + +def get_active_galaxies(): + return r_serv_tags.smembers('active_galaxies') + +def is_taxonomie_tag_enabled(taxonomie, tag): + if tag in r_serv_tags.smembers('active_tag_' + taxonomie): + return True + else: + return False + +def is_galaxy_tag_enabled(taxonomie, galaxy): + if tag in r_serv_tags.smembers('active_tag_galaxies_' + galaxy): + return True + else: + return False + +# Check if tags are enabled in AIL +def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): + if list_tags: + active_taxonomies = Tags.get_active_taxonomies() + + for tag in list_tags: + taxonomie = get_taxonomie_from_tag(tag) + if taxonomie not in active_taxonomies: + return False + if not is_taxonomie_tag_enabled(taxonomie, tag): + return False + + if list_tags_galaxy: + active_galaxies = Tags.get_active_galaxies() + + for tag in list_tags_galaxy: + galaxy = get_galaxy_from_tag(tag) + if galaxy not in active_galaxies: + return False + if not is_galaxy_tag_enabled(galaxy, tag): + return False + return True diff --git a/bin/submit_paste.py b/bin/submit_paste.py index 34009774..e6875b3b 100755 --- a/bin/submit_paste.py +++ b/bin/submit_paste.py @@ -92,7 +92,6 @@ def remove_submit_uuid(uuid): r_serv_log_submit.expire(uuid + ':nb_sucess', expire_time) r_serv_log_submit.expire(uuid + ':nb_end', expire_time) r_serv_log_submit.expire(uuid + ':error', expire_time) - r_serv_log_submit.srem(uuid + ':paste_submit_link', '') r_serv_log_submit.expire(uuid + ':paste_submit_link', expire_time) # delete uuid @@ -230,8 +229,6 @@ if __name__ == "__main__": r_serv_log_submit.set(uuid + ':nb_total', -1) r_serv_log_submit.set(uuid + ':nb_end', 0) r_serv_log_submit.set(uuid + ':nb_sucess', 0) - r_serv_log_submit.set(uuid + ':error', 'error:') - r_serv_log_submit.sadd(uuid + ':paste_submit_link', '') r_serv_log_submit.set(uuid + ':processing', 1) diff --git a/var/www/Flask_server.py b/var/www/Flask_server.py index 8ba4526e..ab22ffd1 100755 --- a/var/www/Flask_server.py +++ b/var/www/Flask_server.py @@ -67,15 +67,15 @@ log_dir = os.path.join(os.environ['AIL_HOME'], 'logs') if not os.path.isdir(log_dir): os.makedirs(logs_dir) -log_filename = os.path.join(log_dir, 'flask_server.logs') -logger = logging.getLogger() -formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') -handler_log = logging.handlers.TimedRotatingFileHandler(log_filename, when="midnight", interval=1) -handler_log.suffix = '%Y-%m-%d.log' -handler_log.setFormatter(formatter) -handler_log.setLevel(30) -logger.addHandler(handler_log) -logger.setLevel(30) +# log_filename = os.path.join(log_dir, 'flask_server.logs') +# logger = logging.getLogger() +# formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') +# handler_log = logging.handlers.TimedRotatingFileHandler(log_filename, when="midnight", interval=1) +# handler_log.suffix = '%Y-%m-%d.log' +# handler_log.setFormatter(formatter) +# handler_log.setLevel(30) +# logger.addHandler(handler_log) +# logger.setLevel(30) # ========= =========# @@ -226,7 +226,7 @@ def login(): # login failed else: # set brute force protection - logger.warning("Login failed, ip={}, username={}".format(current_ip, username)) + #logger.warning("Login failed, ip={}, username={}".format(current_ip, username)) r_cache.incr('failed_login_ip:{}'.format(current_ip)) r_cache.expire('failed_login_ip:{}'.format(current_ip), 300) r_cache.incr('failed_login_user_id:{}'.format(username)) diff --git a/var/www/modules/Flask_config.py b/var/www/modules/Flask_config.py index ff5ba02a..0e0d0e8b 100644 --- a/var/www/modules/Flask_config.py +++ b/var/www/modules/Flask_config.py @@ -12,7 +12,6 @@ import sys # FLASK # app = None -#secret_key = 'ail-super-secret_key01C' # CONFIG # configfile = os.path.join(os.environ['AIL_BIN'], 'packages/config.cfg') @@ -146,7 +145,7 @@ if HiveApi != False: HiveApi = False print('The Hive not connected') -# VARIABLES # +#### VARIABLES #### baseUrl = cfg.get("Flask", "baseurl") baseUrl = baseUrl.replace('/', '') if baseUrl != '': diff --git a/var/www/modules/PasteSubmit/Flask_PasteSubmit.py b/var/www/modules/PasteSubmit/Flask_PasteSubmit.py index efd0650e..11e405a7 100644 --- a/var/www/modules/PasteSubmit/Flask_PasteSubmit.py +++ b/var/www/modules/PasteSubmit/Flask_PasteSubmit.py @@ -23,6 +23,9 @@ import json import Paste +import Import_helper +import Tags + from pytaxonomies import Taxonomies from pymispgalaxies import Galaxies, Clusters @@ -108,44 +111,6 @@ def launch_submit(ltags, ltagsgalaxies, paste_content, UUID, password, isfile = # save UUID on disk r_serv_db.sadd('submitted:uuid', UUID) - -def addTagsVerification(tags, tagsgalaxies): - - list_tag = tags.split(',') - list_tag_galaxies = tagsgalaxies.split(',') - - taxonomies = Taxonomies() - active_taxonomies = r_serv_tags.smembers('active_taxonomies') - - active_galaxies = r_serv_tags.smembers('active_galaxies') - - if list_tag != ['']: - for tag in list_tag: - # verify input - tax = tag.split(':')[0] - if tax in active_taxonomies: - if tag in r_serv_tags.smembers('active_tag_' + tax): - pass - else: - return False - else: - return False - - if list_tag_galaxies != ['']: - for tag in list_tag_galaxies: - # verify input - gal = tag.split(':')[1] - gal = gal.split('=')[0] - - if gal in active_galaxies: - if tag in r_serv_tags.smembers('active_tag_galaxies_' + gal): - pass - else: - return False - else: - return False - return True - def date_to_str(date): return "{0}-{1}-{2}".format(date.year, date.month, date.day) @@ -279,11 +244,9 @@ def hive_create_case(hive_tlp, threat_level, hive_description, hive_case_title, @login_required @login_analyst def PasteSubmit_page(): - #active taxonomies - active_taxonomies = r_serv_tags.smembers('active_taxonomies') - - #active galaxies - active_galaxies = r_serv_tags.smembers('active_galaxies') + # Get all active tags/galaxy + active_taxonomies = Tags.get_active_taxonomies() + active_galaxies = Tags.get_active_galaxies() return render_template("submit_items.html", active_taxonomies = active_taxonomies, @@ -301,6 +264,9 @@ def submit(): ltagsgalaxies = request.form['tags_galaxies'] paste_content = request.form['paste_content'] + print(ltags) + print(ltagsgalaxies) + is_file = False if 'file' in request.files: file = request.files['file'] @@ -311,12 +277,16 @@ def submit(): submitted_tag = 'infoleak:submission="manual"' #active taxonomies - active_taxonomies = r_serv_tags.smembers('active_taxonomies') + active_taxonomies = Tags.get_active_taxonomies() #active galaxies - active_galaxies = r_serv_tags.smembers('active_galaxies') + active_galaxies = Tags.get_active_galaxies() if ltags or ltagsgalaxies: - if not addTagsVerification(ltags, ltagsgalaxies): + + list_tag = tags.split(',') + list_tag_galaxies = tagsgalaxies.split(',') + + if not Tags.is_valid_tags_taxonomies_galaxy(ltags, ltagsgalaxies): content = 'INVALID TAGS' print(content) return content, 400 @@ -358,7 +328,7 @@ def submit(): paste_content = full_path - launch_submit(ltags, ltagsgalaxies, paste_content, UUID, password ,True) + Import_helper.create_import_queue(ltags, ltagsgalaxies, paste_content, UUID, password ,True) return render_template("submit_items.html", active_taxonomies = active_taxonomies, @@ -381,7 +351,7 @@ def submit(): # clean file name #id = clean_filename(paste_name) - launch_submit(ltags, ltagsgalaxies, paste_content, UUID, password) + Import_helper.create_import_queue(ltags, ltagsgalaxies, paste_content, UUID, password) return render_template("submit_items.html", active_taxonomies = active_taxonomies, @@ -433,10 +403,10 @@ def submit_status(): else: prog = 0 - if error == 'error:': - isError = False - else: + if error: isError = True + else: + isError = False if end == '0': end = False diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index f73434de..07e3240f 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -8,10 +8,13 @@ import os import re import sys +import uuid import json import redis import datetime +import Import_helper + from flask import Flask, render_template, jsonify, request, Blueprint, redirect, url_for, Response from flask_login import login_required @@ -20,6 +23,7 @@ from functools import wraps # ============ VARIABLES ============ import Flask_config + app = Flask_config.app cfg = Flask_config.cfg baseUrl = Flask_config.baseUrl @@ -108,8 +112,20 @@ def authErrors(user_role): else: return None +# ============ API CORE ============= + + + # ============ FUNCTIONS ============ +def is_valid_uuid_v4(header_uuid): + try: + header_uuid=header_uuid.replace('-', '') + uuid_test = uuid.UUID(hex=header_uuid, version=4) + return uuid_test.hex == header_uuid + except: + return False + def one(): return 1 @@ -127,5 +143,57 @@ def items(): return Response(json.dumps({'test': 2}), mimetype='application/json') + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# +# POST JSON FORMAT +# +# { +# "type": "text", (default value) +# "tags": [], (default value) +# "default_ags": True, (default value) +# "galaxy" [], (default value) +# "text": "", mandatory if type = text +# } +# +# response: {"uuid": "uuid"} +# +# # # # +# GET +# +# { +# "uuid": "uuid", mandatory +# } +# +# response: {"uuid": "uuid"} +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +@restApi.route("api/import/item", methods=['POST']) +@token_required('admin') +def import_item(): + data = request.get_json() + if not data: + return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + + # TODO: add submitted tag + + UUID = 'uuuuuuu' + + return Response(json.dumps({'uuid': UUID}, indent=2, sort_keys=True), mimetype='application/json') + +@restApi.route("api/import/item/", methods=['GET']) +@token_required('admin') +def import_item_uuid(UUID): + + # Verify uuid + if not is_valid_uuid_v4(UUID): + Response(json.dumps({'status': 'error', 'reason': 'Invalid uuid'}), mimetype='application/json'), 400 + + + + + return Response(json.dumps({'item_id': 4}), mimetype='application/json') + # ========= REGISTRATION ========= app.register_blueprint(restApi, url_prefix=baseUrl) From 0a756294fe7b74aaa5c3167a7de320f85248dd74 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Fri, 26 Jul 2019 14:28:02 +0200 Subject: [PATCH 03/25] chg: [API] import item (text) --- OVERVIEW.md | 12 ++++ bin/LAUNCH.sh | 6 +- bin/packages/Import_helper.py | 70 +++++++++++++++---- bin/packages/Tags.py | 8 ++- bin/submit_paste.py | 24 +++---- var/www/modules/Flask_config.py | 2 + .../modules/PasteSubmit/Flask_PasteSubmit.py | 45 +++--------- var/www/modules/restApi/Flask_restApi.py | 60 +++++++++++----- 8 files changed, 138 insertions(+), 89 deletions(-) diff --git a/OVERVIEW.md b/OVERVIEW.md index ee553848..f4ee12ec 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -97,6 +97,18 @@ Redis and ARDB overview | ------ | ------ | ------ | | ail:all_role | **role** | **int, role priority (1=admin)** | +##### Item Import: +| Key | Value | +| ------ | ------ | +| **uuid**:isfile | **boolean** | +| **uuid**:paste_content | **item_content** | + +| Set Key | Value | +| ------ | ------ | +| submitted:uuid | **uuid** | +| **uuid**:ltags | **tag** | +| **uuid**:ltagsgalaxies | **tag** | + ## DB2 - TermFreq: ##### Set: diff --git a/bin/LAUNCH.sh b/bin/LAUNCH.sh index e4175b90..98645165 100755 --- a/bin/LAUNCH.sh +++ b/bin/LAUNCH.sh @@ -66,8 +66,8 @@ function helptext { "$DEFAULT" This script launch: "$CYAN" - - All the ZMQ queuing modules. - - All the ZMQ processing modules. + - All the queuing modules. + - All the processing modules. - All Redis in memory servers. - All ARDB on disk servers. "$DEFAULT" @@ -143,7 +143,7 @@ function launching_scripts { screen -dmS "Script_AIL" sleep 0.1 - echo -e $GREEN"\t* Launching ZMQ scripts"$DEFAULT + echo -e $GREEN"\t* Launching scripts"$DEFAULT screen -S "Script_AIL" -X screen -t "ModuleInformation" bash -c "cd ${AIL_BIN}; ${ENV_PY} ./ModulesInformationV2.py -k 0 -c 1; read x" sleep 0.1 diff --git a/bin/packages/Import_helper.py b/bin/packages/Import_helper.py index 85a8b0d5..3ce4406f 100755 --- a/bin/packages/Import_helper.py +++ b/bin/packages/Import_helper.py @@ -2,33 +2,75 @@ # -*-coding:UTF-8 -* import os +import uuid import redis import Flask_config r_serv_db = Flask_config.r_serv_db -r_serv_log = Flask_config.r_serv_log +r_serv_log_submit = Flask_config.r_serv_log_submit -def create_import_queue(ltags, ltagsgalaxies, paste_content, UUID, password, isfile = False): +def is_valid_uuid_v4(UUID): + UUID = UUID.replace('-', '') + try: + uuid_test = uuid.UUID(hex=UUID, version=4) + return uuid_test.hex == UUID + except: + return False + +def create_import_queue(tags, galaxy, paste_content, UUID, password=None, isfile = False): # save temp value on disk - r_serv_db.set(UUID + ':ltags', ltags) - r_serv_db.set(UUID + ':ltagsgalaxies', ltagsgalaxies) + for tag in tags: + r_serv_db.sadd(UUID + ':ltags', tag) + for tag in galaxy: + r_serv_db.sadd(UUID + ':ltagsgalaxies', tag) + r_serv_db.set(UUID + ':paste_content', paste_content) - r_serv_db.set(UUID + ':password', password) + + if password: + r_serv_db.set(UUID + ':password', password) + r_serv_db.set(UUID + ':isfile', isfile) - r_serv_log.set(UUID + ':end', 0) - r_serv_log.set(UUID + ':processing', 0) - r_serv_log.set(UUID + ':nb_total', -1) - r_serv_log.set(UUID + ':nb_end', 0) - r_serv_log.set(UUID + ':nb_sucess', 0) + r_serv_log_submit.set(UUID + ':end', 0) + r_serv_log_submit.set(UUID + ':processing', 0) + r_serv_log_submit.set(UUID + ':nb_total', -1) + r_serv_log_submit.set(UUID + ':nb_end', 0) + r_serv_log_submit.set(UUID + ':nb_sucess', 0) # save UUID on disk r_serv_db.sadd('submitted:uuid', UUID) return UUID -def import_text_item(): - res = r_serv_db.smembers('submitted:uuid') - print(res) - return res +def check_import_status(UUID): + if not is_valid_uuid_v4(UUID): + return ({'status': 'error', 'reason': 'Invalid uuid'}, 400) + + processing = r_serv_log_submit.get(UUID + ':processing') + if not processing: + return ({'status': 'error', 'reason': 'Unknow uuid'}, 400) + + # nb_total = r_serv_log_submit.get(UUID + ':nb_total') + # nb_sucess = r_serv_log_submit.get(UUID + ':nb_sucess') + # nb_end = r_serv_log_submit.get(UUID + ':nb_end') + items_id = list(r_serv_log_submit.smembers(UUID + ':paste_submit_link')) + error = r_serv_log_submit.get(UUID + ':error') + end = r_serv_log_submit.get(UUID + ':end') + + dict_import_status = {} + if items_id: + dict_import_status['items'] = items_id + if error: + dict_import_status['error'] = error + + if processing == '0': + status = 'in queue' + else: + if end == '0': + status = 'in progress' + else: + status = 'imported' + dict_import_status['status'] = status + + return (dict_import_status, 200) diff --git a/bin/packages/Tags.py b/bin/packages/Tags.py index d916a29d..88963732 100755 --- a/bin/packages/Tags.py +++ b/bin/packages/Tags.py @@ -31,7 +31,7 @@ def is_taxonomie_tag_enabled(taxonomie, tag): else: return False -def is_galaxy_tag_enabled(taxonomie, galaxy): +def is_galaxy_tag_enabled(galaxy, tag): if tag in r_serv_tags.smembers('active_tag_galaxies_' + galaxy): return True else: @@ -39,8 +39,10 @@ def is_galaxy_tag_enabled(taxonomie, galaxy): # Check if tags are enabled in AIL def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): + print(list_tags) + print(list_tags_galaxy) if list_tags: - active_taxonomies = Tags.get_active_taxonomies() + active_taxonomies = get_active_taxonomies() for tag in list_tags: taxonomie = get_taxonomie_from_tag(tag) @@ -50,7 +52,7 @@ def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): return False if list_tags_galaxy: - active_galaxies = Tags.get_active_galaxies() + active_galaxies = get_active_galaxies() for tag in list_tags_galaxy: galaxy = get_galaxy_from_tag(tag) diff --git a/bin/submit_paste.py b/bin/submit_paste.py index e6875b3b..0609f581 100755 --- a/bin/submit_paste.py +++ b/bin/submit_paste.py @@ -47,7 +47,11 @@ def create_paste(uuid, paste_content, ltags, ltagsgalaxies, name): r_serv_log_submit.hincrby("mixer_cache:list_feeder", "submitted", 1) # add tags - add_tags(ltags, ltagsgalaxies, rel_item_path) + for tag in ltags: + add_item_tag(tag, rel_item_path) + + for tag in ltagsgalaxies: + add_item_tag(tag, rel_item_path) r_serv_log_submit.incr(uuid + ':nb_end') r_serv_log_submit.incr(uuid + ':nb_sucess') @@ -133,18 +137,6 @@ def add_item_tag(tag, item_path): if item_date > tag_last_seen: r_serv_tags.hset('tag_metadata:{}'.format(tag), 'last_seen', item_date) -def add_tags(tags, tagsgalaxies, path): - list_tag = tags.split(',') - list_tag_galaxies = tagsgalaxies.split(',') - - if list_tag != ['']: - for tag in list_tag: - add_item_tag(tag, path) - - if list_tag_galaxies != ['']: - for tag in list_tag_galaxies: - add_item_tag(tag, path) - def verify_extention_filename(filename): if not '.' in filename: return True @@ -217,8 +209,8 @@ if __name__ == "__main__": uuid = r_serv_db.srandmember('submitted:uuid') # get temp value save on disk - ltags = r_serv_db.get(uuid + ':ltags') - ltagsgalaxies = r_serv_db.get(uuid + ':ltagsgalaxies') + ltags = r_serv_db.smembers(uuid + ':ltags') + ltagsgalaxies = r_serv_db.smembers(uuid + ':ltagsgalaxies') paste_content = r_serv_db.get(uuid + ':paste_content') isfile = r_serv_db.get(uuid + ':isfile') password = r_serv_db.get(uuid + ':password') @@ -272,7 +264,7 @@ if __name__ == "__main__": else: #decompress file try: - if password == '': + if password == None: files = unpack(file_full_path.encode()) #print(files.children) else: diff --git a/var/www/modules/Flask_config.py b/var/www/modules/Flask_config.py index 0e0d0e8b..0e3852e7 100644 --- a/var/www/modules/Flask_config.py +++ b/var/www/modules/Flask_config.py @@ -178,6 +178,8 @@ crawler_enabled = cfg.getboolean("Crawler", "activate_crawler") email_regex = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}' email_regex = re.compile(email_regex) +IMPORT_MAX_TEXT_SIZE = 900000 # size in bytes + # VT try: from virusTotalKEYS import vt_key diff --git a/var/www/modules/PasteSubmit/Flask_PasteSubmit.py b/var/www/modules/PasteSubmit/Flask_PasteSubmit.py index 11e405a7..71d16de2 100644 --- a/var/www/modules/PasteSubmit/Flask_PasteSubmit.py +++ b/var/www/modules/PasteSubmit/Flask_PasteSubmit.py @@ -90,27 +90,6 @@ def clean_filename(filename, whitelist=valid_filename_chars, replace=' '): # keep only whitelisted chars return ''.join(c for c in cleaned_filename if c in whitelist) -def launch_submit(ltags, ltagsgalaxies, paste_content, UUID, password, isfile = False): - - # save temp value on disk - r_serv_db.set(UUID + ':ltags', ltags) - r_serv_db.set(UUID + ':ltagsgalaxies', ltagsgalaxies) - r_serv_db.set(UUID + ':paste_content', paste_content) - r_serv_db.set(UUID + ':password', password) - r_serv_db.set(UUID + ':isfile', isfile) - - r_serv_log_submit.set(UUID + ':end', 0) - r_serv_log_submit.set(UUID + ':processing', 0) - r_serv_log_submit.set(UUID + ':nb_total', -1) - r_serv_log_submit.set(UUID + ':nb_end', 0) - r_serv_log_submit.set(UUID + ':nb_sucess', 0) - r_serv_log_submit.set(UUID + ':error', 'error:') - r_serv_log_submit.sadd(UUID + ':paste_submit_link', '') - - - # save UUID on disk - r_serv_db.sadd('submitted:uuid', UUID) - def date_to_str(date): return "{0}-{1}-{2}".format(date.year, date.month, date.day) @@ -264,9 +243,6 @@ def submit(): ltagsgalaxies = request.form['tags_galaxies'] paste_content = request.form['paste_content'] - print(ltags) - print(ltagsgalaxies) - is_file = False if 'file' in request.files: file = request.files['file'] @@ -283,8 +259,11 @@ def submit(): if ltags or ltagsgalaxies: - list_tag = tags.split(',') - list_tag_galaxies = tagsgalaxies.split(',') + ltags = ltags.split(',') + ltagsgalaxies = ltagsgalaxies.split(',') + + print(ltags) + print(ltagsgalaxies) if not Tags.is_valid_tags_taxonomies_galaxy(ltags, ltagsgalaxies): content = 'INVALID TAGS' @@ -292,10 +271,9 @@ def submit(): return content, 400 # add submitted tags - if(ltags != ''): - ltags = ltags + ',' + submitted_tag - else: - ltags = submitted_tag + if not ltags: + ltags = [] + ltags.append(submitted_tag) if is_file: if file: @@ -346,11 +324,6 @@ def submit(): # get id UUID = str(uuid.uuid4()) - - #if paste_name: - # clean file name - #id = clean_filename(paste_name) - Import_helper.create_import_queue(ltags, ltagsgalaxies, paste_content, UUID, password) return render_template("submit_items.html", @@ -385,7 +358,7 @@ def submit_status(): nb_sucess = r_serv_log_submit.get(UUID + ':nb_sucess') paste_submit_link = list(r_serv_log_submit.smembers(UUID + ':paste_submit_link')) - if (end != None) and (nb_total != None) and (nb_end != None) and (error != None) and (processing != None) and (paste_submit_link != None): + if (end != None) and (nb_total != None) and (nb_end != None) and (processing != None): link = '' if paste_submit_link: diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 07e3240f..ae3f0375 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -14,6 +14,7 @@ import redis import datetime import Import_helper +import Tags from flask import Flask, render_template, jsonify, request, Blueprint, redirect, url_for, Response from flask_login import login_required @@ -151,24 +152,14 @@ def items(): # { # "type": "text", (default value) # "tags": [], (default value) -# "default_ags": True, (default value) +# "default_tags": True, (default value) # "galaxy" [], (default value) # "text": "", mandatory if type = text # } # # response: {"uuid": "uuid"} # -# # # # -# GET -# -# { -# "uuid": "uuid", mandatory -# } -# -# response: {"uuid": "uuid"} -# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # - @restApi.route("api/import/item", methods=['POST']) @token_required('admin') def import_item(): @@ -176,24 +167,59 @@ def import_item(): if not data: return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 - # TODO: add submitted tag + # unpack json + text_to_import = data.get('text', None) + if not text_to_import: + return Response(json.dumps({'status': 'error', 'reason': 'No text supplied'}, indent=2, sort_keys=True), mimetype='application/json'), 400 - UUID = 'uuuuuuu' + tags = data.get('tags', []) + if not type(tags) is list: + tags = [] + galaxy = data.get('galaxy', []) + if not type(galaxy) is list: + galaxy = [] + + if not Tags.is_valid_tags_taxonomies_galaxy(tags, galaxy): + return Response(json.dumps({'status': 'error', 'reason': 'Tags or Galaxy not enabled'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + + default_tags = data.get('default_tags', True) + if default_tags: + tags.append('infoleak:submission="manual"') + + if sys.getsizeof(text_to_import) > 900000: + return Response(json.dumps({'status': 'error', 'reason': 'Size exceeds default'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + + UUID = str(uuid.uuid4()) + Import_helper.create_import_queue(tags, galaxy, text_to_import, UUID) return Response(json.dumps({'uuid': UUID}, indent=2, sort_keys=True), mimetype='application/json') +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# GET +# +# { +# "uuid": "uuid", mandatory +# } +# +# response: { +# "status": "in queue"/"in progress"/"imported", +# "items": [all item id] +# } +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/import/item/", methods=['GET']) @token_required('admin') def import_item_uuid(UUID): # Verify uuid if not is_valid_uuid_v4(UUID): - Response(json.dumps({'status': 'error', 'reason': 'Invalid uuid'}), mimetype='application/json'), 400 + return Response(json.dumps({'status': 'error', 'reason': 'Invalid uuid'}), mimetype='application/json'), 400 + data = Import_helper.check_import_status(UUID) + if data: + return Response(json.dumps(data[0]), mimetype='application/json'), data[1] - - - return Response(json.dumps({'item_id': 4}), mimetype='application/json') + return Response(json.dumps({'status': 'error', 'reason': 'Invalid response'}), mimetype='application/json'), 400 # ========= REGISTRATION ========= app.register_blueprint(restApi, url_prefix=baseUrl) From 6af9514a48be9074df294c3ec6c0ecfdccd86513 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Fri, 26 Jul 2019 15:44:29 +0200 Subject: [PATCH 04/25] chg: [API] add GET: item metadata + item content + item tags --- bin/packages/Item.py | 16 +++++ bin/packages/Paste.py | 15 ++++- bin/packages/Tags.py | 8 +++ var/www/modules/restApi/Flask_restApi.py | 77 ++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100755 bin/packages/Item.py diff --git a/bin/packages/Item.py b/bin/packages/Item.py new file mode 100755 index 00000000..1bc77b79 --- /dev/null +++ b/bin/packages/Item.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# -*-coding:UTF-8 -* + +import os +import redis + +import Flask_config +import Date + +PASTES_FOLDER = Flask_config.PASTES_FOLDER + +def exist_item(item_id): + if os.path.isfile(os.path.join(PASTES_FOLDER, item_id)): + return True + else: + return False diff --git a/bin/packages/Paste.py b/bin/packages/Paste.py index 1087880b..6c464610 100755 --- a/bin/packages/Paste.py +++ b/bin/packages/Paste.py @@ -115,6 +115,17 @@ class Paste(object): self.p_duplicate = None self.p_tags = None + def get_item_dict(self): + dict_item = {} + dict_item['id'] = self.p_rel_path + dict_item['date'] = str(self.p_date) + dict_item['content'] = self.get_p_content() + tags = self._get_p_tags() + if tags: + dict_item['tags'] = tags + return dict_item + + def get_p_content(self): """ Returning the content of the Paste @@ -321,8 +332,8 @@ class Paste(object): return self.store_metadata.scard('dup:'+self.p_path) + self.store_metadata.scard('dup:'+self.p_rel_path) def _get_p_tags(self): - self.p_tags = self.store_metadata.smembers('tag:'+path, tag) - if self.self.p_tags is not None: + self.p_tags = self.store_metadata.smembers('tag:'+self.p_rel_path) + if self.p_tags is not None: return list(self.p_tags) else: return '[]' diff --git a/bin/packages/Tags.py b/bin/packages/Tags.py index 88963732..d6636ca7 100755 --- a/bin/packages/Tags.py +++ b/bin/packages/Tags.py @@ -10,6 +10,7 @@ from pytaxonomies import Taxonomies from pymispgalaxies import Galaxies, Clusters r_serv_tags = Flask_config.r_serv_tags +r_serv_metadata = Flask_config.r_serv_metadata def get_taxonomie_from_tag(tag): return tag.split(':')[0] @@ -61,3 +62,10 @@ def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): if not is_galaxy_tag_enabled(galaxy, tag): return False return True + +def get_item_tags(item_id): + tags = r_serv_metadata.smembers('tag:'+item_id) + if tags: + return list(tags) + else: + return '[]' diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index ae3f0375..2c10ad62 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -14,6 +14,8 @@ import redis import datetime import Import_helper +import Item +import Paste import Tags from flask import Flask, render_template, jsonify, request, Blueprint, redirect, url_for, Response @@ -144,6 +146,81 @@ def items(): return Response(json.dumps({'test': 2}), mimetype='application/json') +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# GET +# +# { +# "id": item_id, mandatory +# } +# +# response: { +# "id": "item_id", +# "date": "date", +# "tags": [], +# "content": "item content" +# } +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +@restApi.route("api/get/item/info/", methods=['GET']) +@token_required('admin') +def get_item_id(item_id): + try: + item_object = Paste.Paste(item_id) + except FileNotFoundError: + return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + + data = item_object.get_item_dict() + return Response(json.dumps(data, indent=2, sort_keys=True), mimetype='application/json') + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# GET +# +# { +# "id": item_id, mandatory +# } +# +# response: { +# "id": "item_id", +# "tags": [], +# } +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +@restApi.route("api/get/item/tag/", methods=['GET']) +@token_required('admin') +def get_item_tag(item_id): + if not Item.exist_item(item_id): + return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + tags = Tags.get_item_tags(item_id) + dict_tags = {} + dict_tags['id'] = item_id + dict_tags['tags'] = tags + return Response(json.dumps(dict_tags, indent=2, sort_keys=True), mimetype='application/json') + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# GET +# +# { +# "id": item_id, mandatory +# } +# +# response: { +# "id": "item_id", +# "content": "item content" +# } +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +@restApi.route("api/get/item/content/", methods=['GET']) +@token_required('admin') +def get_item_content(item_id): + try: + item_object = Paste.Paste(item_id) + except FileNotFoundError: + return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + item_object = Paste.Paste(item_id) + dict_content = {} + dict_content['id'] = item_id + dict_content['content'] = item_object.get_p_content() + return Response(json.dumps(dict_content, indent=2, sort_keys=True), mimetype='application/json') # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # From 44cf5bb4af753acbac7ad0b0215ebef748758ef9 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Tue, 30 Jul 2019 13:49:21 +0200 Subject: [PATCH 05/25] chg: [API v1] add API documentation + update/delete items tags + Flask_tags refractor --- bin/packages/Date.py | 10 + bin/packages/Item.py | 4 + bin/packages/Tag.py | 210 ++++++++++ bin/packages/Tags.py | 71 ---- doc/README.md | 464 +++++++++++++++++++++++ doc/api/submit_paste.py | 53 --- doc/dia/ZMQ_Queuing_Tree.jpg | Bin 74495 -> 0 bytes var/www/Flask_server.py | 12 +- var/www/modules/Tags/Flask_Tags.py | 149 +------- var/www/modules/restApi/Flask_restApi.py | 379 +++++++++++++++++- 10 files changed, 1068 insertions(+), 284 deletions(-) create mode 100755 bin/packages/Tag.py delete mode 100755 bin/packages/Tags.py create mode 100644 doc/README.md delete mode 100755 doc/api/submit_paste.py delete mode 100644 doc/dia/ZMQ_Queuing_Tree.jpg diff --git a/bin/packages/Date.py b/bin/packages/Date.py index 72b960b1..85edb0be 100644 --- a/bin/packages/Date.py +++ b/bin/packages/Date.py @@ -40,3 +40,13 @@ class Date(object): comp_month = str(computed_date.month).zfill(2) comp_day = str(computed_date.day).zfill(2) return comp_year + comp_month + comp_day + +def date_add_day(date, num_day=1): + new_date = datetime.date(int(date[0:4]), int(date[4:6]), int(date[6:8])) + datetime.timedelta(num_day) + new_date = str(new_date).replace('-', '') + return new_date + +def date_substract_day(date, num_day=1): + new_date = datetime.date(int(date[0:4]), int(date[4:6]), int(date[6:8])) - datetime.timedelta(num_day) + new_date = str(new_date).replace('-', '') + return new_date diff --git a/bin/packages/Item.py b/bin/packages/Item.py index 1bc77b79..a2276fbd 100755 --- a/bin/packages/Item.py +++ b/bin/packages/Item.py @@ -14,3 +14,7 @@ def exist_item(item_id): return True else: return False + +def get_item_date(item_id): + l_directory = item_id.split('/') + return '{}{}{}'.format(l_directory[-4], l_directory[-3], l_directory[-2]) diff --git a/bin/packages/Tag.py b/bin/packages/Tag.py new file mode 100755 index 00000000..eb5a5bb3 --- /dev/null +++ b/bin/packages/Tag.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# -*-coding:UTF-8 -* + +import os +import redis + +import Flask_config +import Date +import Item + +from pytaxonomies import Taxonomies +from pymispgalaxies import Galaxies, Clusters + +r_serv_tags = Flask_config.r_serv_tags +r_serv_metadata = Flask_config.r_serv_metadata + +def get_taxonomie_from_tag(tag): + return tag.split(':')[0] + +def get_galaxy_from_tag(tag): + galaxy = tag.split(':')[1] + galaxy = galaxy.split('=')[0] + return galaxy + +def get_active_taxonomies(): + return r_serv_tags.smembers('active_taxonomies') + +def get_active_galaxies(): + return r_serv_tags.smembers('active_galaxies') + +def is_taxonomie_tag_enabled(taxonomie, tag): + if tag in r_serv_tags.smembers('active_tag_' + taxonomie): + return True + else: + return False + +def is_galaxy_tag_enabled(galaxy, tag): + if tag in r_serv_tags.smembers('active_tag_galaxies_' + galaxy): + return True + else: + return False + +# Check if tags are enabled in AIL +def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): + print(list_tags) + print(list_tags_galaxy) + if list_tags: + active_taxonomies = get_active_taxonomies() + + for tag in list_tags: + taxonomie = get_taxonomie_from_tag(tag) + if taxonomie not in active_taxonomies: + return False + if not is_taxonomie_tag_enabled(taxonomie, tag): + return False + + if list_tags_galaxy: + active_galaxies = get_active_galaxies() + + for tag in list_tags_galaxy: + galaxy = get_galaxy_from_tag(tag) + if galaxy not in active_galaxies: + return False + if not is_galaxy_tag_enabled(galaxy, tag): + return False + return True + +def get_item_tags(item_id): + tags = r_serv_metadata.smembers('tag:'+item_id) + if tags: + return list(tags) + else: + return '[]' + +# TEMPLATE + API QUERY +def add_items_tag(tags=[], galaxy_tags=[], item_id=None): + res_dict = {} + if item_id == None: + return ({'status': 'error', 'reason': 'Item id not found'}, 400) + if not tags and not galaxy_tags: + return ({'status': 'error', 'reason': 'Tags or Galaxy not specified'}, 400) + + res_dict['tags'] = [] + for tag in tags: + taxonomie = get_taxonomie_from_tag(tag) + if is_taxonomie_tag_enabled(taxonomie, tag): + add_item_tag(tag, item_id) + res_dict['tags'].append(tag) + else: + return ({'status': 'error', 'reason': 'Tags or Galaxy not enabled'}, 400) + + for tag in galaxy_tags: + galaxy = get_galaxy_from_tag(tag) + if is_galaxy_tag_enabled(galaxy, tag): + add_item_tag(tag, item_id) + res_dict['tags'].append(tag) + else: + return ({'status': 'error', 'reason': 'Tags or Galaxy not enabled'}, 400) + + res_dict['id'] = item_id + return (res_dict, 200) + + +def add_item_tag(tag, item_path): + + item_date = int(Item.get_item_date(item_path)) + + #add tag + r_serv_metadata.sadd('tag:{}'.format(item_path), tag) + r_serv_tags.sadd('{}:{}'.format(tag, item_date), item_path) + + r_serv_tags.hincrby('daily_tags:{}'.format(item_date), tag, 1) + + tag_first_seen = r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen') + if tag_first_seen is None: + tag_first_seen = 99999999 + else: + tag_first_seen = int(tag_first_seen) + tag_last_seen = r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen') + if tag_last_seen is None: + tag_last_seen = 0 + else: + tag_last_seen = int(tag_last_seen) + + #add new tag in list of all used tags + r_serv_tags.sadd('list_tags', tag) + + # update fisrt_seen/last_seen + if item_date < tag_first_seen: + r_serv_tags.hset('tag_metadata:{}'.format(tag), 'first_seen', item_date) + + # update metadata last_seen + if item_date > tag_last_seen: + r_serv_tags.hset('tag_metadata:{}'.format(tag), 'last_seen', item_date) + +# API QUERY +def remove_item_tags(tags=[], item_id=None): + if item_id == None: + return ({'status': 'error', 'reason': 'Item id not found'}, 400) + if not tags: + return ({'status': 'error', 'reason': 'No Tag(s) specified'}, 400) + + dict_res = {} + dict_res[tags] = [] + for tag in tags: + res = remove_item_tag(tag, item_id) + if res[1] != 200: + return res + else: + dict_res[tags].append(tag) + dict_res[id] = item_id + return (dict_res, 200) + +# TEMPLATE + API QUERY +def remove_item_tag(tag, item_id): + item_date = int(Item.get_item_date(item_id)) + + #remove tag + r_serv_metadata.srem('tag:{}'.format(item_id), tag) + res = r_serv_tags.srem('{}:{}'.format(tag, item_date), item_id) + + if res ==1: + # no tag for this day + if int(r_serv_tags.hget('daily_tags:{}'.format(item_date), tag)) == 1: + r_serv_tags.hdel('daily_tags:{}'.format(item_date), tag) + else: + r_serv_tags.hincrby('daily_tags:{}'.format(item_date), tag, -1) + + tag_first_seen = int(r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen')) + tag_last_seen = int(r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen')) + # update fisrt_seen/last_seen + if item_date == tag_first_seen: + update_tag_first_seen(tag, tag_first_seen, tag_last_seen) + if item_date == tag_last_seen: + update_tag_last_seen(tag, tag_first_seen, tag_last_seen) + return ({'status': 'success'}, 200) + else: + return ({'status': 'error', 'reason': 'Item id or tag not found'}, 400) + +def update_tag_first_seen(tag, tag_first_seen, tag_last_seen): + if tag_first_seen == tag_last_seen: + if r_serv_tags.scard('{}:{}'.format(tag, tag_first_seen)) > 0: + r_serv_tags.hset('tag_metadata:{}'.format(tag), 'first_seen', tag_first_seen) + # no tag in db + else: + r_serv_tags.srem('list_tags', tag) + r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'first_seen') + r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'last_seen') + else: + if r_serv_tags.scard('{}:{}'.format(tag, tag_first_seen)) > 0: + r_serv_tags.hset('tag_metadata:{}'.format(tag), 'first_seen', tag_first_seen) + else: + tag_first_seen = Date.date_add_day(tag_first_seen) + update_tag_first_seen(tag, tag_first_seen, tag_last_seen) + +def update_tag_last_seen(tag, tag_first_seen, tag_last_seen): + if tag_first_seen == tag_last_seen: + if r_serv_tags.scard('{}:{}'.format(tag, tag_last_seen)) > 0: + r_serv_tags.hset('tag_metadata:{}'.format(tag), 'last_seen', tag_last_seen) + # no tag in db + else: + r_serv_tags.srem('list_tags', tag) + r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'first_seen') + r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'last_seen') + else: + if r_serv_tags.scard('{}:{}'.format(tag, tag_last_seen)) > 0: + r_serv_tags.hset('tag_metadata:{}'.format(tag), 'last_seen', tag_last_seen) + else: + tag_last_seen = Date.date_substract_day(tag_last_seen) + update_tag_last_seen(tag, tag_first_seen, tag_last_seen) diff --git a/bin/packages/Tags.py b/bin/packages/Tags.py deleted file mode 100755 index d6636ca7..00000000 --- a/bin/packages/Tags.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -# -*-coding:UTF-8 -* - -import os -import redis - -import Flask_config - -from pytaxonomies import Taxonomies -from pymispgalaxies import Galaxies, Clusters - -r_serv_tags = Flask_config.r_serv_tags -r_serv_metadata = Flask_config.r_serv_metadata - -def get_taxonomie_from_tag(tag): - return tag.split(':')[0] - -def get_galaxy_from_tag(tag): - galaxy = tag.split(':')[1] - galaxy = galaxy.split('=')[0] - return galaxy - -def get_active_taxonomies(): - return r_serv_tags.smembers('active_taxonomies') - -def get_active_galaxies(): - return r_serv_tags.smembers('active_galaxies') - -def is_taxonomie_tag_enabled(taxonomie, tag): - if tag in r_serv_tags.smembers('active_tag_' + taxonomie): - return True - else: - return False - -def is_galaxy_tag_enabled(galaxy, tag): - if tag in r_serv_tags.smembers('active_tag_galaxies_' + galaxy): - return True - else: - return False - -# Check if tags are enabled in AIL -def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): - print(list_tags) - print(list_tags_galaxy) - if list_tags: - active_taxonomies = get_active_taxonomies() - - for tag in list_tags: - taxonomie = get_taxonomie_from_tag(tag) - if taxonomie not in active_taxonomies: - return False - if not is_taxonomie_tag_enabled(taxonomie, tag): - return False - - if list_tags_galaxy: - active_galaxies = get_active_galaxies() - - for tag in list_tags_galaxy: - galaxy = get_galaxy_from_tag(tag) - if galaxy not in active_galaxies: - return False - if not is_galaxy_tag_enabled(galaxy, tag): - return False - return True - -def get_item_tags(item_id): - tags = r_serv_metadata.smembers('tag:'+item_id) - if tags: - return list(tags) - else: - return '[]' diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 00000000..44613c26 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,464 @@ +# API DOCUMENTATION + +## General + +### Automation key + +The authentication of the automation is performed via a secure key available in the AIL UI interface. Make sure you keep that key secret as it gives access to the entire database! The API key is available in the ``Server Management`` menu under ``My Profile``. + +The authorization is performed by using the following header: + +~~~~ +Authorization: YOUR API KEY +~~~~ +### Accept and Content-Type headers + +When submitting data in a POST, PUT or DELETE operation you need to specify in what content-type you encoded the payload. This is done by setting the below Content-Type headers: + +~~~~ +Content-Type: application/json +~~~~ + +Example: + +~~~~ +curl --header "Authorization: YOUR API KEY" --header "Content-Type: application/json" https:/// +~~~~ + +## Item management + +### Get item: `api/get/item/info/` + +#### Description +Get a specific item information. + +**Method** : `GET` + +#### Parameters +- `id` + - item id + - *str - relative item path* + - mandatory + +#### JSON response +- `content` + - item content + - *str* +- `id` + - item id + - *str* +- `date` + - item date + - *str - YYMMDD* +- `tags` + - item tags list + - *list* + +#### Example +``` +curl https://127.0.0.1:7000/api/get/item/info/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +``` + +#### Expected Success Response +**HTTP Status Code** : `200` + +```json + { + "content": "item content test", + "date": "20190726", + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": + [ + "misp-galaxy:backdoor=\"Rosenbridge\"", + "infoleak:automatic-detection=\"pgp-message\"", + "infoleak:automatic-detection=\"encrypted-private-key\"", + "infoleak:submission=\"manual\"", + "misp-galaxy:backdoor=\"SLUB\"" + ] + } +``` + +#### Expected Fail Response + +**HTTP Status Code** : `400` + +``` + {'status': 'error', 'reason': 'Item not found'} +``` + + + + +### Get item content: `api/get/item/content/` + +#### Description +Get a specific item content. + +**Method** : `GET` + +#### Parameters +- `id` + - item id + - *str - relative item path* + - mandatory + +#### JSON response +- `content` + - item content + - *str* +- `id` + - item id + - *str* + +#### Example +``` +curl https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +``` + +#### Expected Success Response +**HTTP Status Code** : `200` + +```json + { + "content": "item content test", + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz" + } +``` + +#### Expected Fail Response + +**HTTP Status Code** : `400` + +``` + {'status': 'error', 'reason': 'Item not found'} +``` + + + +### Get item content: `api/get/item/tag/` + +#### Description +Get all tags from an item. + +**Method** : `GET` + +#### Parameters +- `id` + - item id + - *str - relative item path* + - mandatory + +#### JSON response +- `content` + - item content + - *str* +- `tags` + - item tags list + - *list* + +#### Example +``` +curl https://127.0.0.1:7000/api/get/item/tag/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +``` + +#### Expected Success Response +**HTTP Status Code** : `200` + +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": + [ + "misp-galaxy:backdoor=\"Rosenbridge\"", + "infoleak:automatic-detection=\"pgp-message\"", + "infoleak:automatic-detection=\"encrypted-private-key\"", + "infoleak:submission=\"manual\"", + "misp-galaxy:backdoor=\"SLUB\"" + ] + } +``` + +#### Expected Fail Response + +**HTTP Status Code** : `400` + +``` + {'status': 'error', 'reason': 'Item not found'} +``` + + + +### add item tags: `api/add/item/tag` + +#### Description +Add tags to an item. + +**Method** : `POST` + +#### Parameters +- `id` + - item id + - *str - relative item path* + - mandatory +- `tags` + - list of tags + - *list* + - default: `[]` +- `galaxy` + - list of galaxy + - *list* + - default: `[]` + +#### JSON response +- `id` + - item id + - *str - relative item path* +- `tags` + - list of item tags added + - *list* + +#### Example +``` +curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"" + ], + "galaxy": [ + "misp-galaxy:stealer=\"Vidar\"" + ] + } +``` + +#### Expected Success Response +**HTTP Status Code** : `200` + +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"", + "misp-galaxy:stealer=\"Vidar\"" + ] + } +``` + +#### Expected Fail Response +**HTTP Status Code** : `400` + +``` + {'status': 'error', 'reason': 'Item id not found'} + {'status': 'error', 'reason': 'Tags or Galaxy not specified'} + {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} +``` + + + + +### Delete item tags: `api/delete/item/tag` + +#### Description +Delete tags from an item. + +**Method** : `DELETE` + +#### Parameters +- `id` + - item id + - *str - relative item path* + - mandatory +- `tags` + - list of tags + - *list* + - default: `[]` + +#### JSON response +- `id` + - item id + - *str - relative item path* +- `tags` + - list of item tags deleted + - *list* + +#### Example +``` +curl https://127.0.0.1:7000/api/delete/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X DELETE +``` + +#### input.json Example +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"", + "misp-galaxy:stealer=\"Vidar\"" + ] + } +``` + +#### Expected Success Response +**HTTP Status Code** : `200` + +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"", + "misp-galaxy:stealer=\"Vidar\"" + ] + } +``` + +#### Expected Fail Response +**HTTP Status Code** : `400` + +``` + {'status': 'error', 'reason': 'Item id not found'} + {'status': 'error', 'reason': 'No Tag(s) specified'} +``` + + + + + + + +## Import management + + + +### Import item (currently: text only): `api/import/item` + +#### Description +Allows users to import new items. asynchronous function. + +**Method** : `POST` + +#### Parameters +- `type` + - import type + - *str* + - default: `text` +- `text` + - text to import + - *str* + - mandatory if type = text +- `default_tags` + - add default import tag + - *boolean* + - default: True +- `tags` + - list of tags + - *list* + - default: `[]` +- `galaxy` + - list of galaxy + - *list* + - default: `[]` + +#### JSON response +- `uuid` + - import uuid + - *uuid4* + +#### Example +``` +curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json + { + "type": "text", + "tags": [ + "infoleak:analyst-detection=\"private-key\"" + ], + "text": "text to import" + } +``` + +#### Expected Success Response +**HTTP Status Code** : `200` + +```json + { + "uuid": "0c3d7b34-936e-4f01-9cdf-2070184b6016" + } +``` + +#### Expected Fail Response +**HTTP Status Code** : `400` + +``` + {'status': 'error', 'reason': 'Malformed JSON'} + {'status': 'error', 'reason': 'No text supplied'} + {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} + {'status': 'error', 'reason': 'Size exceeds default'} +``` + + + + + +### GET Import item info: `api/import/item/` + +#### Description + +Get import status and all items imported by uuid + +**Method** : `GET` + +#### Parameters + +- `uuid` + - import uuid + - *uuid4* + - mandatory + +#### JSON response + +- `status` + - import status + - *str* + - values: `in queue`, `in progress`, `imported` +- `items` + - list of imported items id + - *list* + - The full list of imported items is not complete until `status` = `"imported"` + +#### Example + +``` +curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763b50 --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +``` + +#### Expected Success Response + +**HTTP Status Code** : `200` + +```json + { + "items": [ + "submitted/2019/07/26/b20a69f1-99ad-4cb3-b212-7ce24b763b50.gz" + ], + "status": "imported" + } +``` + +#### Expected Fail Response + +**HTTP Status Code** : `400` + +``` + {'status': 'error', 'reason': 'Invalid uuid'} + {'status': 'error', 'reason': 'Unknow uuid'} +``` diff --git a/doc/api/submit_paste.py b/doc/api/submit_paste.py deleted file mode 100755 index 3e1e2299..00000000 --- a/doc/api/submit_paste.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -# -*-coding:UTF-8 -* - -''' -submit your own pastes in AIL - -empty values must be initialized -''' - -import requests - -if __name__ == '__main__': - - #AIL url - url = 'http://localhost:7000' - - ail_url = url + '/PasteSubmit/submit' - - # MIPS TAXONOMIE, need to be initialized (tags_taxonomies = '') - tags_taxonomies = 'CERT-XLM:malicious-code=\"ransomware\",CERT-XLM:conformity=\"standard\"' - - # MISP GALAXY, need to be initialized (tags_galaxies = '') - tags_galaxies = 'misp-galaxy:cert-seu-gocsector=\"Constituency\",misp-galaxy:cert-seu-gocsector=\"EU-Centric\"' - - # user paste input, need to be initialized (paste_content = '') - paste_content = 'paste content test' - - #file full or relative path - file_to_submit = 'test_file.zip' - - #compress file password, need to be initialized (password = '') - password = '' - - ''' - submit user text - ''' - r = requests.post(ail_url, data={ 'password': password, - 'paste_content': paste_content, - 'tags_taxonomies': tags_taxonomies, - 'tags_galaxies': tags_galaxies}) - print(r.status_code, r.reason) - - - ''' - submit a file - ''' - with open(file_submit,'rb') as f: - - r = requests.post(ail_url, data={ 'password': password, - 'paste_content': paste_content, - 'tags_taxonomies': tags_taxonomies, - 'tags_galaxies': tags_galaxies}, files={'file': (file_to_submit, f.read() )}) - print(r.status_code, r.reason) diff --git a/doc/dia/ZMQ_Queuing_Tree.jpg b/doc/dia/ZMQ_Queuing_Tree.jpg deleted file mode 100644 index b4297340f61cdefc06976d1413b6a4ba4c1e4f5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 74495 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

Ih#ho{F# zf(c(=`I~_{Gr|uFYi3BJlA}5bIL^reWIQn#1fKf#@7Bx29k*e>X(OAQb*xT&aC8&@ zjb2aw5E|hM*+9wy^~ZnlGX_9^vnjA2@n2k!tx=E-Yy-^nk)ZFwY!a2~2N523STnA} zy;%zB*tkVqF09`P8jE(Q(<|5Jqz1i3LxDs0!tGn8Xs(U8I-gTAUYv~Tl;?Ax{wgbCauBvx zL^{`d#$={I%pGRyw2N#@bjkde=^n<986Kuy!J8rmIwT>6KV>NlKVJvy72RwRI%Tl# zCYR>I9@QKzE~4gB!ZPl+k`&XfQH_V@LL`VV?M_%!t^^E-r6s4qIGj}H`LWOCguWzC zQR~W>`OruCjcBw<>SS}9Ns~0#%*&&o+afdADrV5xjjoW3t(}bQdoSSLBrqz=pjkBg zBY7mH0gD}9is|uAbM}b+gk+I5Ol#Gk9SrX)raD?Kqw0i^H9|?c=CX=lvygnZQS@CR zw$>xC^R>e;TICf@gKARUa6^B1X%W4*zHHz0_)ZjKnfW7HVX znlIWRuTdun>^73}@cJO`+OgU}G4186c!0u$a)S*!xw;mtV=d!0<~-9cd5!VtsP1xy zFb4qWU&c4{fEsn(dedt&g3%tdix%bH+G&R-agvmF9Zv~{lJhByUo`Hwl${ZFa%~a| z1yQwsNoW3p_$Dl_xK8$a>W?iO^v-d|?ppZqB5EO^A-HtI;n^6Q`Dj%x18VIkMOmpk zS^9Uy!moG20uRf5gV9T0L-cb?i1cMKIwYm=*l<$Bhvo#U%L;kw+vS4c1INRSZvQEe zMGt+!IISgwW`$&7S$LD@g6eOD{Eu(f`H%iD26sFddokXdy`FtwIN4etb{DoC|jihpm2y6hSgOMhZp|pgcIUmzK@X%0N++_VlIb+st z!Rt(=jdj>m9#JSJ~0Y1&sVIi*aG%gDX3wD9uYeAb*~kglP0V`G7deQY+YKy543 zk#H@>{l?r#Y;i&L17Kz?&?u;QQOb&<6DLHjn>H~!Y)5b6Q>r)^+GNkJw33(_)Vw>Ovyw6b%)=Q{hBH-6fqi6RAm$1uH@Mf^M}F}!u;F!U z)XOAsh0-w5fkNbeYVbX;;L}SfQ&eHgs{+3VnRUcAESr_dlKNGUn*-+rxhc0D8VBNn zFe+_@Ir+7ws|6H>GjCQ%DhHM6ShFXPtv?F)ej5|ula}ADs@9DWfPK$vvtQc8BnMki z5E^2s)szw!c}iJkag_FlL$gnCmN21|{02NV06LN_oYw2vP8!n3{e?zQ&1 z^6!vEcu8770+rf&JUi5u7O8?Jnt|BXSplpZRAc@iYl!>| zKdmxvw8h=QOHvxnvZh0A8yzIW2Sbd3yDsxY_=%z;9C4LwE@)VzVq0z_jVomkPy(@K z!KRc8MC&WYb=#wTWa&f>(z>n zivcil`eY?5Y zqtX8#6|mPc-PTqJ{aJGL=3?~ns0cc{%>S6ov}7t79Me!V5|CXbpDQAf<+E(;AYk8b z82t|FT(kzfzMIQxKBthKVTwJiG!pB~l}%0y1#$YEL(c~b>mN7dyWaSsNAHf)th`Gy zp1llXkh7z<+LGdHGZ;K*-+r3acpo8ZqCq(=*7xcp`pwruHwO(K z=hUtVhyUyJN>8eCwq@y+!Hykt9?*O)HwUQv;k+HBQ0ARGy@9Dv>S$U7;bh8dtSEwD zD8ErxFM>E~VuDs$;PsoXI3bHU0StQ*8ZGjvuzBX!RO9 zZ;HAE%V5?90Yr|Ppuy(k0pzZty@{qL79wc)RPOX3Nut^^EQ`a`7Jywbn8HI&#+S8u zyZRrax%D)Ua;3R3_EjZ8l}@c+ADWRJ>pNt&-T93%movRVdS3k4Fd>#{N8

f6S#n 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 diff --git a/var/www/Flask_server.py b/var/www/Flask_server.py index ab22ffd1..5f8d52b2 100755 --- a/var/www/Flask_server.py +++ b/var/www/Flask_server.py @@ -37,6 +37,8 @@ import Flask_config from Role_Manager import create_user_db, check_password_strength, check_user_role_integrity from Role_Manager import login_admin, login_analyst +Flask_dir = os.environ['AIL_FLASK'] + # CONFIG # cfg = Flask_config.cfg baseUrl = cfg.get("Flask", "baseurl") @@ -81,7 +83,7 @@ if not os.path.isdir(log_dir): # ========= TLS =========# ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) -ssl_context.load_cert_chain(certfile='server.crt', keyfile='server.key') +ssl_context.load_cert_chain(certfile=os.path.join(Flask_dir, 'server.crt'), keyfile=os.path.join(Flask_dir, 'server.key')) #print(ssl_context.get_ciphers()) # ========= =========# @@ -112,13 +114,13 @@ try: toIgnoreModule.add(line) except IOError: - f = open('templates/ignored_modules.txt', 'w') + f = open(os.path.join(Flask_dir, 'templates', 'ignored_modules.txt'), 'w') f.close() # Dynamically import routes and functions from modules # Also, prepare header.html to_add_to_header_dico = {} -for root, dirs, files in os.walk('modules/'): +for root, dirs, files in os.walk(os.path.join(Flask_dir, 'modules')): sys.path.append(join(root)) # Ignore the module @@ -140,7 +142,7 @@ for root, dirs, files in os.walk('modules/'): #create header.html complete_header = "" -with open('templates/header_base.html', 'r') as f: +with open(os.path.join(Flask_dir, 'templates', 'header_base.html'), 'r') as f: complete_header = f.read() modified_header = complete_header @@ -159,7 +161,7 @@ for module_name, txt in to_add_to_header_dico.items(): modified_header = modified_header.replace('', '\n'.join(to_add_to_header)) #Write the header.html file -with open('templates/header.html', 'w') as f: +with open(os.path.join(Flask_dir, 'templates', 'header.html'), 'w') as f: f.write(modified_header) # ========= JINJA2 FUNCTIONS ======== diff --git a/var/www/modules/Tags/Flask_Tags.py b/var/www/modules/Tags/Flask_Tags.py index 8ab81297..d15b78a8 100644 --- a/var/www/modules/Tags/Flask_Tags.py +++ b/var/www/modules/Tags/Flask_Tags.py @@ -20,6 +20,7 @@ from pymispgalaxies import Galaxies, Clusters # ============ VARIABLES ============ import Flask_config +import Tag app = Flask_config.app cfg = Flask_config.cfg @@ -59,16 +60,6 @@ for name, tags in clusters.items(): #galaxie name + tags def one(): return 1 -def date_substract_day(date, num_day=1): - new_date = datetime.date(int(date[0:4]), int(date[4:6]), int(date[6:8])) - datetime.timedelta(num_day) - new_date = str(new_date).replace('-', '') - return new_date - -def date_add_day(date, num_day=1): - new_date = datetime.date(int(date[0:4]), int(date[4:6]), int(date[6:8])) + datetime.timedelta(num_day) - new_date = str(new_date).replace('-', '') - return new_date - def get_tags_with_synonyms(tag): str_synonyms = ' - synonyms: ' synonyms = r_serv_tags.smembers('synonym_tag_' + tag) @@ -131,93 +122,6 @@ def get_last_seen_from_tags_list(list_tags): min_last_seen = tag_last_seen return str(min_last_seen) -def add_item_tag(tag, item_path): - item_date = int(get_item_date(item_path)) - - #add tag - r_serv_metadata.sadd('tag:{}'.format(item_path), tag) - r_serv_tags.sadd('{}:{}'.format(tag, item_date), item_path) - - r_serv_tags.hincrby('daily_tags:{}'.format(item_date), tag, 1) - - tag_first_seen = r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen') - if tag_first_seen is None: - tag_first_seen = 99999999 - else: - tag_first_seen = int(tag_first_seen) - tag_last_seen = r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen') - if tag_last_seen is None: - tag_last_seen = 0 - else: - tag_last_seen = int(tag_last_seen) - - #add new tag in list of all used tags - r_serv_tags.sadd('list_tags', tag) - - # update fisrt_seen/last_seen - if item_date < tag_first_seen: - r_serv_tags.hset('tag_metadata:{}'.format(tag), 'first_seen', item_date) - - # update metadata last_seen - if item_date > tag_last_seen: - r_serv_tags.hset('tag_metadata:{}'.format(tag), 'last_seen', item_date) - -def remove_item_tag(tag, item_path): - item_date = int(get_item_date(item_path)) - - #remove tag - r_serv_metadata.srem('tag:{}'.format(item_path), tag) - res = r_serv_tags.srem('{}:{}'.format(tag, item_date), item_path) - - if res ==1: - # no tag for this day - if int(r_serv_tags.hget('daily_tags:{}'.format(item_date), tag)) == 1: - r_serv_tags.hdel('daily_tags:{}'.format(item_date), tag) - else: - r_serv_tags.hincrby('daily_tags:{}'.format(item_date), tag, -1) - - tag_first_seen = int(r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen')) - tag_last_seen = int(r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen')) - # update fisrt_seen/last_seen - if item_date == tag_first_seen: - update_tag_first_seen(tag, tag_first_seen, tag_last_seen) - if item_date == tag_last_seen: - update_tag_last_seen(tag, tag_first_seen, tag_last_seen) - else: - return 'Error incorrect tag' - -def update_tag_first_seen(tag, tag_first_seen, tag_last_seen): - if tag_first_seen == tag_last_seen: - if r_serv_tags.scard('{}:{}'.format(tag, tag_first_seen)) > 0: - r_serv_tags.hset('tag_metadata:{}'.format(tag), 'first_seen', tag_first_seen) - # no tag in db - else: - r_serv_tags.srem('list_tags', tag) - r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'first_seen') - r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'last_seen') - else: - if r_serv_tags.scard('{}:{}'.format(tag, tag_first_seen)) > 0: - r_serv_tags.hset('tag_metadata:{}'.format(tag), 'first_seen', tag_first_seen) - else: - tag_first_seen = date_add_day(tag_first_seen) - update_tag_first_seen(tag, tag_first_seen, tag_last_seen) - -def update_tag_last_seen(tag, tag_first_seen, tag_last_seen): - if tag_first_seen == tag_last_seen: - if r_serv_tags.scard('{}:{}'.format(tag, tag_last_seen)) > 0: - r_serv_tags.hset('tag_metadata:{}'.format(tag), 'last_seen', tag_last_seen) - # no tag in db - else: - r_serv_tags.srem('list_tags', tag) - r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'first_seen') - r_serv_tags.hdel('tag_metadata:{}'.format(tag), 'last_seen') - else: - if r_serv_tags.scard('{}:{}'.format(tag, tag_last_seen)) > 0: - r_serv_tags.hset('tag_metadata:{}'.format(tag), 'last_seen', tag_last_seen) - else: - tag_last_seen = date_substract_day(tag_last_seen) - update_tag_last_seen(tag, tag_first_seen, tag_last_seen) - # ============= ROUTES ============== @Tags.route("/tags/", methods=['GET']) @@ -472,8 +376,9 @@ def remove_tag(): path = request.args.get('paste') tag = request.args.get('tag') - remove_item_tag(tag, path) - + res = Tag.remove_item_tag(tag, path) + if res[1] != 200: + str(res[0]) return redirect(url_for('showsavedpastes.showsavedpaste', paste=path)) @Tags.route("/Tags/confirm_tag") @@ -486,11 +391,11 @@ def confirm_tag(): tag = request.args.get('tag') if(tag[9:28] == 'automatic-detection'): - remove_item_tag(tag, path) + Tag.remove_item_tag(tag, path) tag = tag.replace('automatic-detection','analyst-detection', 1) #add analyst tag - add_item_tag(tag, path) + Tag.add_item_tag(tag, path) return redirect(url_for('showsavedpastes.showsavedpaste', paste=path)) @@ -530,42 +435,12 @@ def addTags(): list_tag = tags.split(',') list_tag_galaxies = tagsgalaxies.split(',') - taxonomies = Taxonomies() - active_taxonomies = r_serv_tags.smembers('active_taxonomies') - - active_galaxies = r_serv_tags.smembers('active_galaxies') - - if not path: - return 'INCORRECT INPUT0' - - if list_tag != ['']: - for tag in list_tag: - # verify input - tax = tag.split(':')[0] - if tax in active_taxonomies: - if tag in r_serv_tags.smembers('active_tag_' + tax): - add_item_tag(tag, path) - - else: - return 'INCORRECT INPUT1' - else: - return 'INCORRECT INPUT2' - - if list_tag_galaxies != ['']: - for tag in list_tag_galaxies: - # verify input - gal = tag.split(':')[1] - gal = gal.split('=')[0] - - if gal in active_galaxies: - if tag in r_serv_tags.smembers('active_tag_galaxies_' + gal): - add_item_tag(tag, path) - - else: - return 'INCORRECT INPUT3' - else: - return 'INCORRECT INPUT4' - + res = Tag.add_items_tag(list_tag, list_tag_galaxies, path) + print(res) + # error + if res[1] != 200: + return str(res[0]) + # success return redirect(url_for('showsavedpastes.showsavedpaste', paste=path)) diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 2c10ad62..727cd524 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -16,7 +16,7 @@ import datetime import Import_helper import Item import Paste -import Tags +import Tag from flask import Flask, render_template, jsonify, request, Blueprint, redirect, url_for, Response from flask_login import login_required @@ -146,24 +146,49 @@ def items(): return Response(json.dumps({'test': 2}), mimetype='application/json') -# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -# GET -# -# { -# "id": item_id, mandatory -# } -# -# response: { -# "id": "item_id", -# "date": "date", -# "tags": [], -# "content": "item content" -# } -# -# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/get/item/info/", methods=['GET']) @token_required('admin') def get_item_id(item_id): + """ + **GET api/get/item/info/** + + **Get item** + + This function allows user to get a specific item information through their item_id. + + :param id: id of the item + :type id: item id + :return: item's information in json and http status code + + - Example:: + + curl -k https://127.0.0.1:7000/api/get/item/info/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" + + - Expected Success Response:: + + HTTP Status Code: 200 + + { + "content": "item content test", + "date": "20190726", + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": + [ + "misp-galaxy:backdoor=\"Rosenbridge\"", + "infoleak:automatic-detection=\"pgp-message\"", + "infoleak:automatic-detection=\"encrypted-private-key\"", + "infoleak:submission=\"manual\"", + "misp-galaxy:backdoor=\"SLUB\"" + ] + } + + - Expected Fail Response:: + + HTTP Status Code: 400 + + {'status': 'error', 'reason': 'Item not found'} + + """ try: item_object = Paste.Paste(item_id) except FileNotFoundError: @@ -188,14 +213,214 @@ def get_item_id(item_id): @restApi.route("api/get/item/tag/", methods=['GET']) @token_required('admin') def get_item_tag(item_id): + """ + **GET api/get/item/tag/** + + **Get item tags** + + This function allows user to get all items tags form a specified item id. + + :param id: id of the item + :type id: item id + :return: item's tags list in json and http status code + + - Example:: + + curl -k https://127.0.0.1:7000/api/get/item/tag/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" + + - Expected Success Response:: + + HTTP Status Code: 200 + + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": + [ + "misp-galaxy:backdoor=\"Rosenbridge\"", + "infoleak:automatic-detection=\"pgp-message\"", + "infoleak:automatic-detection=\"encrypted-private-key\"", + "infoleak:submission=\"manual\"", + "misp-galaxy:backdoor=\"SLUB\"" + ] + } + + - Expected Fail Response:: + + HTTP Status Code: 400 + + {'status': 'error', 'reason': 'Item not found'} + + """ if not Item.exist_item(item_id): return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 400 - tags = Tags.get_item_tags(item_id) + tags = Tag.get_item_tags(item_id) dict_tags = {} dict_tags['id'] = item_id dict_tags['tags'] = tags return Response(json.dumps(dict_tags, indent=2, sort_keys=True), mimetype='application/json') +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# POST +# +# { +# "id": item_id, mandatory +# "tags": [tags to add], +# "galaxy": [galaxy to add], +# } +# +# response: { +# "id": "item_id", +# "tags": [tags added], +# } +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +@restApi.route("api/add/item/tag", methods=['POST']) +@token_required('admin') +def add_item_tags(): + """ + **POST api/add/item/tag** + + **add tags to an item** + + This function allows user to add tags and galaxy to an item. + + :param id: id of the item + :type id: item id + :param tags: list of tags (default=[]) + :type tags: list + :param galaxy: list of galaxy (default=[]) + :type galaxy: list + + :return: item id and tags added in json and http status code + + - Example:: + + curl -k https://127.0.0.1:7000/api/add/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST + + - input.json Example:: + + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"" + ], + "galaxy": [ + "misp-galaxy:stealer=\"Vidar\"" + ] + } + + - Expected Success Response:: + + HTTP Status Code: 200 + + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"", + "misp-galaxy:stealer=\"Vidar\"" + ] + } + + - Expected Fail Response:: + + HTTP Status Code: 400 + + {'status': 'error', 'reason': 'Item id not found'} + {'status': 'error', 'reason': 'Tags or Galaxy not specified'} + {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} + + """ + data = request.get_json() + if not data: + return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + + item_id = data.get('id', None) + tags = data.get('tags', []) + galaxy = data.get('galaxy', []) + + res = Tag.add_items_tag(tags, galaxy, item_id) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# DELETE +# +# { +# "id": item_id, mandatory +# "tags": [tags to delete], +# } +# +# response: { +# "id": "item_id", +# "tags": [tags deleted], +# } +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +@restApi.route("api/delete/item/tag", methods=['DELETE']) +@token_required('admin') +def delete_item_tags(): + """ + **DELET E api/delete/item/tag** + + **delete tags from an item** + + This function allows user to delete tags and galaxy from an item. + + :param id: id of the item + :type id: item id + :param tags: list of tags (default=[]) + :type tags: list + + :return: item id and tags deleted in json and http status code + + - Example:: + + curl -k https://127.0.0.1:7000/api/delete/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X DELET E + + - input.json Example:: + + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"", + "misp-galaxy:stealer=\"Vidar\"" + ] + } + + - Expected Success Response:: + + HTTP Status Code: 200 + + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "tags": [ + "infoleak:analyst-detection=\"private-key\"", + "infoleak:analyst-detection=\"api-key\"", + "misp-galaxy:stealer=\"Vidar\"" + ] + } + + - Expected Fail Response:: + + HTTP Status Code: 400 + + {'status': 'error', 'reason': 'Item id not found'} + {'status': 'error', 'reason': 'No Tag(s) specified} + {'status': 'error', 'reason': 'Malformed JSON'} + + """ + data = request.get_json() + if not data: + return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + + item_id = data.get('id', None) + tags = data.get('tags', []) + + res = Tag.remove_item_tags(tags, item_id) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # GET # @@ -212,6 +437,37 @@ def get_item_tag(item_id): @restApi.route("api/get/item/content/", methods=['GET']) @token_required('admin') def get_item_content(item_id): + """ + **GET api/get/item/content/** + + **Get item content** + + This function allows user to get a specific item content. + + :param id: id of the item + :type id: item id + :return: item's content in json and http status code + + - Example:: + + curl -k https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" + + - Expected Success Response:: + + HTTP Status Code: 200 + + { + "content": "item content test", + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz" + } + + - Expected Fail Response:: + + HTTP Status Code: 400 + + {'status': 'error', 'reason': 'Item not found'} + + """ try: item_object = Paste.Paste(item_id) except FileNotFoundError: @@ -240,6 +496,58 @@ def get_item_content(item_id): @restApi.route("api/import/item", methods=['POST']) @token_required('admin') def import_item(): + """ + **POST api/import/item** + + **Import new item** + + This function allows user to import new items. asynchronous function. + + :param text: text to import + :type text: str + :param type: import type (default='text') + :type type: "text" + :param tags: list of tags (default=[]) + :type tags: list + :param galaxy: list of galaxy (default=[]) + :type galaxy: list + :param default_tags: add default tag (default=True) + :type default_tags: boolean + + :return: imported uuid in json and http status code + + - Example:: + + curl -k https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST + + - input.json Example:: + + { + "type": "text", + "tags": [ + "infoleak:analyst-detection=\"private-key\"" + ], + "text": "text to import" + } + + - Expected Success Response:: + + HTTP Status Code: 200 + + { + "uuid": "0c3d7b34-936e-4f01-9cdf-2070184b6016" + } + + - Expected Fail Response:: + + HTTP Status Code: 400 + + {'status': 'error', 'reason': 'Malformed JSON'} + {'status': 'error', 'reason': 'No text supplied'} + {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} + {'status': 'error', 'reason': 'Size exceeds default'} + + """ data = request.get_json() if not data: return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 @@ -256,7 +564,7 @@ def import_item(): if not type(galaxy) is list: galaxy = [] - if not Tags.is_valid_tags_taxonomies_galaxy(tags, galaxy): + if not Tag.is_valid_tags_taxonomies_galaxy(tags, galaxy): return Response(json.dumps({'status': 'error', 'reason': 'Tags or Galaxy not enabled'}, indent=2, sort_keys=True), mimetype='application/json'), 400 default_tags = data.get('default_tags', True) @@ -287,6 +595,41 @@ def import_item(): @restApi.route("api/import/item/", methods=['GET']) @token_required('admin') def import_item_uuid(UUID): + """ + **GET api/import/item/** + + **Get import status and all items imported by uuid** + + This return the import status and a list of imported items. + The full list of imported items is not complete until 'status'='imported'. + + :param uuid: import uuid + :type uuid: uuid4 + :return: json: import status + imported items list + + - Example:: + + curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763b50 --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" + + - Expected Success Response:: + + HTTP Status Code: 200 + + { + "items": [ + "submitted/2019/07/26/b20a69f1-99ad-4cb3-b212-7ce24b763b50.gz" + ], + "status": "in queue"/"in progress"/"imported" + } + + - Expected Fail Response:: + + HTTP Status Code: 400 + + {'status': 'error', 'reason': 'Invalid uuid'} + {'status': 'error', 'reason': 'Unknow uuid'} + + """ # Verify uuid if not is_valid_uuid_v4(UUID): From 0e6f337e15e9a5d7fec6b14f33e7dbc24691fd36 Mon Sep 17 00:00:00 2001 From: Sascha Rommelfangen Date: Tue, 30 Jul 2019 14:15:01 +0200 Subject: [PATCH 06/25] consistency and wording --- doc/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/README.md b/doc/README.md index 44613c26..b1477dc6 100644 --- a/doc/README.md +++ b/doc/README.md @@ -4,12 +4,12 @@ ### Automation key -The authentication of the automation is performed via a secure key available in the AIL UI interface. Make sure you keep that key secret as it gives access to the entire database! The API key is available in the ``Server Management`` menu under ``My Profile``. +The authentication of the automation is performed via a secure key available in the AIL UI interface. Make sure you keep that key secret. It gives access to the entire database! The API key is available in the ``Server Management`` menu under ``My Profile``. The authorization is performed by using the following header: ~~~~ -Authorization: YOUR API KEY +Authorization: YOUR_API_KEY ~~~~ ### Accept and Content-Type headers @@ -22,7 +22,7 @@ Content-Type: application/json Example: ~~~~ -curl --header "Authorization: YOUR API KEY" --header "Content-Type: application/json" https:/// +curl --header "Authorization: YOUR_API_KEY" --header "Content-Type: application/json" https://AIL_URL/ ~~~~ ## Item management From ced2e3bfe98b720ed33fe7a8b899026e6a422b87 Mon Sep 17 00:00:00 2001 From: Sascha Rommelfangen Date: Tue, 30 Jul 2019 14:23:37 +0200 Subject: [PATCH 07/25] so many spelling mistakes have been corrected in this commit. It's totally worth writing so much explanation... --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index b1477dc6..bc1eb7c1 100644 --- a/doc/README.md +++ b/doc/README.md @@ -460,5 +460,5 @@ curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763 ``` {'status': 'error', 'reason': 'Invalid uuid'} - {'status': 'error', 'reason': 'Unknow uuid'} + {'status': 'error', 'reason': 'Unknown uuid'} ``` From 5f5e86bb1314a7299910f64395e982f6bcce1685 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Wed, 31 Jul 2019 11:15:34 +0200 Subject: [PATCH 08/25] fix: [Tags + api] fix dict keys name + fix documentation errors output --- bin/packages/Tag.py | 4 ++-- doc/README.md | 42 +++++++++++++++++++++--------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/bin/packages/Tag.py b/bin/packages/Tag.py index eb5a5bb3..5d717bb7 100755 --- a/bin/packages/Tag.py +++ b/bin/packages/Tag.py @@ -141,14 +141,14 @@ def remove_item_tags(tags=[], item_id=None): return ({'status': 'error', 'reason': 'No Tag(s) specified'}, 400) dict_res = {} - dict_res[tags] = [] + dict_res['tags'] = [] for tag in tags: res = remove_item_tag(tag, item_id) if res[1] != 200: return res else: dict_res[tags].append(tag) - dict_res[id] = item_id + dict_res['id'] = item_id return (dict_res, 200) # TEMPLATE + API QUERY diff --git a/doc/README.md b/doc/README.md index 44613c26..a47f4923 100644 --- a/doc/README.md +++ b/doc/README.md @@ -82,8 +82,8 @@ curl https://127.0.0.1:7000/api/get/item/info/submitted/2019/07/26/3efb8a79-08e9 **HTTP Status Code** : `400` -``` - {'status': 'error', 'reason': 'Item not found'} +```json + {"status": "error", "reason": "Item not found"} ``` @@ -129,8 +129,8 @@ curl https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-0 **HTTP Status Code** : `400` -``` - {'status': 'error', 'reason': 'Item not found'} +```json + {"status": "error", "reason": "Item not found"} ``` @@ -182,8 +182,8 @@ curl https://127.0.0.1:7000/api/get/item/tag/submitted/2019/07/26/3efb8a79-08e9- **HTTP Status Code** : `400` -``` - {'status': 'error', 'reason': 'Item not found'} +```json + {"status": "error", "reason": "Item not found"} ``` @@ -253,10 +253,10 @@ curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1 #### Expected Fail Response **HTTP Status Code** : `400` -``` - {'status': 'error', 'reason': 'Item id not found'} - {'status': 'error', 'reason': 'Tags or Galaxy not specified'} - {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} +```json + {"status": "error", "reason": "Item id not found"} + {"status": "error", "reason": "Tags or Galaxy not specified"} + {"status": "error", "reason": "Tags or Galaxy not enabled"} ``` @@ -321,9 +321,9 @@ curl https://127.0.0.1:7000/api/delete/item/tag --header "Authorization: iHc1_Ch #### Expected Fail Response **HTTP Status Code** : `400` -``` - {'status': 'error', 'reason': 'Item id not found'} - {'status': 'error', 'reason': 'No Tag(s) specified'} +```json + {"status": "error", "reason": "Item id not found"} + {"status": "error", "reason": "No Tag(s) specified"} ``` @@ -398,11 +398,11 @@ curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1 #### Expected Fail Response **HTTP Status Code** : `400` -``` - {'status': 'error', 'reason': 'Malformed JSON'} - {'status': 'error', 'reason': 'No text supplied'} - {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} - {'status': 'error', 'reason': 'Size exceeds default'} +```json + {"status": "error", "reason": "Malformed JSON"} + {"status": "error", "reason": "No text supplied"} + {"status": "error", "reason": "Tags or Galaxy not enabled"} + {"status": "error", "reason": "Size exceeds default"} ``` @@ -458,7 +458,7 @@ curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763 **HTTP Status Code** : `400` -``` - {'status': 'error', 'reason': 'Invalid uuid'} - {'status': 'error', 'reason': 'Unknow uuid'} +```json + {"status": "error", "reason": "Invalid uuid"} + {"status": "error", "reason": "Unknow uuid"} ``` From 918b4c28ed34b17b32006e5950fe7f34fc40d103 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Wed, 31 Jul 2019 13:24:43 +0200 Subject: [PATCH 09/25] fix: [api] fix errors handler 404 405, return json --- bin/packages/Import_helper.py | 2 +- var/www/Flask_server.py | 17 ++++++++++++++++- var/www/modules/restApi/Flask_restApi.py | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/bin/packages/Import_helper.py b/bin/packages/Import_helper.py index 3ce4406f..0b7fdfb8 100755 --- a/bin/packages/Import_helper.py +++ b/bin/packages/Import_helper.py @@ -49,7 +49,7 @@ def check_import_status(UUID): processing = r_serv_log_submit.get(UUID + ':processing') if not processing: - return ({'status': 'error', 'reason': 'Unknow uuid'}, 400) + return ({'status': 'error', 'reason': 'Unknown uuid'}, 400) # nb_total = r_serv_log_submit.get(UUID + ':nb_total') # nb_sucess = r_serv_log_submit.get(UUID + ':nb_sucess') diff --git a/var/www/Flask_server.py b/var/www/Flask_server.py index 5f8d52b2..c4d3c3c9 100755 --- a/var/www/Flask_server.py +++ b/var/www/Flask_server.py @@ -5,6 +5,7 @@ import os import re import sys import ssl +import json import time import redis @@ -13,7 +14,7 @@ import logging import logging.handlers import configparser -from flask import Flask, render_template, jsonify, request, Request, session, redirect, url_for +from flask import Flask, render_template, jsonify, request, Request, Response, session, redirect, url_for from flask_login import LoginManager, current_user, login_user, logout_user, login_required import bcrypt @@ -291,7 +292,21 @@ def searchbox(): # ========== ERROR HANDLER ============ +@app.errorhandler(405) +def _handle_client_error(e): + if request.path.startswith('/api/'): + return Response(json.dumps({"status": "error", "reason": "Method Not Allowed: The method is not allowed for the requested URL"}, indent=2, sort_keys=True), mimetype='application/json'), 405 + else: + return e + @app.errorhandler(404) +def error_page_not_found(e): + if request.path.startswith('/api/'): + return Response(json.dumps({"status": "error", "reason": "404 Not Found"}, indent=2, sort_keys=True), mimetype='application/json'), 404 + else: + # avoid endpoint enumeration + return page_not_found(e) + @login_required def page_not_found(e): # avoid endpoint enumeration diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 727cd524..673b83c6 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -627,7 +627,7 @@ def import_item_uuid(UUID): HTTP Status Code: 400 {'status': 'error', 'reason': 'Invalid uuid'} - {'status': 'error', 'reason': 'Unknow uuid'} + {'status': 'error', 'reason': 'Unknown uuid'} """ From 8c02c1b00b0730d660d4c110d4dfe57ecc799521 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Thu, 1 Aug 2019 09:45:59 +0200 Subject: [PATCH 10/25] chg: [api] add 404 errors code + add future endpoints in doc --- bin/packages/Import_helper.py | 2 +- bin/packages/Tag.py | 4 +- doc/README.md | 91 +++++++++++++++++++++++- var/www/modules/restApi/Flask_restApi.py | 17 ++--- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/bin/packages/Import_helper.py b/bin/packages/Import_helper.py index 0b7fdfb8..c95c101b 100755 --- a/bin/packages/Import_helper.py +++ b/bin/packages/Import_helper.py @@ -49,7 +49,7 @@ def check_import_status(UUID): processing = r_serv_log_submit.get(UUID + ':processing') if not processing: - return ({'status': 'error', 'reason': 'Unknown uuid'}, 400) + return ({'status': 'error', 'reason': 'Unknown uuid'}, 404) # nb_total = r_serv_log_submit.get(UUID + ':nb_total') # nb_sucess = r_serv_log_submit.get(UUID + ':nb_sucess') diff --git a/bin/packages/Tag.py b/bin/packages/Tag.py index 5d717bb7..3665451f 100755 --- a/bin/packages/Tag.py +++ b/bin/packages/Tag.py @@ -76,7 +76,7 @@ def get_item_tags(item_id): def add_items_tag(tags=[], galaxy_tags=[], item_id=None): res_dict = {} if item_id == None: - return ({'status': 'error', 'reason': 'Item id not found'}, 400) + return ({'status': 'error', 'reason': 'Item id not found'}, 404) if not tags and not galaxy_tags: return ({'status': 'error', 'reason': 'Tags or Galaxy not specified'}, 400) @@ -136,7 +136,7 @@ def add_item_tag(tag, item_path): # API QUERY def remove_item_tags(tags=[], item_id=None): if item_id == None: - return ({'status': 'error', 'reason': 'Item id not found'}, 400) + return ({'status': 'error', 'reason': 'Item id not found'}, 404) if not tags: return ({'status': 'error', 'reason': 'No Tag(s) specified'}, 400) diff --git a/doc/README.md b/doc/README.md index 8979780a..0a819ac6 100644 --- a/doc/README.md +++ b/doc/README.md @@ -27,10 +27,10 @@ curl --header "Authorization: YOUR_API_KEY" --header "Content-Type: application/ ## Item management -### Get item: `api/get/item/info/` +### Get item: `api/get/item/basic/` #### Description -Get a specific item information. +Get anitem basic information. **Method** : `GET` @@ -462,3 +462,90 @@ curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763 {"status": "error", "reason": "Invalid uuid"} {"status": "error", "reason": "Unknown uuid"} ``` + + + + + + + + +# FUTURE endpoints + +### Text search by daterange +##### ``api/search/textIndexer/item`` POST + +### Get all tags list +##### ``api/get/tag/all`` + +### Get tagged items by daterange +##### ``api/search/tag/item`` POST + +### Submit a domain to crawl +##### ``api/add/crawler/domain`` POST + +### Create a term/set/regex tracker +##### ``api/add/termTracker/`` POST + +### Get tracker items list +##### ``api/get/termTracker/item`` POST + +----- + +### Check if a tor/regular domain have been crawled +##### ``api/get/crawler/domain/`` POST + +### Check if a tor/regular domain have been crawled +##### ``api/get/crawler/domain/metadata/ `` GET + +### Get domain tags +##### ``api/get/crawler/domain/tag/ `` GET + +### Get domain history +##### ``api/get/crawler/domain/history/ `` GET + +### Get domain list of items +##### ``api/get/crawler/domain/item/ `` GET + +----- + +### Create auto-crawlers +##### ``api/add/crawler/autoCrawler/`` POST + +----- + +### get item by mime type/ decoded type +##### ``api/get/decoded`` POST + +### Check if a decoded item exists (via sha1) +##### ``api/get/decoded/exist/`` GET + +### Get decoded item metadata +### Check if a decoded item exists (via sha1) +##### ``api/get/decoded/metadata/`` GET + +### Get decoded item correlation (1 depth) +##### ``api/get/decoded/metadata/`` GET + +----- + + +----- +##### ``api/get/cryptocurrency`` POST + +### Check if a cryptocurrency address (bitcoin, ..) exists +##### ``api/get/cryptocurrency/exist/`` GET + +### Get cryptocurrency address metadata +##### ``api/get/cryptocurrency/metadata/`` GET + +----- + +### Item correlation (1 depth) +##### ``api/get/item/correlation/`` POST + +### Create MISP event from item +##### ``api/export/item/misp`` POST + +### Create TheHive case from item +##### ``api/export/item/thehive`` POST diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 673b83c6..0bb842aa 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -139,14 +139,7 @@ def one(): # def api(): # return 'api doc' -@restApi.route("api/items", methods=['GET', 'POST']) -@token_required('admin') -def items(): - item = request.args.get('id') - - return Response(json.dumps({'test': 2}), mimetype='application/json') - -@restApi.route("api/get/item/info/", methods=['GET']) +@restApi.route("api/get/item/basic/", methods=['GET']) @token_required('admin') def get_item_id(item_id): """ @@ -192,7 +185,7 @@ def get_item_id(item_id): try: item_object = Paste.Paste(item_id) except FileNotFoundError: - return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 data = item_object.get_item_dict() return Response(json.dumps(data, indent=2, sort_keys=True), mimetype='application/json') @@ -252,7 +245,7 @@ def get_item_tag(item_id): """ if not Item.exist_item(item_id): - return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 tags = Tag.get_item_tags(item_id) dict_tags = {} dict_tags['id'] = item_id @@ -471,7 +464,7 @@ def get_item_content(item_id): try: item_object = Paste.Paste(item_id) except FileNotFoundError: - return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 item_object = Paste.Paste(item_id) dict_content = {} dict_content['id'] = item_id @@ -572,7 +565,7 @@ def import_item(): tags.append('infoleak:submission="manual"') if sys.getsizeof(text_to_import) > 900000: - return Response(json.dumps({'status': 'error', 'reason': 'Size exceeds default'}, indent=2, sort_keys=True), mimetype='application/json'), 400 + return Response(json.dumps({'status': 'error', 'reason': 'Size exceeds default'}, indent=2, sort_keys=True), mimetype='application/json'), 413 UUID = str(uuid.uuid4()) Import_helper.create_import_queue(tags, galaxy, text_to_import, UUID) From 4c20f58a522e380a2afaa329fa40793e32993f49 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Thu, 1 Aug 2019 13:16:57 +0200 Subject: [PATCH 11/25] chg: [api] add advanced get item via POST + use same query for each get item --- bin/packages/Item.py | 74 ++++++++++ doc/README.md | 130 +++++++++++++++++- .../modules/PasteSubmit/Flask_PasteSubmit.py | 10 +- var/www/modules/restApi/Flask_restApi.py | 66 +++++---- 4 files changed, 243 insertions(+), 37 deletions(-) diff --git a/bin/packages/Item.py b/bin/packages/Item.py index a2276fbd..6b24bb18 100755 --- a/bin/packages/Item.py +++ b/bin/packages/Item.py @@ -2,12 +2,15 @@ # -*-coding:UTF-8 -* import os +import gzip import redis import Flask_config import Date +import Tag PASTES_FOLDER = Flask_config.PASTES_FOLDER +r_cache = Flask_config.r_cache def exist_item(item_id): if os.path.isfile(os.path.join(PASTES_FOLDER, item_id)): @@ -18,3 +21,74 @@ def exist_item(item_id): def get_item_date(item_id): l_directory = item_id.split('/') return '{}{}{}'.format(l_directory[-4], l_directory[-3], l_directory[-2]) + +def get_item_size(item_id): + return round(os.path.getsize(os.path.join(PASTES_FOLDER, item_id))/1024.0, 2) + +def get_lines_info(item_id, item_content=None): + if not item_content: + item_content = get_item_content(item_id) + max_length = 0 + line_id = 0 + nb_line = 0 + for line in item_content.splitlines(): + length = len(line) + if length > max_length: + max_length = length + nb_line += 1 + return {'nb': nb_line, 'max_length': max_length} + + +def get_item_content(item_id): + item_full_path = os.path.join(PASTES_FOLDER, item_id) + try: + item_content = r_cache.get(item_full_path) + except UnicodeDecodeError: + item_content = None + except Exception as e: + print("ERROR in: " + item_id) + print(e) + item_content = None + if item_content is None: + try: + with gzip.open(item_full_path, 'r') as f: + item_content = f.read() + r_cache.set(item_full_path, item_content) + r_cache.expire(item_full_path, 300) + except: + item_content = '' + return str(item_content) + +# API +def get_item(request_dict): + if not request_dict: + return Response({'status': 'error', 'reason': 'Malformed JSON'}, 400) + + item_id = request_dict.get('id', None) + if not item_id: + return ( {'status': 'error', 'reason': 'Mandatory parameter(s) not provided'}, 400 ) + if not exist_item(item_id): + return ( {'status': 'error', 'reason': 'Item not found'}, 404 ) + + dict_item = {} + dict_item['id'] = item_id + date = request_dict.get('date', True) + if date: + dict_item['date'] = get_item_date(item_id) + tags = request_dict.get('tags', True) + if tags: + dict_item['tags'] = Tag.get_item_tags(item_id) + + size = request_dict.get('size', False) + if size: + dict_item['size'] = get_item_size(item_id) + + content = request_dict.get('content', False) + if content: + dict_item['content'] = get_item_content(item_id) + + lines_info = request_dict.get('lines', False) + if lines_info: + dict_item['lines'] = get_lines_info(item_id, dict_item.get('content', 'None')) + + return (dict_item, 200) diff --git a/doc/README.md b/doc/README.md index 0a819ac6..af651872 100644 --- a/doc/README.md +++ b/doc/README.md @@ -27,10 +27,10 @@ curl --header "Authorization: YOUR_API_KEY" --header "Content-Type: application/ ## Item management -### Get item: `api/get/item/basic/` +### Get item: `api/get/item/default/` #### Description -Get anitem basic information. +Get item default info. **Method** : `GET` @@ -56,7 +56,7 @@ Get anitem basic information. #### Example ``` -curl https://127.0.0.1:7000/api/get/item/info/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/get/item/default/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" ``` #### Expected Success Response @@ -81,7 +81,10 @@ curl https://127.0.0.1:7000/api/get/item/info/submitted/2019/07/26/3efb8a79-08e9 #### Expected Fail Response **HTTP Status Code** : `400` - +```json + {"status": "error", "reason": "Mandatory parameter(s) not provided"} +``` +**HTTP Status Code** : `404` ```json {"status": "error", "reason": "Item not found"} ``` @@ -128,7 +131,10 @@ curl https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-0 #### Expected Fail Response **HTTP Status Code** : `400` - +```json + {"status": "error", "reason": "Mandatory parameter(s) not provided"} +``` +**HTTP Status Code** : `404` ```json {"status": "error", "reason": "Item not found"} ``` @@ -181,13 +187,125 @@ curl https://127.0.0.1:7000/api/get/item/tag/submitted/2019/07/26/3efb8a79-08e9- #### Expected Fail Response **HTTP Status Code** : `400` - +```json + {"status": "error", "reason": "Mandatory parameter(s) not provided"} +``` +**HTTP Status Code** : `404` ```json {"status": "error", "reason": "Item not found"} ``` +### Advanced Get item: `api/get/item` + +#### Description +Get item. Filter requested field. + +**Method** : `POST` + +#### Parameters +- `id` + - item id + - *str - relative item path* + - mandatory +- `date` + - get item date + - *boolean* + - default: `true` +- `tags` + - get item tags + - *boolean* + - default: `true` +- `content` + - get item content + - *boolean* + - default: `false` +- `size` + - get item size + - *boolean* + - default: `false` +- `lines` + - get item lines info + - *boolean* + - default: `false` + +#### JSON response +- `content` + - item content + - *str* +- `id` + - item id + - *str* +- `date` + - item date + - *str - YYMMDD* +- `tags` + - item tags list + - *list* +- `size` + - item size (Kb) + - *int* +- `lines` + - item lines info + - *{}* + - `max_length` + - line max length line + - *int* + - `nb` + - nb lines item + - *int* + + +#### Example +``` +curl https://127.0.0.1:7000/api/get/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json +{ + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "content": true, + "lines_info": true, + "tags": true, + "size": true +} +``` + +#### Expected Success Response +**HTTP Status Code** : `200` +```json + { + "content": "b'dsvcdsvcdsc vvvv'", + "date": "20190726", + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", + "lines": { + "max_length": 19, + "nb": 1 + }, + "size": 0.03, + "tags": [ + "misp-galaxy:stealer=\"Vidar\"", + "infoleak:submission=\"manual\"" + ] + } +``` + +#### Expected Fail Response +**HTTP Status Code** : `400` +```json + {"status": "error", "reason": "Mandatory parameter(s) not provided"} +``` +**HTTP Status Code** : `404` +```json + {"status": "error", "reason": "Item not found"} +``` + + + + + ### add item tags: `api/add/item/tag` #### Description diff --git a/var/www/modules/PasteSubmit/Flask_PasteSubmit.py b/var/www/modules/PasteSubmit/Flask_PasteSubmit.py index 71d16de2..76bae898 100644 --- a/var/www/modules/PasteSubmit/Flask_PasteSubmit.py +++ b/var/www/modules/PasteSubmit/Flask_PasteSubmit.py @@ -24,7 +24,7 @@ import json import Paste import Import_helper -import Tags +import Tag from pytaxonomies import Taxonomies from pymispgalaxies import Galaxies, Clusters @@ -224,8 +224,8 @@ def hive_create_case(hive_tlp, threat_level, hive_description, hive_case_title, @login_analyst def PasteSubmit_page(): # Get all active tags/galaxy - active_taxonomies = Tags.get_active_taxonomies() - active_galaxies = Tags.get_active_galaxies() + active_taxonomies = Tag.get_active_taxonomies() + active_galaxies = Tag.get_active_galaxies() return render_template("submit_items.html", active_taxonomies = active_taxonomies, @@ -253,9 +253,9 @@ def submit(): submitted_tag = 'infoleak:submission="manual"' #active taxonomies - active_taxonomies = Tags.get_active_taxonomies() + active_taxonomies = Tag.get_active_taxonomies() #active galaxies - active_galaxies = Tags.get_active_galaxies() + active_galaxies = Tag.get_active_galaxies() if ltags or ltagsgalaxies: diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 0bb842aa..719d307d 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -139,11 +139,38 @@ def one(): # def api(): # return 'api doc' -@restApi.route("api/get/item/basic/", methods=['GET']) +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# POST +# +# { +# "id": item_id, mandatory +# "content": true, +# "tags": true, +# +# +# } +# +# response: { +# "id": "item_id", +# "tags": [], +# } +# +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +@restApi.route("api/get/item", methods=['GET', 'POST']) @token_required('admin') -def get_item_id(item_id): +def get_item_id(): + if request.method == 'POST': + data = request.get_json() + res = Item.get_item(data) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] + else: + return 'description API endpoint' + +@restApi.route("api/get/item/default/", methods=['GET']) +@token_required('admin') +def get_item_id_basic(item_id): """ - **GET api/get/item/info/** + **POST api/get/item/default/** **Get item** @@ -155,7 +182,7 @@ def get_item_id(item_id): - Example:: - curl -k https://127.0.0.1:7000/api/get/item/info/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" + curl -k https://127.0.0.1:7000/api/get/item/default --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json --data @input.json -X POST" - Expected Success Response:: @@ -182,13 +209,10 @@ def get_item_id(item_id): {'status': 'error', 'reason': 'Item not found'} """ - try: - item_object = Paste.Paste(item_id) - except FileNotFoundError: - return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 - data = item_object.get_item_dict() - return Response(json.dumps(data, indent=2, sort_keys=True), mimetype='application/json') + data = {'id': item_id, 'date': True, 'content': True, 'tags': True} + res = Item.get_item(data) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # GET @@ -244,13 +268,9 @@ def get_item_tag(item_id): {'status': 'error', 'reason': 'Item not found'} """ - if not Item.exist_item(item_id): - return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 - tags = Tag.get_item_tags(item_id) - dict_tags = {} - dict_tags['id'] = item_id - dict_tags['tags'] = tags - return Response(json.dumps(dict_tags, indent=2, sort_keys=True), mimetype='application/json') + data = {'id': item_id, 'date': False, 'tags': True} + res = Item.get_item(data) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # POST @@ -461,15 +481,9 @@ def get_item_content(item_id): {'status': 'error', 'reason': 'Item not found'} """ - try: - item_object = Paste.Paste(item_id) - except FileNotFoundError: - return Response(json.dumps({'status': 'error', 'reason': 'Item not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 - item_object = Paste.Paste(item_id) - dict_content = {} - dict_content['id'] = item_id - dict_content['content'] = item_object.get_p_content() - return Response(json.dumps(dict_content, indent=2, sort_keys=True), mimetype='application/json') + data = {'id': item_id, 'date': False, 'content': True, 'tags': False} + res = Item.get_item(data) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # From 98fb6ecef75a412f90e6f911589014913cbec5f9 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Thu, 1 Aug 2019 13:43:28 +0200 Subject: [PATCH 12/25] fix: [api doc] typo --- bin/packages/Item.py | 1 + doc/README.md | 2 +- var/www/modules/restApi/Flask_restApi.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/packages/Item.py b/bin/packages/Item.py index 6b24bb18..9b1b4b9d 100755 --- a/bin/packages/Item.py +++ b/bin/packages/Item.py @@ -85,6 +85,7 @@ def get_item(request_dict): content = request_dict.get('content', False) if content: + # UTF-8 outpout, # TODO: use base64 dict_item['content'] = get_item_content(item_id) lines_info = request_dict.get('lines', False) diff --git a/doc/README.md b/doc/README.md index af651872..07aa8e22 100644 --- a/doc/README.md +++ b/doc/README.md @@ -277,7 +277,7 @@ curl https://127.0.0.1:7000/api/get/item --header "Authorization: iHc1_ChZxj1aXm **HTTP Status Code** : `200` ```json { - "content": "b'dsvcdsvcdsc vvvv'", + "content": "dsvcdsvcdsc vvvv", "date": "20190726", "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", "lines": { diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 719d307d..3f363555 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -182,7 +182,7 @@ def get_item_id_basic(item_id): - Example:: - curl -k https://127.0.0.1:7000/api/get/item/default --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json --data @input.json -X POST" + curl -k https://127.0.0.1:7000/api/get/item/default/ --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json --data @input.json -X POST" - Expected Success Response:: From 5e1ae8a89367b78cab76353df19907d2d19a6a5b Mon Sep 17 00:00:00 2001 From: Terrtia Date: Thu, 1 Aug 2019 14:36:52 +0200 Subject: [PATCH 13/25] chg: [api] add new endpoints: get tag metadata + get all tags --- bin/packages/Tag.py | 14 +++ doc/README.md | 108 ++++++++++++++++++++++- var/www/modules/restApi/Flask_restApi.py | 25 ++++++ 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/bin/packages/Tag.py b/bin/packages/Tag.py index 3665451f..37a43423 100755 --- a/bin/packages/Tag.py +++ b/bin/packages/Tag.py @@ -65,6 +65,20 @@ def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): return False return True +def get_tag_metadata(tag): + first_seen = r_serv_tags.hget('tag_metadata:{}'.format(tag), 'first_seen') + last_seen = r_serv_tags.hget('tag_metadata:{}'.format(tag), 'last_seen') + return {'tag': tag, 'first_seen': first_seen, 'last_seen': last_seen} + +def is_tag_in_all_tag(tag): + if r_serv_tags.sismember('list_tags', tag): + return True + else: + return False + +def get_all_tags(): + return list(r_serv_tags.smembers('list_tags')) + def get_item_tags(item_id): tags = r_serv_metadata.smembers('tag:'+item_id) if tags: diff --git a/doc/README.md b/doc/README.md index 07aa8e22..3c047e6e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -449,6 +449,111 @@ curl https://127.0.0.1:7000/api/delete/item/tag --header "Authorization: iHc1_Ch +## Tag management + + +### Get all AIL tags: `api/get/tag/all` + +#### Description +Get all tags used in AIL. + +**Method** : `GET` + +#### JSON response +- `tags` + - list of tag + - *list* +#### Example +``` +curl https://127.0.0.1:7000/api/get/tag/all --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +``` + +#### Expected Success Response +**HTTP Status Code** : `200` +```json + { + "tags": [ + "misp-galaxy:backdoor=\"Rosenbridge\"", + "infoleak:automatic-detection=\"pgp-private-key\"", + "infoleak:automatic-detection=\"pgp-signature\"", + "infoleak:automatic-detection=\"base64\"", + "infoleak:automatic-detection=\"encrypted-private-key\"", + "infoleak:submission=\"crawler\"", + "infoleak:automatic-detection=\"binary\"", + "infoleak:automatic-detection=\"pgp-public-key-block\"", + "infoleak:automatic-detection=\"hexadecimal\"", + "infoleak:analyst-detection=\"private-key\"", + "infoleak:submission=\"manual\"", + "infoleak:automatic-detection=\"private-ssh-key\"", + "infoleak:automatic-detection=\"iban\"", + "infoleak:automatic-detection=\"pgp-message\"", + "infoleak:automatic-detection=\"certificate\"", + "infoleak:automatic-detection=\"credential\"", + "infoleak:automatic-detection=\"cve\"", + "infoleak:automatic-detection=\"google-api-key\"", + "infoleak:automatic-detection=\"phone-number\"", + "infoleak:automatic-detection=\"rsa-private-key\"", + "misp-galaxy:backdoor=\"SLUB\"", + "infoleak:automatic-detection=\"credit-card\"", + "misp-galaxy:stealer=\"Vidar\"", + "infoleak:automatic-detection=\"private-key\"", + "infoleak:automatic-detection=\"api-key\"", + "infoleak:automatic-detection=\"mail\"" + ] + } +``` + + + + +### Get tag metadata: `api/get/tag/metadata/` + +#### Description +Get tag metadata. + +**Method** : `GET` + +#### Parameters +- `tag` + - tag name + - *str* + - mandatory + +#### JSON response +- `tag` + - tag name + - *str* +- `first_seen` + - date: first seen + - *str - YYMMDD* +- `last_seen` + - date: first seen + - *str - YYMMDD* +#### Example +``` +curl https://127.0.0.1:7000/api/get/tag/metadata/infoleak:submission=\"manual\" --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +``` + +#### Expected Success Response +**HTTP Status Code** : `200` +```json + { + "first_seen": "20190605", + "last_seen": "20190726", + "tag": "infoleak:submission=\"manual\"" + } +``` + +#### Expected Fail Response +**HTTP Status Code** : `404` +```json + {"status": "error", "reason": "Tag not found"} +``` + + + + + ## Import management @@ -593,9 +698,6 @@ curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763 ### Text search by daterange ##### ``api/search/textIndexer/item`` POST -### Get all tags list -##### ``api/get/tag/all`` - ### Get tagged items by daterange ##### ``api/search/tag/item`` POST diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 3f363555..f2c6f64a 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -485,6 +485,31 @@ def get_item_content(item_id): res = Item.get_item(data) return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# # # # # # # # # # # # # # TAGS # # # # # # # # # # # # # # # # # +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +@restApi.route("api/get/tag/metadata/", methods=['GET']) +@token_required('admin') +def get_tag_metadata(tag): + if not Tag.is_tag_in_all_tag(tag): + return Response(json.dumps({'status': 'error', 'reason':'Tag not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 + metadata = Tag.get_tag_metadata(tag) + return Response(json.dumps(metadata, indent=2, sort_keys=True), mimetype='application/json'), 200 + +@restApi.route("api/get/tag/all", methods=['GET']) +@token_required('admin') +def get_all_tags(): + res = {'tags': Tag.get_all_tags()} + return Response(json.dumps(res, indent=2, sort_keys=True), mimetype='application/json'), 200 + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# # # # # # # # # # # # # IMPORT # # # # # # # # # # # # # # # # # # +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + + + + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # POST JSON FORMAT From fea7b071349d91f85404237283a7377ef813aca2 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Mon, 5 Aug 2019 09:46:56 +0200 Subject: [PATCH 14/25] chg: [api] add api versioning --- doc/README.md | 36 ++++++++++++------------ var/www/modules/restApi/Flask_restApi.py | 18 ++++++------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/doc/README.md b/doc/README.md index 3c047e6e..ca7b8e75 100644 --- a/doc/README.md +++ b/doc/README.md @@ -27,7 +27,7 @@ curl --header "Authorization: YOUR_API_KEY" --header "Content-Type: application/ ## Item management -### Get item: `api/get/item/default/` +### Get item: `api/v1/get/item/default/` #### Description Get item default info. @@ -92,7 +92,7 @@ curl https://127.0.0.1:7000/api/get/item/default/submitted/2019/07/26/3efb8a79-0 -### Get item content: `api/get/item/content/` +### Get item content: `api/v1/get/item/content/` #### Description Get a specific item content. @@ -141,7 +141,7 @@ curl https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-0 -### Get item content: `api/get/item/tag/` +### Get item content: `api/v1/get/item/tag/` #### Description Get all tags from an item. @@ -164,7 +164,7 @@ Get all tags from an item. #### Example ``` -curl https://127.0.0.1:7000/api/get/item/tag/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/v1/get/item/tag/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" ``` #### Expected Success Response @@ -197,7 +197,7 @@ curl https://127.0.0.1:7000/api/get/item/tag/submitted/2019/07/26/3efb8a79-08e9- -### Advanced Get item: `api/get/item` +### Advanced Get item: `api/v1/get/item` #### Description Get item. Filter requested field. @@ -259,7 +259,7 @@ Get item. Filter requested field. #### Example ``` -curl https://127.0.0.1:7000/api/get/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +curl https://127.0.0.1:7000/api/v1/get/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST ``` #### input.json Example @@ -306,7 +306,7 @@ curl https://127.0.0.1:7000/api/get/item --header "Authorization: iHc1_ChZxj1aXm -### add item tags: `api/add/item/tag` +### add item tags: `api/v1/add/item/tag` #### Description Add tags to an item. @@ -337,7 +337,7 @@ Add tags to an item. #### Example ``` -curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +curl https://127.0.0.1:7000/api/v1/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST ``` #### input.json Example @@ -380,7 +380,7 @@ curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1 -### Delete item tags: `api/delete/item/tag` +### Delete item tags: `api/v1/delete/item/tag` #### Description Delete tags from an item. @@ -407,7 +407,7 @@ Delete tags from an item. #### Example ``` -curl https://127.0.0.1:7000/api/delete/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X DELETE +curl https://127.0.0.1:7000/api/v1/delete/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X DELETE ``` #### input.json Example @@ -452,7 +452,7 @@ curl https://127.0.0.1:7000/api/delete/item/tag --header "Authorization: iHc1_Ch ## Tag management -### Get all AIL tags: `api/get/tag/all` +### Get all AIL tags: `api/v1/get/tag/all` #### Description Get all tags used in AIL. @@ -465,7 +465,7 @@ Get all tags used in AIL. - *list* #### Example ``` -curl https://127.0.0.1:7000/api/get/tag/all --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/v1/get/tag/all --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" ``` #### Expected Success Response @@ -506,7 +506,7 @@ curl https://127.0.0.1:7000/api/get/tag/all --header "Authorization: iHc1_ChZxj1 -### Get tag metadata: `api/get/tag/metadata/` +### Get tag metadata: `api/v1/get/tag/metadata/` #### Description Get tag metadata. @@ -531,7 +531,7 @@ Get tag metadata. - *str - YYMMDD* #### Example ``` -curl https://127.0.0.1:7000/api/get/tag/metadata/infoleak:submission=\"manual\" --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/v1/get/tag/metadata/infoleak:submission=\"manual\" --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" ``` #### Expected Success Response @@ -559,7 +559,7 @@ curl https://127.0.0.1:7000/api/get/tag/metadata/infoleak:submission=\"manual\" -### Import item (currently: text only): `api/import/item` +### Import item (currently: text only): `api/v1/import/item` #### Description Allows users to import new items. asynchronous function. @@ -595,7 +595,7 @@ Allows users to import new items. asynchronous function. #### Example ``` -curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +curl https://127.0.0.1:7000/api/v1/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST ``` #### input.json Example @@ -632,7 +632,7 @@ curl https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1 -### GET Import item info: `api/import/item/` +### GET Import item info: `api/v1/import/item/` #### Description @@ -661,7 +661,7 @@ Get import status and all items imported by uuid #### Example ``` -curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763b50 --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl -k https://127.0.0.1:7000/api/v1/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763b50 --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" ``` #### Expected Success Response diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index f2c6f64a..489af778 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -156,7 +156,7 @@ def one(): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/get/item", methods=['GET', 'POST']) +@restApi.route("api/v1/get/item", methods=['GET', 'POST']) @token_required('admin') def get_item_id(): if request.method == 'POST': @@ -166,7 +166,7 @@ def get_item_id(): else: return 'description API endpoint' -@restApi.route("api/get/item/default/", methods=['GET']) +@restApi.route("api/v1/get/item/default/", methods=['GET']) @token_required('admin') def get_item_id_basic(item_id): """ @@ -227,7 +227,7 @@ def get_item_id_basic(item_id): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/get/item/tag/", methods=['GET']) +@restApi.route("api/v1/get/item/tag/", methods=['GET']) @token_required('admin') def get_item_tag(item_id): """ @@ -287,7 +287,7 @@ def get_item_tag(item_id): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/add/item/tag", methods=['POST']) +@restApi.route("api/v1/add/item/tag", methods=['POST']) @token_required('admin') def add_item_tags(): """ @@ -370,7 +370,7 @@ def add_item_tags(): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/delete/item/tag", methods=['DELETE']) +@restApi.route("api/v1/delete/item/tag", methods=['DELETE']) @token_required('admin') def delete_item_tags(): """ @@ -447,7 +447,7 @@ def delete_item_tags(): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/get/item/content/", methods=['GET']) +@restApi.route("api/v1/get/item/content/", methods=['GET']) @token_required('admin') def get_item_content(item_id): """ @@ -489,7 +489,7 @@ def get_item_content(item_id): # # # # # # # # # # # # # # TAGS # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/get/tag/metadata/", methods=['GET']) +@restApi.route("api/v1/get/tag/metadata/", methods=['GET']) @token_required('admin') def get_tag_metadata(tag): if not Tag.is_tag_in_all_tag(tag): @@ -525,7 +525,7 @@ def get_all_tags(): # response: {"uuid": "uuid"} # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/import/item", methods=['POST']) +@restApi.route("api/v1/import/item", methods=['POST']) @token_required('admin') def import_item(): """ @@ -624,7 +624,7 @@ def import_item(): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/import/item/", methods=['GET']) +@restApi.route("api/v1/import/item/", methods=['GET']) @token_required('admin') def import_item_uuid(UUID): """ From e28d5635235d5c9d906f97c16b4fc51c0cb7d579 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Mon, 5 Aug 2019 16:00:23 +0200 Subject: [PATCH 15/25] chg: [api] use POST with parameters + add API unittest --- bin/packages/Item.py | 4 +- bin/packages/Tag.py | 4 +- doc/README.md | 63 ++++- var/www/modules/restApi/Flask_restApi.py | 344 ++--------------------- 4 files changed, 80 insertions(+), 335 deletions(-) diff --git a/bin/packages/Item.py b/bin/packages/Item.py index 9b1b4b9d..2c10cb85 100755 --- a/bin/packages/Item.py +++ b/bin/packages/Item.py @@ -46,13 +46,11 @@ def get_item_content(item_id): except UnicodeDecodeError: item_content = None except Exception as e: - print("ERROR in: " + item_id) - print(e) item_content = None if item_content is None: try: with gzip.open(item_full_path, 'r') as f: - item_content = f.read() + item_content = f.read().decode() r_cache.set(item_full_path, item_content) r_cache.expire(item_full_path, 300) except: diff --git a/bin/packages/Tag.py b/bin/packages/Tag.py index 37a43423..dd1e858c 100755 --- a/bin/packages/Tag.py +++ b/bin/packages/Tag.py @@ -42,8 +42,6 @@ def is_galaxy_tag_enabled(galaxy, tag): # Check if tags are enabled in AIL def is_valid_tags_taxonomies_galaxy(list_tags, list_tags_galaxy): - print(list_tags) - print(list_tags_galaxy) if list_tags: active_taxonomies = get_active_taxonomies() @@ -161,7 +159,7 @@ def remove_item_tags(tags=[], item_id=None): if res[1] != 200: return res else: - dict_res[tags].append(tag) + dict_res['tags'].append(tag) dict_res['id'] = item_id return (dict_res, 200) diff --git a/doc/README.md b/doc/README.md index ca7b8e75..143f782f 100644 --- a/doc/README.md +++ b/doc/README.md @@ -27,12 +27,12 @@ curl --header "Authorization: YOUR_API_KEY" --header "Content-Type: application/ ## Item management -### Get item: `api/v1/get/item/default/` +### Get item: `api/v1/get/item/default` #### Description Get item default info. -**Method** : `GET` +**Method** : `POST` #### Parameters - `id` @@ -56,7 +56,14 @@ Get item default info. #### Example ``` -curl https://127.0.0.1:7000/api/get/item/default/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/v1/get/item/default --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz" + } ``` #### Expected Success Response @@ -92,12 +99,12 @@ curl https://127.0.0.1:7000/api/get/item/default/submitted/2019/07/26/3efb8a79-0 -### Get item content: `api/v1/get/item/content/` +### Get item content: `api/v1/get/item/content` #### Description Get a specific item content. -**Method** : `GET` +**Method** : `POST` #### Parameters - `id` @@ -115,7 +122,14 @@ Get a specific item content. #### Example ``` -curl https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/v1/get/item/content --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz" + } ``` #### Expected Success Response @@ -141,12 +155,12 @@ curl https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-0 -### Get item content: `api/v1/get/item/tag/` +### Get item content: `api/v1/get/item/tag` #### Description Get all tags from an item. -**Method** : `GET` +**Method** : `POST` #### Parameters - `id` @@ -164,7 +178,14 @@ Get all tags from an item. #### Example ``` -curl https://127.0.0.1:7000/api/v1/get/item/tag/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/v1/get/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json + { + "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz" + } ``` #### Expected Success Response @@ -506,12 +527,12 @@ curl https://127.0.0.1:7000/api/v1/get/tag/all --header "Authorization: iHc1_ChZ -### Get tag metadata: `api/v1/get/tag/metadata/` +### Get tag metadata: `api/v1/get/tag/metadata` #### Description Get tag metadata. -**Method** : `GET` +**Method** : `POST` #### Parameters - `tag` @@ -531,7 +552,14 @@ Get tag metadata. - *str - YYMMDD* #### Example ``` -curl https://127.0.0.1:7000/api/v1/get/tag/metadata/infoleak:submission=\"manual\" --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl https://127.0.0.1:7000/api/v1/get/tag/metadata --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json + { + "tag": "infoleak:submission=\"manual\"" + } ``` #### Expected Success Response @@ -638,7 +666,7 @@ curl https://127.0.0.1:7000/api/v1/import/item --header "Authorization: iHc1_ChZ Get import status and all items imported by uuid -**Method** : `GET` +**Method** : `POST` #### Parameters @@ -661,7 +689,14 @@ Get import status and all items imported by uuid #### Example ``` -curl -k https://127.0.0.1:7000/api/v1/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763b50 --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" +curl -k https://127.0.0.1:7000/api/v1/get/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST +``` + +#### input.json Example +```json + { + "uuid": "0c3d7b34-936e-4f01-9cdf-2070184b6016" + } ``` #### Expected Success Response diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 489af778..b3697a6c 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -166,53 +166,16 @@ def get_item_id(): else: return 'description API endpoint' -@restApi.route("api/v1/get/item/default/", methods=['GET']) +@restApi.route("api/v1/get/item/default", methods=['POST']) @token_required('admin') -def get_item_id_basic(item_id): - """ - **POST api/get/item/default/** +def get_item_id_basic(): - **Get item** - - This function allows user to get a specific item information through their item_id. - - :param id: id of the item - :type id: item id - :return: item's information in json and http status code - - - Example:: - - curl -k https://127.0.0.1:7000/api/get/item/default/ --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json --data @input.json -X POST" - - - Expected Success Response:: - - HTTP Status Code: 200 - - { - "content": "item content test", - "date": "20190726", - "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", - "tags": - [ - "misp-galaxy:backdoor=\"Rosenbridge\"", - "infoleak:automatic-detection=\"pgp-message\"", - "infoleak:automatic-detection=\"encrypted-private-key\"", - "infoleak:submission=\"manual\"", - "misp-galaxy:backdoor=\"SLUB\"" - ] - } - - - Expected Fail Response:: - - HTTP Status Code: 400 - - {'status': 'error', 'reason': 'Item not found'} - - """ - - data = {'id': item_id, 'date': True, 'content': True, 'tags': True} - res = Item.get_item(data) - return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] + if request.method == 'POST': + data = request.get_json() + item_id = data.get('id', None) + req_data = {'id': item_id, 'date': True, 'content': True, 'tags': True} + res = Item.get_item(req_data) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # GET @@ -227,49 +190,14 @@ def get_item_id_basic(item_id): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/v1/get/item/tag/", methods=['GET']) +@restApi.route("api/v1/get/item/tag", methods=['POST']) @token_required('admin') -def get_item_tag(item_id): - """ - **GET api/get/item/tag/** +def get_item_tag(): - **Get item tags** - - This function allows user to get all items tags form a specified item id. - - :param id: id of the item - :type id: item id - :return: item's tags list in json and http status code - - - Example:: - - curl -k https://127.0.0.1:7000/api/get/item/tag/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" - - - Expected Success Response:: - - HTTP Status Code: 200 - - { - "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", - "tags": - [ - "misp-galaxy:backdoor=\"Rosenbridge\"", - "infoleak:automatic-detection=\"pgp-message\"", - "infoleak:automatic-detection=\"encrypted-private-key\"", - "infoleak:submission=\"manual\"", - "misp-galaxy:backdoor=\"SLUB\"" - ] - } - - - Expected Fail Response:: - - HTTP Status Code: 400 - - {'status': 'error', 'reason': 'Item not found'} - - """ - data = {'id': item_id, 'date': False, 'tags': True} - res = Item.get_item(data) + data = request.get_json() + item_id = data.get('id', None) + req_data = {'id': item_id, 'date': False, 'tags': True} + res = Item.get_item(req_data) return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @@ -290,61 +218,7 @@ def get_item_tag(item_id): @restApi.route("api/v1/add/item/tag", methods=['POST']) @token_required('admin') def add_item_tags(): - """ - **POST api/add/item/tag** - **add tags to an item** - - This function allows user to add tags and galaxy to an item. - - :param id: id of the item - :type id: item id - :param tags: list of tags (default=[]) - :type tags: list - :param galaxy: list of galaxy (default=[]) - :type galaxy: list - - :return: item id and tags added in json and http status code - - - Example:: - - curl -k https://127.0.0.1:7000/api/add/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST - - - input.json Example:: - - { - "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", - "tags": [ - "infoleak:analyst-detection=\"private-key\"", - "infoleak:analyst-detection=\"api-key\"" - ], - "galaxy": [ - "misp-galaxy:stealer=\"Vidar\"" - ] - } - - - Expected Success Response:: - - HTTP Status Code: 200 - - { - "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", - "tags": [ - "infoleak:analyst-detection=\"private-key\"", - "infoleak:analyst-detection=\"api-key\"", - "misp-galaxy:stealer=\"Vidar\"" - ] - } - - - Expected Fail Response:: - - HTTP Status Code: 400 - - {'status': 'error', 'reason': 'Item id not found'} - {'status': 'error', 'reason': 'Tags or Galaxy not specified'} - {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} - - """ data = request.get_json() if not data: return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 @@ -373,57 +247,7 @@ def add_item_tags(): @restApi.route("api/v1/delete/item/tag", methods=['DELETE']) @token_required('admin') def delete_item_tags(): - """ - **DELET E api/delete/item/tag** - **delete tags from an item** - - This function allows user to delete tags and galaxy from an item. - - :param id: id of the item - :type id: item id - :param tags: list of tags (default=[]) - :type tags: list - - :return: item id and tags deleted in json and http status code - - - Example:: - - curl -k https://127.0.0.1:7000/api/delete/item/tag --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X DELET E - - - input.json Example:: - - { - "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", - "tags": [ - "infoleak:analyst-detection=\"private-key\"", - "infoleak:analyst-detection=\"api-key\"", - "misp-galaxy:stealer=\"Vidar\"" - ] - } - - - Expected Success Response:: - - HTTP Status Code: 200 - - { - "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz", - "tags": [ - "infoleak:analyst-detection=\"private-key\"", - "infoleak:analyst-detection=\"api-key\"", - "misp-galaxy:stealer=\"Vidar\"" - ] - } - - - Expected Fail Response:: - - HTTP Status Code: 400 - - {'status': 'error', 'reason': 'Item id not found'} - {'status': 'error', 'reason': 'No Tag(s) specified} - {'status': 'error', 'reason': 'Malformed JSON'} - - """ data = request.get_json() if not data: return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 @@ -447,57 +271,31 @@ def delete_item_tags(): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/v1/get/item/content/", methods=['GET']) +@restApi.route("api/v1/get/item/content", methods=['POST']) @token_required('admin') -def get_item_content(item_id): - """ - **GET api/get/item/content/** +def get_item_content(): - **Get item content** - - This function allows user to get a specific item content. - - :param id: id of the item - :type id: item id - :return: item's content in json and http status code - - - Example:: - - curl -k https://127.0.0.1:7000/api/get/item/content/submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" - - - Expected Success Response:: - - HTTP Status Code: 200 - - { - "content": "item content test", - "id": "submitted/2019/07/26/3efb8a79-08e9-4776-94ab-615eb370b6d4.gz" - } - - - Expected Fail Response:: - - HTTP Status Code: 400 - - {'status': 'error', 'reason': 'Item not found'} - - """ - data = {'id': item_id, 'date': False, 'content': True, 'tags': False} - res = Item.get_item(data) + data = request.get_json() + item_id = data.get('id', None) + req_data = {'id': item_id, 'date': False, 'content': True, 'tags': False} + res = Item.get_item(req_data) return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # TAGS # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/v1/get/tag/metadata/", methods=['GET']) +@restApi.route("api/v1/get/tag/metadata", methods=['POST']) @token_required('admin') -def get_tag_metadata(tag): +def get_tag_metadata(): + data = request.get_json() + tag = data.get('tag', None) if not Tag.is_tag_in_all_tag(tag): return Response(json.dumps({'status': 'error', 'reason':'Tag not found'}, indent=2, sort_keys=True), mimetype='application/json'), 404 metadata = Tag.get_tag_metadata(tag) return Response(json.dumps(metadata, indent=2, sort_keys=True), mimetype='application/json'), 200 -@restApi.route("api/get/tag/all", methods=['GET']) +@restApi.route("api/v1/get/tag/all", methods=['GET']) @token_required('admin') def get_all_tags(): res = {'tags': Tag.get_all_tags()} @@ -528,58 +326,7 @@ def get_all_tags(): @restApi.route("api/v1/import/item", methods=['POST']) @token_required('admin') def import_item(): - """ - **POST api/import/item** - **Import new item** - - This function allows user to import new items. asynchronous function. - - :param text: text to import - :type text: str - :param type: import type (default='text') - :type type: "text" - :param tags: list of tags (default=[]) - :type tags: list - :param galaxy: list of galaxy (default=[]) - :type galaxy: list - :param default_tags: add default tag (default=True) - :type default_tags: boolean - - :return: imported uuid in json and http status code - - - Example:: - - curl -k https://127.0.0.1:7000/api/import/item --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST - - - input.json Example:: - - { - "type": "text", - "tags": [ - "infoleak:analyst-detection=\"private-key\"" - ], - "text": "text to import" - } - - - Expected Success Response:: - - HTTP Status Code: 200 - - { - "uuid": "0c3d7b34-936e-4f01-9cdf-2070184b6016" - } - - - Expected Fail Response:: - - HTTP Status Code: 400 - - {'status': 'error', 'reason': 'Malformed JSON'} - {'status': 'error', 'reason': 'No text supplied'} - {'status': 'error', 'reason': 'Tags or Galaxy not enabled'} - {'status': 'error', 'reason': 'Size exceeds default'} - - """ data = request.get_json() if not data: return Response(json.dumps({'status': 'error', 'reason': 'Malformed JSON'}, indent=2, sort_keys=True), mimetype='application/json'), 400 @@ -624,44 +371,11 @@ def import_item(): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/v1/import/item/", methods=['GET']) +@restApi.route("api/v1/get/import/item", methods=['POST']) @token_required('admin') -def import_item_uuid(UUID): - """ - **GET api/import/item/** - - **Get import status and all items imported by uuid** - - This return the import status and a list of imported items. - The full list of imported items is not complete until 'status'='imported'. - - :param uuid: import uuid - :type uuid: uuid4 - :return: json: import status + imported items list - - - Example:: - - curl -k https://127.0.0.1:7000/api/import/item/b20a69f1-99ad-4cb3-b212-7ce24b763b50 --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" - - - Expected Success Response:: - - HTTP Status Code: 200 - - { - "items": [ - "submitted/2019/07/26/b20a69f1-99ad-4cb3-b212-7ce24b763b50.gz" - ], - "status": "in queue"/"in progress"/"imported" - } - - - Expected Fail Response:: - - HTTP Status Code: 400 - - {'status': 'error', 'reason': 'Invalid uuid'} - {'status': 'error', 'reason': 'Unknown uuid'} - - """ +def import_item_uuid(): + data = request.get_json() + UUID = data.get('uuid', None) # Verify uuid if not is_valid_uuid_v4(UUID): From 3c36c9c8eb144d35f96520e850e84d15252c628d Mon Sep 17 00:00:00 2001 From: Terrtia Date: Mon, 5 Aug 2019 16:00:52 +0200 Subject: [PATCH 16/25] chg: [api] use POST with parameters + add API unittest --- tests/testApi.py | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/testApi.py diff --git a/tests/testApi.py b/tests/testApi.py new file mode 100644 index 00000000..77331769 --- /dev/null +++ b/tests/testApi.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys +import time +import unittest + +sys.path.append(os.path.join(os.environ['AIL_BIN'], 'packages')) +sys.path.append(os.environ['AIL_FLASK']) +sys.path.append(os.path.join(os.environ['AIL_FLASK'], 'modules')) + +import Import_helper +import Tag + +from Flask_server import app + +def parse_response(obj, ail_response): + res_json = ail_response.get_json() + if 'status' in res_json: + if res_json['status'] == 'error': + return obj.fail('{}: {}: {}'.format(ail_response.status_code, res_json['status'], res_json['reason'])) + return res_json + +def get_api_key(): + with open(os.path.join(os.environ['AIL_HOME'], 'DEFAULT_PASSWORD'). 'r') as f: + content = f.read() + content = content.splitline() + apikey = content[-1] + apikey = apikey.replace('API_Key=', '', 1) + +class TestApiV1(unittest.TestCase): + import_uuid = None + item_id = None + + def setUp(self): + self.app = app + self.app.config['TESTING'] = True + self.client = self.app.test_client() + self.apikey = get_api_key() + self.item_content = "text to import" + self.item_tags = ["infoleak:analyst-detection=\"private-key\""] + self.expected_tags = ["infoleak:analyst-detection=\"private-key\"", 'infoleak:submission="manual"'] + + # POST /api/v1/import/item + def test_0001_api_import_item(self): + input_json = {"type": "text","tags": self.item_tags,"text": self.item_content} + req = self.client.post('/api/v1/import/item', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + import_uuid = req_json['uuid'] + self.__class__.import_uuid = import_uuid + self.assertTrue(Import_helper.is_valid_uuid_v4(import_uuid)) + + # POST /api/v1/get/import/item + def test_0002_api_get_import_item(self): + input_json = {"uuid": self.__class__.import_uuid} + item_not_imported = True + import_timout = 30 + start = time.time() + + while item_not_imported: + req = self.client.post('/api/v1/get/import/item', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + if req_json['status'] == 'imported': + try: + item_id = req_json['items'][0] + item_not_imported = False + except Exception as e: + if time.time() - start > import_timout: + item_not_imported = False + self.fail("Import error: {}".format(req_json)) + else: + if time.time() - start > import_timout: + item_not_imported = False + self.fail("Import Timeout, import status: {}".format(req_json['status'])) + self.__class__.item_id = item_id + + # Process item + time.sleep(5) + + # POST /api/v1/get/item/content + def test_0003_api_get_item_content(self): + input_json = {"id": self.__class__.item_id} + req = self.client.post('/api/v1/get/item/content', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + item_content = req_json['content'] + self.assertEqual(item_content, self.item_content) + + # POST /api/v1/get/item/tag + def test_0004_api_get_item_tag(self): + input_json = {"id": self.__class__.item_id} + req = self.client.post('/api/v1/get/item/tag', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + item_tags = req_json['tags'] + self.assertCountEqual(item_tags, self.expected_tags) + + # POST /api/v1/get/item/tag + def test_0005_api_get_item_default(self): + input_json = {"id": self.__class__.item_id} + req = self.client.post('/api/v1/get/item/default', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + item_tags = req_json['tags'] + self.assertCountEqual(item_tags, self.expected_tags) + item_content = req_json['content'] + self.assertEqual(item_content, self.item_content) + + # POST /api/v1/get/item/tag + # # TODO: add more test + def test_0006_api_get_item(self): + input_json = {"id": self.__class__.item_id, "content": True} + req = self.client.post('/api/v1/get/item', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + item_tags = req_json['tags'] + self.assertCountEqual(item_tags, self.expected_tags) + item_content = req_json['content'] + self.assertEqual(item_content, self.item_content) + + # POST api/v1/add/item/tag + def test_0007_api_add_item_tag(self): + tags_to_add = ["infoleak:analyst-detection=\"api-key\""] + current_item_tag = Tag.get_item_tags(self.__class__.item_id) + current_item_tag.append(tags_to_add[0]) + + #galaxy_to_add = ["misp-galaxy:stealer=\"Vidar\""] + input_json = {"id": self.__class__.item_id, "tags": tags_to_add} + req = self.client.post('/api/v1/add/item/tag', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + item_tags = req_json['tags'] + self.assertEqual(item_tags, tags_to_add) + + new_item_tag = Tag.get_item_tags(self.__class__.item_id) + self.assertCountEqual(new_item_tag, current_item_tag) + + # DELETE api/v1/delete/item/tag + def test_0008_api_add_item_tag(self): + tags_to_delete = ["infoleak:analyst-detection=\"api-key\""] + input_json = {"id": self.__class__.item_id, "tags": tags_to_delete} + req = self.client.delete('/api/v1/delete/item/tag', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + item_tags = req_json['tags'] + self.assertCountEqual(item_tags, tags_to_delete) + current_item_tag = Tag.get_item_tags(self.__class__.item_id) + if tags_to_delete[0] in current_item_tag: + self.fail('Tag no deleted') + + # POST api/v1/get/tag/metadata + def test_0009_api_add_item_tag(self): + input_json = {"tag": self.item_tags[0]} + req = self.client.post('/api/v1/get/tag/metadata', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + self.assertEqual(req_json['tag'], self.item_tags[0]) + + # GET api/v1/get/tag/all + def test_0010_api_add_item_tag(self): + input_json = {"tag": self.item_tags[0]} + req = self.client.get('/api/v1/get/tag/all', json=input_json ,headers={ 'Authorization': self.apikey }) + req_json = parse_response(self, req) + self.assertTrue(req_json['tags']) + +if __name__ == "__main__": + unittest.main() From fa133ce12c3b263c41646fa533aea62e628ac17f Mon Sep 17 00:00:00 2001 From: Terrtia Date: Mon, 5 Aug 2019 16:31:03 +0200 Subject: [PATCH 17/25] fix: [test api] typo --- doc/README.md | 20 ++++++++++---------- tests/testApi.py | 10 +++++++--- var/www/Flask_server.py | 3 +-- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/doc/README.md b/doc/README.md index 143f782f..e553c4ba 100644 --- a/doc/README.md +++ b/doc/README.md @@ -660,7 +660,7 @@ curl https://127.0.0.1:7000/api/v1/import/item --header "Authorization: iHc1_ChZ -### GET Import item info: `api/v1/import/item/` +### GET Import item info: `api/v1/get/import/item/` #### Description @@ -751,16 +751,16 @@ curl -k https://127.0.0.1:7000/api/v1/get/import/item --header "Authorization: i ##### ``api/get/crawler/domain/`` POST ### Check if a tor/regular domain have been crawled -##### ``api/get/crawler/domain/metadata/ `` GET +##### ``api/get/crawler/domain/metadata/ `` POST ### Get domain tags -##### ``api/get/crawler/domain/tag/ `` GET +##### ``api/get/crawler/domain/tag/ `` POST ### Get domain history -##### ``api/get/crawler/domain/history/ `` GET +##### ``api/get/crawler/domain/history/ `` POST ### Get domain list of items -##### ``api/get/crawler/domain/item/ `` GET +##### ``api/get/crawler/domain/item/ `` POST ----- @@ -773,14 +773,14 @@ curl -k https://127.0.0.1:7000/api/v1/get/import/item --header "Authorization: i ##### ``api/get/decoded`` POST ### Check if a decoded item exists (via sha1) -##### ``api/get/decoded/exist/`` GET +##### ``api/get/decoded/exist/`` POST ### Get decoded item metadata ### Check if a decoded item exists (via sha1) -##### ``api/get/decoded/metadata/`` GET +##### ``api/get/decoded/metadata/`` POST ### Get decoded item correlation (1 depth) -##### ``api/get/decoded/metadata/`` GET +##### ``api/get/decoded/metadata/`` POST ----- @@ -789,10 +789,10 @@ curl -k https://127.0.0.1:7000/api/v1/get/import/item --header "Authorization: i ##### ``api/get/cryptocurrency`` POST ### Check if a cryptocurrency address (bitcoin, ..) exists -##### ``api/get/cryptocurrency/exist/`` GET +##### ``api/get/cryptocurrency/exist/`` POST ### Get cryptocurrency address metadata -##### ``api/get/cryptocurrency/metadata/`` GET +##### ``api/get/cryptocurrency/metadata/`` POST ----- diff --git a/tests/testApi.py b/tests/testApi.py index 77331769..ceac0185 100644 --- a/tests/testApi.py +++ b/tests/testApi.py @@ -23,21 +23,25 @@ def parse_response(obj, ail_response): return res_json def get_api_key(): - with open(os.path.join(os.environ['AIL_HOME'], 'DEFAULT_PASSWORD'). 'r') as f: + with open(os.path.join(os.environ['AIL_HOME'], 'DEFAULT_PASSWORD'), 'r') as f: content = f.read() - content = content.splitline() + content = content.splitlines() apikey = content[-1] apikey = apikey.replace('API_Key=', '', 1) + return apikey + +APIKEY = get_api_key() class TestApiV1(unittest.TestCase): import_uuid = None item_id = None + def setUp(self): self.app = app self.app.config['TESTING'] = True self.client = self.app.test_client() - self.apikey = get_api_key() + self.apikey = APIKEY self.item_content = "text to import" self.item_tags = ["infoleak:analyst-detection=\"private-key\""] self.expected_tags = ["infoleak:analyst-detection=\"private-key\"", 'infoleak:submission="manual"'] diff --git a/var/www/Flask_server.py b/var/www/Flask_server.py index c4d3c3c9..6361e94c 100755 --- a/var/www/Flask_server.py +++ b/var/www/Flask_server.py @@ -115,8 +115,7 @@ try: toIgnoreModule.add(line) except IOError: - f = open(os.path.join(Flask_dir, 'templates', 'ignored_modules.txt'), 'w') - f.close() + pass # Dynamically import routes and functions from modules # Also, prepare header.html From 88592dae577d3abd0a0bf4e9a3d71173f32965e3 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Tue, 6 Aug 2019 10:22:09 +0200 Subject: [PATCH 18/25] chg: [api] add bruteforce protection --- var/www/modules/restApi/Flask_restApi.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index b3697a6c..a714d3da 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -96,6 +96,15 @@ def authErrors(user_role): data = None # verify token format + # brute force protection + current_ip = request.remote_addr + login_failed_ip = r_cache.get('failed_login_ip_api:{}'.format(current_ip)) + # brute force by ip + if login_failed_ip: + login_failed_ip = int(login_failed_ip) + if login_failed_ip >= 5: + return ({'status': 'error', 'reason': 'Max Connection Attempts reached, Please wait {}s'.format(r_cache.ttl('failed_login_ip_api:{}'.format(current_ip)))}, 401) + try: authenticated = False if verify_token(token): @@ -106,6 +115,8 @@ def authErrors(user_role): data = ({'status': 'error', 'reason': 'Access Forbidden'}, 403) if not authenticated: + r_cache.incr('failed_login_ip_api:{}'.format(current_ip)) + r_cache.expire('failed_login_ip_api:{}'.format(current_ip), 300) data = ({'status': 'error', 'reason': 'Authentication failed'}, 401) except Exception as e: print(e) From 071a206d5ed5f6080e74b6d3086b1ccb10f6ff3e Mon Sep 17 00:00:00 2001 From: Terrtia Date: Tue, 6 Aug 2019 10:37:56 +0200 Subject: [PATCH 19/25] fix: [api] chg api role --- var/www/modules/restApi/Flask_restApi.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index a714d3da..93de87ee 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -168,7 +168,7 @@ def one(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/get/item", methods=['GET', 'POST']) -@token_required('admin') +@token_required('analyst') def get_item_id(): if request.method == 'POST': data = request.get_json() @@ -178,7 +178,7 @@ def get_item_id(): return 'description API endpoint' @restApi.route("api/v1/get/item/default", methods=['POST']) -@token_required('admin') +@token_required('analyst') def get_item_id_basic(): if request.method == 'POST': @@ -202,7 +202,7 @@ def get_item_id_basic(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/get/item/tag", methods=['POST']) -@token_required('admin') +@token_required('analyst') def get_item_tag(): data = request.get_json() @@ -227,7 +227,7 @@ def get_item_tag(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/add/item/tag", methods=['POST']) -@token_required('admin') +@token_required('analyst') def add_item_tags(): data = request.get_json() @@ -256,7 +256,7 @@ def add_item_tags(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/delete/item/tag", methods=['DELETE']) -@token_required('admin') +@token_required('analyst') def delete_item_tags(): data = request.get_json() @@ -283,7 +283,7 @@ def delete_item_tags(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/get/item/content", methods=['POST']) -@token_required('admin') +@token_required('analyst') def get_item_content(): data = request.get_json() @@ -297,7 +297,7 @@ def get_item_content(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/get/tag/metadata", methods=['POST']) -@token_required('admin') +@token_required('analyst') def get_tag_metadata(): data = request.get_json() tag = data.get('tag', None) @@ -307,7 +307,7 @@ def get_tag_metadata(): return Response(json.dumps(metadata, indent=2, sort_keys=True), mimetype='application/json'), 200 @restApi.route("api/v1/get/tag/all", methods=['GET']) -@token_required('admin') +@token_required('analyst') def get_all_tags(): res = {'tags': Tag.get_all_tags()} return Response(json.dumps(res, indent=2, sort_keys=True), mimetype='application/json'), 200 @@ -335,7 +335,7 @@ def get_all_tags(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/import/item", methods=['POST']) -@token_required('admin') +@token_required('analyst') def import_item(): data = request.get_json() @@ -383,7 +383,7 @@ def import_item(): # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @restApi.route("api/v1/get/import/item", methods=['POST']) -@token_required('admin') +@token_required('analyst') def import_item_uuid(): data = request.get_json() UUID = data.get('uuid', None) From b06777c9074ad108fa92e5261c68fda1c78900ac Mon Sep 17 00:00:00 2001 From: Terrtia Date: Tue, 6 Aug 2019 10:45:20 +0200 Subject: [PATCH 20/25] chg: [api doc] test anchor link --- doc/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/README.md b/doc/README.md index e553c4ba..d832a3e6 100644 --- a/doc/README.md +++ b/doc/README.md @@ -25,9 +25,11 @@ Example: curl --header "Authorization: YOUR_API_KEY" --header "Content-Type: application/json" https://AIL_URL/ ~~~~ +Take me to [get_item_content](#get_item_content) + ## Item management -### Get item: `api/v1/get/item/default` +### Get item: `api/v1/get/item/default` #### Description Get item default info. @@ -99,7 +101,7 @@ curl https://127.0.0.1:7000/api/v1/get/item/default --header "Authorization: iHc -### Get item content: `api/v1/get/item/content` +### Get item content: `api/v1/get/item/content` #### Description Get a specific item content. From 4ce548ab8404de93dc44df585198bf080a9d3e56 Mon Sep 17 00:00:00 2001 From: Terrtia Date: Tue, 6 Aug 2019 10:54:37 +0200 Subject: [PATCH 21/25] chg: [api doc] test anchor link --- doc/README.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/doc/README.md b/doc/README.md index d832a3e6..3bce7ee8 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,6 +18,7 @@ When submitting data in a POST, PUT or DELETE operation you need to specify in w ~~~~ Content-Type: application/json ~~~~ +Take me to [delete_item_tag](#delete_item_tag) Example: @@ -25,11 +26,9 @@ Example: curl --header "Authorization: YOUR_API_KEY" --header "Content-Type: application/json" https://AIL_URL/ ~~~~ -Take me to [get_item_content](#get_item_content) - ## Item management -### Get item: `api/v1/get/item/default` +### Get item: `api/v1/get/item/default` #### Description Get item default info. @@ -101,7 +100,7 @@ curl https://127.0.0.1:7000/api/v1/get/item/default --header "Authorization: iHc -### Get item content: `api/v1/get/item/content` +### Get item content: `api/v1/get/item/content` #### Description Get a specific item content. @@ -157,7 +156,7 @@ curl https://127.0.0.1:7000/api/v1/get/item/content --header "Authorization: iHc -### Get item content: `api/v1/get/item/tag` +### Get item content: `api/v1/get/item/tag` #### Description Get all tags from an item. @@ -220,7 +219,7 @@ curl https://127.0.0.1:7000/api/v1/get/item/tag --header "Authorization: iHc1_Ch -### Advanced Get item: `api/v1/get/item` +### Advanced Get item: `api/v1/get/item` #### Description Get item. Filter requested field. @@ -329,7 +328,7 @@ curl https://127.0.0.1:7000/api/v1/get/item --header "Authorization: iHc1_ChZxj1 -### add item tags: `api/v1/add/item/tag` +### add item tags: `api/v1/add/item/tag` #### Description Add tags to an item. @@ -403,7 +402,7 @@ curl https://127.0.0.1:7000/api/v1/import/item --header "Authorization: iHc1_ChZ -### Delete item tags: `api/v1/delete/item/tag` +### Delete item tags: `api/v1/delete/item/tag` #### Description Delete tags from an item. @@ -475,7 +474,7 @@ curl https://127.0.0.1:7000/api/v1/delete/item/tag --header "Authorization: iHc1 ## Tag management -### Get all AIL tags: `api/v1/get/tag/all` +### Get all AIL tags: `api/v1/get/tag/all` #### Description Get all tags used in AIL. @@ -529,7 +528,7 @@ curl https://127.0.0.1:7000/api/v1/get/tag/all --header "Authorization: iHc1_ChZ -### Get tag metadata: `api/v1/get/tag/metadata` +### Get tag metadata: `api/v1/get/tag/metadata` #### Description Get tag metadata. @@ -589,7 +588,7 @@ curl https://127.0.0.1:7000/api/v1/get/tag/metadata --header "Authorization: iHc -### Import item (currently: text only): `api/v1/import/item` +### Import item (currently: text only): `api/v1/import/item` #### Description Allows users to import new items. asynchronous function. @@ -662,7 +661,7 @@ curl https://127.0.0.1:7000/api/v1/import/item --header "Authorization: iHc1_ChZ -### GET Import item info: `api/v1/get/import/item/` +### GET Import item info: `api/v1/get/import/item/` #### Description From 0a071a5165d204999fd0eac5c9e0c07d2badf2cd Mon Sep 17 00:00:00 2001 From: Terrtia Date: Tue, 6 Aug 2019 11:29:12 +0200 Subject: [PATCH 22/25] chg: [api] 405 response: add url to api endpoint documentation --- doc/README.md | 1 - var/www/Flask_server.py | 7 ++++++- var/www/modules/restApi/Flask_restApi.py | 25 ++++++++++-------------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/doc/README.md b/doc/README.md index 3bce7ee8..76b016e7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,7 +18,6 @@ When submitting data in a POST, PUT or DELETE operation you need to specify in w ~~~~ Content-Type: application/json ~~~~ -Take me to [delete_item_tag](#delete_item_tag) Example: diff --git a/var/www/Flask_server.py b/var/www/Flask_server.py index 6361e94c..3d1b524e 100755 --- a/var/www/Flask_server.py +++ b/var/www/Flask_server.py @@ -294,7 +294,12 @@ def searchbox(): @app.errorhandler(405) def _handle_client_error(e): if request.path.startswith('/api/'): - return Response(json.dumps({"status": "error", "reason": "Method Not Allowed: The method is not allowed for the requested URL"}, indent=2, sort_keys=True), mimetype='application/json'), 405 + res_dict = {"status": "error", "reason": "Method Not Allowed: The method is not allowed for the requested URL"} + anchor_id = request.path[8:] + anchor_id = anchor_id.replace('/', '_') + api_doc_url = 'https://github.com/CIRCL/AIL-framework/tree/master/doc#{}'.format(anchor_id) + res_dict['documentation'] = api_doc_url + return Response(json.dumps(res_dict, indent=2, sort_keys=True), mimetype='application/json'), 405 else: return e diff --git a/var/www/modules/restApi/Flask_restApi.py b/var/www/modules/restApi/Flask_restApi.py index 93de87ee..6ea8dd69 100644 --- a/var/www/modules/restApi/Flask_restApi.py +++ b/var/www/modules/restApi/Flask_restApi.py @@ -35,6 +35,7 @@ r_serv_db = Flask_config.r_serv_db r_serv_onion = Flask_config.r_serv_onion r_serv_metadata = Flask_config.r_serv_metadata + restApi = Blueprint('restApi', __name__, template_folder='templates') # ============ AUTH FUNCTIONS ============ @@ -128,8 +129,6 @@ def authErrors(user_role): # ============ API CORE ============= - - # ============ FUNCTIONS ============ def is_valid_uuid_v4(header_uuid): @@ -167,26 +166,22 @@ def one(): # } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # -@restApi.route("api/v1/get/item", methods=['GET', 'POST']) +@restApi.route("api/v1/get/item", methods=['POST']) @token_required('analyst') def get_item_id(): - if request.method == 'POST': - data = request.get_json() - res = Item.get_item(data) - return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] - else: - return 'description API endpoint' + data = request.get_json() + res = Item.get_item(data) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] @restApi.route("api/v1/get/item/default", methods=['POST']) @token_required('analyst') def get_item_id_basic(): - if request.method == 'POST': - data = request.get_json() - item_id = data.get('id', None) - req_data = {'id': item_id, 'date': True, 'content': True, 'tags': True} - res = Item.get_item(req_data) - return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] + data = request.get_json() + item_id = data.get('id', None) + req_data = {'id': item_id, 'date': True, 'content': True, 'tags': True} + res = Item.get_item(req_data) + return Response(json.dumps(res[0], indent=2, sort_keys=True), mimetype='application/json'), res[1] # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # GET From 32efbfa01935715b220b548d2a5dde6a61367c9d Mon Sep 17 00:00:00 2001 From: Terrtia Date: Tue, 6 Aug 2019 11:43:18 +0200 Subject: [PATCH 23/25] fix: [doc] typo --- .gitignore | 7 +++++++ doc/README.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 28ed8063..fe1b29a7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ var/www/static/ !var/www/static/js/trendingchart.js var/www/templates/header.html var/www/submitted +var/www/server.crt +var/www/server.key # Local config bin/packages/config.cfg @@ -40,6 +42,11 @@ configs/update.cfg update/current_version files +# Helper +bin/helper/gen_cert/rootCA.* +bin/helper/gen_cert/server.* + + # Pystemon archives pystemon/archives diff --git a/doc/README.md b/doc/README.md index 76b016e7..a466c681 100644 --- a/doc/README.md +++ b/doc/README.md @@ -327,7 +327,7 @@ curl https://127.0.0.1:7000/api/v1/get/item --header "Authorization: iHc1_ChZxj1 -### add item tags: `api/v1/add/item/tag` +### Add item tags: `api/v1/add/item/tag` #### Description Add tags to an item. From ffc119fdbda7ff7f85c17351c16b0503613067cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thirion=20Aur=C3=A9lien?= Date: Mon, 12 Aug 2019 10:18:11 +0200 Subject: [PATCH 24/25] fix: [api doc] typo --- doc/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/README.md b/doc/README.md index a466c681..d88d53a7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -546,10 +546,10 @@ Get tag metadata. - *str* - `first_seen` - date: first seen - - *str - YYMMDD* + - *str - YYYYMMDD* - `last_seen` - - date: first seen - - *str - YYMMDD* + - date: lasr seen + - *str - YYYYMMDD* #### Example ``` curl https://127.0.0.1:7000/api/v1/get/tag/metadata --header "Authorization: iHc1_ChZxj1aXmiFiF1mkxxQkzawwriEaZpPqyTQj " -H "Content-Type: application/json" --data @input.json -X POST From d4071b819c5a0a710c2396ca15d136e31d3d73a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thirion=20Aur=C3=A9lien?= Date: Mon, 12 Aug 2019 10:19:07 +0200 Subject: [PATCH 25/25] fix: [api doc] typo --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index d88d53a7..2788f3e7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -548,7 +548,7 @@ Get tag metadata. - date: first seen - *str - YYYYMMDD* - `last_seen` - - date: lasr seen + - date: last seen - *str - YYYYMMDD* #### Example ```