Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions source/app/blueprints/case/case_assets_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
from app.util import ac_case_requires
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid

case_assets_blueprint = Blueprint('case_assets',
__name__,
Expand Down Expand Up @@ -203,7 +204,8 @@ def add_asset_modal(caseid):
ioc = get_iocs(caseid)
attributes = get_default_custom_attributes('asset')

return render_template("modal_add_case_multi_asset.html", form=form, asset=None, ioc=ioc, attributes=attributes)
return render_template("modal_add_case_multi_asset.html", form=form, asset=None, ioc=ioc, attributes=attributes,
page_uid=generate_page_uid())


@case_assets_blueprint.route('/case/assets/add', methods=['POST'])
Expand Down Expand Up @@ -390,7 +392,8 @@ def asset_view_modal(cur_id, caseid, url_redir):
comments_map = get_case_assets_comments_count([cur_id])

return render_template("modal_add_case_asset.html", form=form, asset=asset, map={}, ioc=case_iocs,
ioc_prefill=ioc_prefill, attributes=asset.custom_attributes, comments_map=comments_map)
ioc_prefill=ioc_prefill, attributes=asset.custom_attributes, comments_map=comments_map,
page_uid=generate_page_uid())


@case_assets_blueprint.route('/case/assets/update/<int:cur_id>', methods=['POST'])
Expand Down
6 changes: 4 additions & 2 deletions source/app/blueprints/case/case_ioc_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
from app.util import ac_case_requires
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid
from app.business.iocs import create
from app.business.iocs import update
from app.business.iocs import delete
Expand Down Expand Up @@ -241,7 +242,8 @@ def case_add_ioc_modal(caseid):

attributes = get_default_custom_attributes('ioc')

return render_template("modal_add_case_ioc.html", form=form, ioc=Ioc(), attributes=attributes)
return render_template("modal_add_case_ioc.html", form=form, ioc=Ioc(), attributes=attributes,
page_uid=generate_page_uid())


@case_ioc_blueprint.route('/case/ioc/delete/<int:cur_id>', methods=['POST'])
Expand Down Expand Up @@ -277,7 +279,7 @@ def case_view_ioc_modal(cur_id, caseid, url_redir):
comments_map = get_case_iocs_comments_count([cur_id])

return render_template("modal_add_case_ioc.html", form=form, ioc=ioc, attributes=ioc.custom_attributes,
comments_map=comments_map)
comments_map=comments_map, page_uid=generate_page_uid())


@case_ioc_blueprint.route('/case/ioc/<int:cur_id>', methods=['GET'])
Expand Down
6 changes: 4 additions & 2 deletions source/app/blueprints/case/case_rfiles_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from app.util import ac_case_requires
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid

case_rfiles_blueprint = Blueprint(
'case_rfiles',
Expand Down Expand Up @@ -149,14 +150,15 @@ def case_edit_rfile_modal(cur_id, caseid, url_redir):
comments_map = get_case_evidence_comments_count([cur_id])

return render_template("modal_add_case_rfile.html", rfile=crf, attributes=crf.custom_attributes,
comments_map=comments_map)
comments_map=comments_map, page_uid=generate_page_uid())


@case_rfiles_blueprint.route('/case/evidences/add/modal', methods=['GET'])
@ac_api_case_requires(CaseAccessLevel.full_access)
def case_add_rfile_modal(caseid):

return render_template("modal_add_case_rfile.html", rfile=None, attributes=get_default_custom_attributes('evidence'))
return render_template("modal_add_case_rfile.html", rfile=None, attributes=get_default_custom_attributes('evidence'),
page_uid=generate_page_uid())


@case_rfiles_blueprint.route('/case/evidences/update/<int:cur_id>', methods=['POST'])
Expand Down
5 changes: 3 additions & 2 deletions source/app/blueprints/case/case_tasks_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
from app.util import ac_case_requires
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid

case_tasks_blueprint = Blueprint('case_tasks',
__name__,
Expand Down Expand Up @@ -138,7 +139,7 @@ def case_add_task_modal(caseid):
form.task_assignees_id.choices = []

return render_template("modal_add_case_task.html", form=form, task=task, uid=current_user.id, user_name=None,
attributes=task.custom_attributes)
attributes=task.custom_attributes, page_uid=generate_page_uid())


@case_tasks_blueprint.route('/case/tasks/add', methods=['POST'])
Expand Down Expand Up @@ -207,7 +208,7 @@ def case_task_view_modal(cur_id, caseid, url_redir):
comments_map = get_case_tasks_comments_count([task.id])

return render_template("modal_add_case_task.html", form=form, task=task, user_name=user_name,
comments_map=comments_map, attributes=task.custom_attributes)
comments_map=comments_map, attributes=task.custom_attributes, page_uid=generate_page_uid())


@case_tasks_blueprint.route('/case/tasks/update/<int:cur_id>', methods=['POST'])
Expand Down
6 changes: 4 additions & 2 deletions source/app/blueprints/case/case_timeline_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
from app.util import add_obj_history_entry
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid


event_tags = ["Network", "Server", "ActiveDirectory", "Computer", "Malware", "User Interaction"]
Expand Down Expand Up @@ -759,7 +760,8 @@ def event_view_modal(cur_id, caseid, url_redir):
return render_template("modal_add_case_event.html", form=form, event=event, user_name=usr_name, tags=event_tags,
assets=assets, iocs=iocs, comments_map=comments_map,
assets_prefill=assets_prefill, iocs_prefill=iocs_prefill,
category=event.category, attributes=event.custom_attributes)
category=event.category, attributes=event.custom_attributes,
page_uid=generate_page_uid())


@case_timeline_blueprint.route('/case/timeline/events/update/<int:cur_id>', methods=["POST"])
Expand Down Expand Up @@ -837,7 +839,7 @@ def case_add_event_modal(caseid):

return render_template("modal_add_case_event.html", form=form, event=event,
tags=event_tags, assets=assets, iocs=iocs, assets_prefill=None, category=def_cat,
attributes=event.custom_attributes)
attributes=event.custom_attributes, page_uid=generate_page_uid())


@case_timeline_blueprint.route('/case/timeline/filter-help/modal', methods=['GET'])
Expand Down
3 changes: 2 additions & 1 deletion source/app/blueprints/manage/manage_attributes_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from app.util import ac_requires
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid

manage_attributes_blueprint = Blueprint('manage_attributes', __name__, template_folder='templates')

Expand Down Expand Up @@ -104,7 +105,7 @@ def attributes_preview(caseid, url_redir):
except Exception as e:
return response_error("Invalid JSON", data=str(e))

templated = render_template("modal_preview_attribute.html", attributes=attribute)
templated = render_template("modal_preview_attribute.html", attributes=attribute, page_uid=generate_page_uid())

return response_success(data=templated)

Expand Down
5 changes: 3 additions & 2 deletions source/app/blueprints/manage/manage_cases_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
from app.util import ac_requires
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid
from app.business.cases import delete
from app.business.cases import update
from app.business.cases import create
Expand Down Expand Up @@ -124,7 +125,7 @@ def details_case(cur_id: int, caseid: int, url_redir: bool) -> Union[str, Respon
print(res.get('status_name'))
return render_template("modal_case_info_from_case.html", data=res, form=form, protagonists=protagonists,
case_classifications=case_classifications, case_states=case_states, customers=customers,
severities=severities)
severities=severities, page_uid=generate_page_uid())

else:
return response_error("Unknown case")
Expand Down Expand Up @@ -340,7 +341,7 @@ def add_case_modal():

attributes = get_default_custom_attributes('case')

return render_template('modal_add_case.html', form=form, attributes=attributes)
return render_template('modal_add_case.html', form=form, attributes=attributes, page_uid=generate_page_uid())


@manage_cases_blueprint.route('/manage/cases/add', methods=['POST'])
Expand Down
6 changes: 4 additions & 2 deletions source/app/blueprints/manage/manage_customers_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from app.util import page_not_found
from app.util import response_error
from app.util import response_success
from app.util import generate_page_uid

manage_customers_blueprint = Blueprint(
'manage_customers',
Expand Down Expand Up @@ -312,7 +313,7 @@ def view_customer_modal(client_id, caseid, url_redir):
form.customer_sla.data = customer.sla

return render_template("modal_add_customer.html", form=form, customer=customer,
attributes=customer.custom_attributes)
attributes=customer.custom_attributes, page_uid=generate_page_uid())


@manage_customers_blueprint.route('/manage/customers/update/<int:client_id>', methods=['POST'])
Expand Down Expand Up @@ -346,7 +347,8 @@ def add_customers_modal(caseid, url_redir):
return redirect(url_for('manage_customers.manage_customers', cid=caseid))
form = AddCustomerForm()
attributes = get_default_custom_attributes('client')
return render_template("modal_add_customer.html", form=form, customer=None, attributes=attributes)
return render_template("modal_add_customer.html", form=form, customer=None, attributes=attributes,
page_uid=generate_page_uid())


@manage_customers_blueprint.route('/manage/customers/add', methods=['POST'])
Expand Down
13 changes: 13 additions & 0 deletions source/app/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,19 @@ def regenerate_session():
session.modified = True


def generate_page_uid() -> str:
"""Generate a short, DOM-safe, per-render-unique id prefix.

Namespaces the ids `modal_attributes_tabs.html` / `modal_attributes_nav.html` generate for
custom-attribute tabs and fields (see dfir-iris/iris-web#1111). Bootstrap 4's `data-toggle="pill"`
resolves its target pane via a document-global CSS id lookup on the link's `href`, not scoped to
the surrounding `.tab-content` — so every render must get its own unique prefix or two
simultaneously-rendered attribute-tab blocks collide and tab-switching breaks.
"""
return f'pu{uuid.uuid4().hex[:8]}_'



def api_login_required(f):
@wraps(f)
def wrap(*args, **kwargs):
Expand Down
31 changes: 31 additions & 0 deletions tests/iris.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,24 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

from pathlib import Path
import re
import shutil
import time
import requests
from docker_compose import DockerCompose
from rest_api import RestApi
from server_timeout_error import ServerTimeoutError

API_URL = 'http://127.0.0.1:8000'
# Cookie-session routes need this, not API_URL: SESSION_COOKIE_SECURE=True (source/app/configuration.py)
# means Flask's session cookie is never sent back over plain HTTP, only through nginx/HTTPS.
HTTPS_URL = 'https://127.0.0.1'
_API_KEY = 'B8BA5D730210B50F41C06941582D7965D57319D5685440587F98DFDC45A01594'
_IRIS_PATH = Path('..')
_TEST_DATA_PATH = Path('./data')
# Matches IRIS_ADM_USERNAME (default) / IRIS_ADM_PASSWORD in tests/data/basic.env.
_ADMIN_USERNAME = 'administrator'
_ADMIN_PASSWORD = 'MySuperAdminPassword!'


class Iris:
Expand Down Expand Up @@ -102,3 +110,26 @@ def get_cases(self):

def get_cases_filter(self):
return self._api.get('/manage/cases/filter')

def get_authenticated_session(self):
"""Log in with a real cookie session (not the API key) for routes gated by @ac_requires(),
which read session['permissions'] directly and 500 if authenticated via API key only.
Must go through nginx/HTTPS (HTTPS_URL) -- SESSION_COOKIE_SECURE=True means the session
cookie is dropped by the client on a plain-HTTP round trip against API_URL."""
session = requests.Session()
session.verify = False
login_page = session.get(f'{HTTPS_URL}/login').text
csrf_match = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', login_page)
if not csrf_match:
raise ServerTimeoutError('Could not find CSRF token on login page')
session.post(f'{HTTPS_URL}/login', data={
'csrf_token': csrf_match.group(1),
'username': _ADMIN_USERNAME,
'password': _ADMIN_PASSWORD,
})
return session

def get_html(self, session, path, case_id=None):
params = {'cid': case_id} if case_id is not None else None
response = session.get(f'{HTTPS_URL}{path}', params=params)
return response.status_code, response.text
6 changes: 6 additions & 0 deletions tests/rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ def get(self, path, query_parameters=None):
print(f'GET {url} => {response.status_code} {body}')
return body

def get_raw(self, path, query_parameters=None):
url = self._build_url(path)
response = requests.get(url, headers=self._headers, params=query_parameters)
print(f'GET {url} => {response.status_code} ({len(response.text)} bytes, non-JSON)')
return response.status_code, response.text

def post(self, path, payload, query_parameters=None):
url = self._build_url(path)
response = requests.post(url, headers=self._headers, params=query_parameters, json=payload)
Expand Down
51 changes: 50 additions & 1 deletion tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
from unittest import TestCase
import json
import re
from iris import Iris


Expand Down Expand Up @@ -67,4 +69,51 @@ def test_update_case_should_not_require_case_name_issue_358(self):
case = self._subject.create_case()
case_identifier = case['case_id']
response = self._subject.update_case(case_identifier, {'case_tags': 'test,example'})
self.assertEqual('success', response['status'])
self.assertEqual('success', response['status'])

def test_custom_attribute_tabs_have_unique_ids_across_cases(self):
# Look up the 'case' attribute definition dynamically -- do not hardcode its id,
# the seeded attribute_id ordering is an implementation detail, not a contract.
attributes = self._subject._api.get('/manage/attributes/list')['data']
case_attribute = next(a for a in attributes if a['attribute_for'] == 'case')

schema = {
'Investigation Metadata': {
'MITRE ATT&CK Tactic': {'type': 'input_string', 'mandatory': False, 'value': ''},
}
}

# Create the cases BEFORE (re-)applying the attribute definition. New cases are not seeded
# with custom_attributes at creation time (that only happens via the "Add case" modal echoing
# its pre-fetched defaults back in the payload) -- but updating the attribute definition runs
# update_all_attributes(), which retroactively backfills every case whose custom_attributes is
# still None. Creating first and updating second reliably exercises that backfill path.
case_a = self._subject.create_case()
case_b = self._subject.create_case()

response = self._subject._api.post(
f'/manage/attributes/update/{case_attribute["attribute_id"]}',
{'attribute_content': json.dumps(schema), 'partial_overwrite': True, 'complete_overwrite': False}
)
self.assertEqual('success', response['status'])

session = self._subject.get_authenticated_session()
status_a, html_a = self._subject.get_html(session, f'/case/details/{case_a["case_id"]}', case_id=case_a['case_id'])
status_b, html_b = self._subject.get_html(session, f'/case/details/{case_b["case_id"]}', case_id=case_b['case_id'])

self.assertEqual(200, status_a)
self.assertEqual(200, status_b)

# "Info" must still be the default active tab in both renders.
self.assertIn('class="nav-link active show" id="pills-home-tab-nobd"', html_a)
self.assertIn('class="nav-link active show" id="pills-home-tab-nobd"', html_b)

# The generated tab id for the custom attribute pane must differ between two
# independently-rendered cases -- this is false on the unfixed code (both render
# "1_investigation_metadata" verbatim) and true once page_uid is a real per-render value.
id_pattern = re.compile(r'id="(\w*1_investigation_metadata)"')
id_a = id_pattern.search(html_a)
id_b = id_pattern.search(html_b)
self.assertIsNotNone(id_a, 'Investigation Metadata tab pane not found in case A details HTML')
self.assertIsNotNone(id_b, 'Investigation Metadata tab pane not found in case B details HTML')
self.assertNotEqual(id_a.group(1), id_b.group(1))