From 1cccefa9916168c0e7ebaf6bc53d8b70c0c0d211 Mon Sep 17 00:00:00 2001 From: jebus Date: Tue, 11 Nov 2025 21:07:25 +1300 Subject: [PATCH 1/4] [Feature] Basic Test Workflow (#186) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/186 Reviewed-by: liambrydon Reviewed-by: Tobias O --- .gitea/workflows/ci.yaml | 116 ++++++++++++++++++ ...750eaca-bc13-4018-81c2-f7f9d94bc435_ds.pkl | Bin 85587 -> 371587 bytes ...50eaca-bc13-4018-81c2-f7f9d94bc435_mod.pkl | Bin 246872 -> 246872 bytes pnpm-lock.yaml | 4 + 4 files changed, 120 insertions(+) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 pnpm-lock.yaml diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 00000000..863c0643 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,116 @@ +name: CI + +on: + pull_request: + branches: + - develop + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ${{ vars.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ vars.POSTGRES_DB }} + ports: + - ${{ vars.POSTGRES_PORT}}:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + #redis: + # image: redis:7 + # ports: + # - 6379:6379 + # options: >- + # --health-cmd "redis-cli ping" + # --health-interval=10s + # --health-timeout=5s + # --health-retries=5 + + env: + RUNNER_TOOL_CACHE: /toolcache + EP_DATA_DIR: /opt/enviPy/ + ALLOWED_HOSTS: 127.0.0.1,localhost + DEBUG: True + LOG_LEVEL: DEBUG + MODEL_BUILDING_ENABLED: True + APPLICABILITY_DOMAIN_ENABLED: True + ENVIFORMER_PRESENT: True + ENVIFORMER_DEVICE: cpu + FLAG_CELERY_PRESENT: False + PLUGINS_ENABLED: True + SERVER_URL: http://localhost:8000 + ADMIN_APPROVAL_REQUIRED: True + REGISTRATION_MANDATORY: True + LOG_DIR: '' + # DB + POSTGRES_SERVICE_NAME: postgres + POSTGRES_DB: ${{ vars.POSTGRES_DB }} + POSTGRES_USER: ${{ vars.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_PORT: 5432 + # SENTRY + SENTRY_ENABLED: False + # MS ENTRA + MS_ENTRA_ENABLED: False + + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install system tools via apt + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client redis-tools openjdk-11-jre-headless + + - name: Setup ssh + run: | + echo "${{ secrets.ENVIPY_CI_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts + eval $(ssh-agent -s) + ssh-add ~/.ssh/id_ed25519 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Setup venv + run: | + uv sync --locked --all-extras --dev + + - name: Wait for services + run: | + until pg_isready -h postgres -U postgres; do sleep 2; done + # until redis-cli -h redis ping; do sleep 2; done + + - name: Run Django migrations + run: | + source .venv/bin/activate + python manage.py migrate --noinput + + - name: Run Django tests + run: | + source .venv/bin/activate + python manage.py test tests --exclude-tag slow diff --git a/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_ds.pkl b/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_ds.pkl index 21cf28d0ff8d83ba9c35368235a1b282901259d1..b8a95a07039cf98307ddfabccc5cdd0af3985689 100644 GIT binary patch literal 371587 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 literal 85587 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 Rzx~&5zx|hAe;)qf{{zye>h}Nu diff --git a/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_mod.pkl b/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_mod.pkl index 1ad990e180daee13e04a7d0e1da0772869f28050..f314724665ea7c4ca4c64d44a979b7ef85200786 100644 GIT binary patch delta 30 jcmcc7!GEKJf5RF^W^+BG=Jky2>ls0qY5RIc=9Py4wQ~zk delta 30 jcmcc7!GEKJf5RF^W-~p*=Jky2>ls0qY5RIc=9Py4wM`36 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..4335a75d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4 @@ +lockfileVersion: 6.0 +specifiers: {} +dependencies: {} +packages: {} From 34589efbde557fcadde64568cd57ba6dc2e22342 Mon Sep 17 00:00:00 2001 From: liambrydon Date: Tue, 11 Nov 2025 22:49:55 +1300 Subject: [PATCH 2/4] [Fix] Mitigate XSS attack vector by cleaning input before it hits our Database (#171) ## Changes - All text input fields are now cleaned with nh3 to remove html tags. We allow certain html tags under `settings.py/ALLOWED_HTML_TAGS` so we can easily update the tags we allow in the future. - All names and descriptions now use the template tag `nh_safe` in all html files. - Usernames and emails are a small exception and are not allowed any html tags Co-authored-by: Liam Brydon <62733830+MyCreativityOutlet@users.noreply.github.com> Co-authored-by: jebus Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/171 Reviewed-by: jebus Co-authored-by: liambrydon Co-committed-by: liambrydon --- envipath/settings.py | 2 + epdb/logic.py | 54 +++-- epdb/models.py | 190 ++++++++++++------ .../{envipytags.py => templatetags.py} | 0 epdb/views.py | 115 ++++++++--- pyproject.toml | 1 + templates/collections/joblog.html | 1 - templates/collections/objects_list.html | 12 +- templates/migration.html | 4 +- templates/migration_detail.html | 2 +- .../modals/collections/new_model_modal.html | 9 +- .../modals/collections/new_pathway_modal.html | 3 +- .../new_prediction_setting_modal.html | 7 +- .../objects/add_pathway_edge_modal.html | 5 +- .../objects/delete_pathway_edge_modal.html | 3 +- .../objects/delete_pathway_node_modal.html | 3 +- .../modals/objects/edit_compound_modal.html | 5 +- .../edit_compound_structure_modal.html | 5 +- .../objects/edit_group_member_modal.html | 5 +- .../modals/objects/edit_model_modal.html | 5 +- templates/modals/objects/edit_node_modal.html | 5 +- .../modals/objects/edit_package_modal.html | 5 +- .../edit_package_permissions_modal.html | 5 +- .../modals/objects/edit_pathway_modal.html | 5 +- .../edit_prediction_setting_modal.html | 2 +- .../modals/objects/edit_reaction_modal.html | 5 +- templates/modals/objects/edit_rule_modal.html | 5 +- templates/modals/objects/edit_user_modal.html | 7 +- .../modals/objects/evaluate_model_modal.html | 5 +- .../objects/generic_copy_object_modal.html | 3 +- .../objects/generic_set_aliases_modal.html | 3 +- .../generic_set_external_reference_modal.html | 3 +- .../objects/generic_set_scenario_modal.html | 3 +- .../objects/manage_api_token_modal.html | 3 +- templates/modals/predict_modal.html | 2 +- templates/objects/composite_rule.html | 6 +- templates/objects/compound.html | 10 +- templates/objects/compound_structure.html | 6 +- templates/objects/edge.html | 12 +- templates/objects/group.html | 10 +- templates/objects/model.html | 11 +- templates/objects/node.html | 6 +- templates/objects/package.html | 2 +- templates/objects/pathway.html | 8 +- templates/objects/reaction.html | 14 +- templates/objects/scenario.html | 4 +- templates/objects/simple_rule.html | 12 +- templates/objects/user.html | 8 +- templates/pathway_playground2.html | 2 +- templates/search.html | 4 +- tests/test_rule_model.py | 2 +- utilities/chem.py | 24 +++ uv.lock | 41 +++- 53 files changed, 444 insertions(+), 230 deletions(-) rename epdb/templatetags/{envipytags.py => templatetags.py} (100%) diff --git a/envipath/settings.py b/envipath/settings.py index 6fdac345..2618b01c 100644 --- a/envipath/settings.py +++ b/envipath/settings.py @@ -92,6 +92,8 @@ TEMPLATES = [ }, ] +ALLOWED_HTML_TAGS = {'b', 'i', 'u', 'br', 'em', 'mark', 'p', 's', 'strong'} + WSGI_APPLICATION = "envipath.wsgi.application" # Database diff --git a/epdb/logic.py b/epdb/logic.py index 0aaebf32..f9e1192a 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -4,6 +4,7 @@ import json from typing import Union, List, Optional, Set, Dict, Any from uuid import UUID +import nh3 from django.contrib.auth import get_user_model from django.db import transaction from django.conf import settings as s @@ -185,6 +186,12 @@ class UserManager(object): def create_user( username, email, password, set_setting=True, add_to_group=True, *args, **kwargs ): + # Clean for potential XSS + clean_username = nh3.clean(username).strip() + clean_email = nh3.clean(email).strip() + if clean_username != username or clean_email != email: + # This will be caught by the try in view.py/register + raise ValueError("Invalid username or password") # avoid circular import :S from .tasks import send_registration_mail @@ -262,8 +269,9 @@ class GroupManager(object): @staticmethod def create_group(current_user, name, description): g = Group() - g.name = name - g.description = description + # Clean for potential XSS + g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() g.owner = current_user g.save() @@ -518,8 +526,13 @@ class PackageManager(object): @transaction.atomic def create_package(current_user, name: str, description: str = None): p = Package() - p.name = name - p.description = description + + # Clean for potential XSS + p.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + + if description is not None and description.strip() != "": + p.description = nh3.clean(description.strip(), tags=s.ALLOWED_HTML_TAGS).strip() + p.save() up = UserPackagePermission() @@ -1094,28 +1107,29 @@ class SettingManager(object): model: EPModel = None, model_threshold: float = None, ): - s = Setting() - s.name = name - s.description = description - s.max_nodes = max_nodes - s.max_depth = max_depth - s.model = model - s.model_threshold = model_threshold + new_s = Setting() + # Clean for potential XSS + new_s.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + new_s.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() + new_s.max_nodes = max_nodes + new_s.max_depth = max_depth + new_s.model = model + new_s.model_threshold = model_threshold - s.save() + new_s.save() if rule_packages is not None: for r in rule_packages: - s.rule_packages.add(r) - s.save() + new_s.rule_packages.add(r) + new_s.save() usp = UserSettingPermission() usp.user = user - usp.setting = s + usp.setting = new_s usp.permission = Permission.ALL[0] usp.save() - return s + return new_s @staticmethod def get_default_setting(user: User): @@ -1542,7 +1556,9 @@ class SPathway(object): if sub.app_domain_assessment is None: if self.prediction_setting.model: if self.prediction_setting.model.app_domain: - app_domain_assessment = self.prediction_setting.model.app_domain.assess(sub.smiles) + app_domain_assessment = self.prediction_setting.model.app_domain.assess( + sub.smiles + ) if self.persist is not None: n = self.snode_persist_lookup[sub] @@ -1574,7 +1590,9 @@ class SPathway(object): app_domain_assessment = None if self.prediction_setting.model: if self.prediction_setting.model.app_domain: - app_domain_assessment = (self.prediction_setting.model.app_domain.assess(c)) + app_domain_assessment = ( + self.prediction_setting.model.app_domain.assess(c) + ) self.smiles_to_node[c] = SNode( c, sub.depth + 1, app_domain_assessment diff --git a/epdb/models.py b/epdb/models.py index 3db8ce0f..4b6d7500 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -11,6 +11,7 @@ from typing import Union, List, Optional, Dict, Tuple, Set, Any from uuid import uuid4 import math import joblib +import nh3 import numpy as np from django.conf import settings as s from django.contrib.auth.models import AbstractUser @@ -28,8 +29,14 @@ from sklearn.metrics import precision_score, recall_score, jaccard_score from sklearn.model_selection import ShuffleSplit from utilities.chem import FormatConverter, ProductSet, PredictionResult, IndigoUtils -from utilities.ml import RuleBasedDataset, ApplicabilityDomainPCA, EnsembleClassifierChain, RelativeReasoning, \ - EnviFormerDataset, Dataset +from utilities.ml import ( + RuleBasedDataset, + ApplicabilityDomainPCA, + EnsembleClassifierChain, + RelativeReasoning, + EnviFormerDataset, + Dataset, +) logger = logging.getLogger(__name__) @@ -803,14 +810,16 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin c = Compound() c.package = package - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Compound {Compound.objects.filter(package=package).count() + 1}" - c.name = name # We have a default here only set the value if it carries some payload if description is not None and description.strip() != "": - c.description = description.strip() + c.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() c.save() @@ -982,11 +991,11 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti raise ValueError("Unpersisted Compound! Persist compound first!") cs = CompoundStructure() + # Clean for potential XSS if name is not None: - cs.name = name - + cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() if description is not None: - cs.description = description + cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() cs.smiles = smiles cs.compound = compound @@ -1188,21 +1197,29 @@ class SimpleAmbitRule(SimpleRule): r = SimpleAmbitRule() r.package = package - if name is None or name.strip() == "": + if name is not None: + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + + if name is None or name == "": name = f"Rule {Rule.objects.filter(package=package).count() + 1}" r.name = name - if description is not None and description.strip() != "": - r.description = description + r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r.smirks = smirks if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != "": - r.reactant_filter_smarts = reactant_filter_smarts + if not FormatConverter.is_valid_smarts(reactant_filter_smarts.strip()): + raise ValueError(f'Reactant Filter SMARTS "{reactant_filter_smarts}" is invalid!') + else: + r.reactant_filter_smarts = reactant_filter_smarts.strip() if product_filter_smarts is not None and product_filter_smarts.strip() != "": - r.product_filter_smarts = product_filter_smarts + if not FormatConverter.is_valid_smarts(product_filter_smarts.strip()): + raise ValueError(f'Product Filter SMARTS "{product_filter_smarts}" is invalid!') + else: + r.product_filter_smarts = product_filter_smarts.strip() r.save() return r @@ -1403,12 +1420,11 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin r = Reaction() r.package = package - + # Clean for potential XSS if name is not None and name.strip() != "": - r.name = name - + r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() if description is not None and name.strip() != "": - r.description = description + r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r.multi_step = multi_step @@ -1716,14 +1732,15 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): ): pw = Pathway() pw.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Pathway {Pathway.objects.filter(package=package).count() + 1}" pw.name = name - if description is not None and description.strip() != "": - pw.description = description + pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() pw.save() try: @@ -2018,11 +2035,16 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin): for node in end_nodes: e.end_nodes.add(node) - if name is None: + # Clean for potential XSS + # Cleaning technically not needed as it is also done in Reaction.create, including it here for consistency + if name is not None: + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Reaction {pathway.package.reactions.count() + 1}" if description is None: description = s.DEFAULT_VALUES["description"] + description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r = Reaction.create( pathway.package, @@ -2344,7 +2366,9 @@ class PackageBasedModel(EPModel): eval_reactions = list( Reaction.objects.filter(package__in=self.eval_packages.all()).distinct() ) - ds = RuleBasedDataset.generate_dataset(eval_reactions, self.applicable_rules, educts_only=True) + ds = RuleBasedDataset.generate_dataset( + eval_reactions, self.applicable_rules, educts_only=True + ) if isinstance(self, RuleBasedRelativeReasoning): X = ds.X(exclude_id_col=False, na_replacement=None).to_numpy() y = ds.y(na_replacement=np.nan).to_numpy() @@ -2542,14 +2566,15 @@ class RuleBasedRelativeReasoning(PackageBasedModel): ): rbrr = RuleBasedRelativeReasoning() rbrr.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"RuleBasedRelativeReasoning {RuleBasedRelativeReasoning.objects.filter(package=package).count() + 1}" rbrr.name = name - if description is not None and description.strip() != "": - rbrr.description = description + rbrr.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if threshold is None or (threshold <= 0 or 1 <= threshold): raise ValueError("Threshold must be a float between 0 and 1.") @@ -2646,14 +2671,15 @@ class MLRelativeReasoning(PackageBasedModel): ): mlrr = MLRelativeReasoning() mlrr.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"MLRelativeReasoning {MLRelativeReasoning.objects.filter(package=package).count() + 1}" mlrr.name = name - if description is not None and description.strip() != "": - mlrr.description = description + mlrr.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if threshold is None or (threshold <= 0 or 1 <= threshold): raise ValueError("Threshold must be a float between 0 and 1.") @@ -2807,7 +2833,9 @@ class ApplicabilityDomain(EnviPathModel): else: smiles.append(structures) - assessment_ds, assessment_prods = ds.classification_dataset(structures, self.model.applicable_rules) + assessment_ds, assessment_prods = ds.classification_dataset( + structures, self.model.applicable_rules + ) # qualified_neighbours_per_rule is a nested dictionary structured as: # { @@ -2823,12 +2851,16 @@ class ApplicabilityDomain(EnviPathModel): qualified_neighbours_per_rule: Dict = {} import polars as pl + # Select only the triggered columns for i, row in enumerate(assessment_ds[:, assessment_ds.triggered()].iter_rows(named=True)): # Find the rules the structure triggers. For each rule, filter the training dataset to rows that also # trigger that rule. - train_trig = {trig_uuid.split("_")[-1]: ds.filter(pl.col(trig_uuid).eq(1)) - for trig_uuid, value in row.items() if value == 1} + train_trig = { + trig_uuid.split("_")[-1]: ds.filter(pl.col(trig_uuid).eq(1)) + for trig_uuid, value in row.items() + if value == 1 + } qualified_neighbours_per_rule[i] = train_trig rule_to_i = {str(r.uuid): i for i, r in enumerate(self.model.applicable_rules)} preds = self.model.combine_products_and_probs( @@ -2848,18 +2880,28 @@ class ApplicabilityDomain(EnviPathModel): # loop through rule indices together with the collected neighbours indices from train dataset for rule_uuid, train_instances in qualified_neighbours_per_rule[i].items(): # compute tanimoto distance for all neighbours and add to dataset - dists = self._compute_distances(assessment_ds[i, assessment_ds.struct_features()].to_numpy()[0], - train_instances[:, train_instances.struct_features()].to_numpy()) + dists = self._compute_distances( + assessment_ds[i, assessment_ds.struct_features()].to_numpy()[0], + train_instances[:, train_instances.struct_features()].to_numpy(), + ) train_instances = train_instances.with_columns(dist=pl.Series(dists)) # sort them in a descending way and take at most `self.num_neighbours` # TODO: Should this be descending? If we want the most similar then we want values close to zero (ascending) - train_instances = train_instances.sort("dist", descending=True)[:self.num_neighbours] + train_instances = train_instances.sort("dist", descending=True)[ + : self.num_neighbours + ] # compute average distance - rule_reliabilities[rule_uuid] = train_instances.select(pl.mean("dist")).fill_nan(0.0).item() + rule_reliabilities[rule_uuid] = ( + train_instances.select(pl.mean("dist")).fill_nan(0.0).item() + ) # for local_compatibility we'll need the datasets for the indices having the highest similarity - local_compatibilities[rule_uuid] = self._compute_compatibility(rule_uuid, train_instances) - neighbours_per_rule[rule_uuid] = list(CompoundStructure.objects.filter(uuid__in=train_instances["structure_id"])) + local_compatibilities[rule_uuid] = self._compute_compatibility( + rule_uuid, train_instances + ) + neighbours_per_rule[rule_uuid] = list( + CompoundStructure.objects.filter(uuid__in=train_instances["structure_id"]) + ) neighbor_probs_per_rule[rule_uuid] = train_instances[f"prob_{rule_uuid}"].to_list() ad_res = { @@ -2933,8 +2975,11 @@ class ApplicabilityDomain(EnviPathModel): def _compute_compatibility(self, rule_idx: int, neighbours: "RuleBasedDataset"): accuracy = 0.0 import polars as pl - obs_pred = neighbours.select(obs=pl.col(f"obs_{rule_idx}").cast(pl.Boolean), - pred=pl.col(f"prob_{rule_idx}") >= self.model.threshold) + + obs_pred = neighbours.select( + obs=pl.col(f"obs_{rule_idx}").cast(pl.Boolean), + pred=pl.col(f"prob_{rule_idx}") >= self.model.threshold, + ) # Compute tp, tn, fp, fn using polars expressions tp = obs_pred.filter((pl.col("obs")) & (pl.col("pred"))).height tn = obs_pred.filter((~pl.col("obs")) & (~pl.col("pred"))).height @@ -2961,14 +3006,15 @@ class EnviFormer(PackageBasedModel): ): mod = EnviFormer() mod.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"EnviFormer {EnviFormer.objects.filter(package=package).count() + 1}" mod.name = name - if description is not None and description.strip() != "": - mod.description = description + mod.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if threshold is None or (threshold <= 0 or 1 <= threshold): raise ValueError("Threshold must be a float between 0 and 1.") @@ -3103,7 +3149,7 @@ class EnviFormer(PackageBasedModel): pred_dict = {} for k, pred in enumerate(predictions): pred_smiles, pred_proba = zip(*pred.items()) - reactant, true_product = test_ds[k, "educts"], test_ds[k, "products"] + reactant, _ = test_ds[k, "educts"], test_ds[k, "products"] pred_dict.setdefault(reactant, {"predict": [], "scores": []}) for smiles, proba in zip(pred_smiles, pred_proba): smiles = set(smiles.split(".")) @@ -3217,8 +3263,9 @@ class EnviFormer(PackageBasedModel): # If there are eval packages perform single generation evaluation on them instead of random splits if self.eval_packages.count() > 0: - ds = EnviFormerDataset.generate_dataset(Reaction.objects.filter( - package__in=self.eval_packages.all()).distinct()) + ds = EnviFormerDataset.generate_dataset( + Reaction.objects.filter(package__in=self.eval_packages.all()).distinct() + ) test_result = self.model.predict_batch(ds.X()) single_gen_result = evaluate_sg(ds, test_result, self.threshold) self.eval_results = self.compute_averages([single_gen_result]) @@ -3236,7 +3283,9 @@ class EnviFormer(PackageBasedModel): train = ds[train_index] test = ds[test_index] start = datetime.now() - model = fine_tune(train.X(), train.y(), s.MODEL_DIR, str(split_id), device=s.ENVIFORMER_DEVICE) + model = fine_tune( + train.X(), train.y(), s.MODEL_DIR, str(split_id), device=s.ENVIFORMER_DEVICE + ) end = datetime.now() logger.debug( f"EnviFormer finetuning took {(end - start).total_seconds():.2f} seconds" @@ -3313,7 +3362,12 @@ class EnviFormer(PackageBasedModel): for pathway in train_pathways: for reaction in pathway.edges: reaction = reaction.edge_label - if any([educt in test_educts for educt in reaction_to_educts[str(reaction.uuid)]]): + if any( + [ + educt in test_educts + for educt in reaction_to_educts[str(reaction.uuid)] + ] + ): overlap += 1 continue train_reactions.append(reaction) @@ -3370,41 +3424,44 @@ class Scenario(EnviPathModel): scenario_type: str, additional_information: List["EnviPyModel"], ): - s = Scenario() - s.package = package - - if name is None or name.strip() == "": + new_s = Scenario() + new_s.package = package + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Scenario {Scenario.objects.filter(package=package).count() + 1}" - - s.name = name + new_s.name = name if description is not None and description.strip() != "": - s.description = description + new_s.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if scenario_date is not None and scenario_date.strip() != "": - s.scenario_date = scenario_date + new_s.scenario_date = nh3.clean(scenario_date).strip() if scenario_type is not None and scenario_type.strip() != "": - s.scenario_type = scenario_type + new_s.scenario_type = scenario_type add_inf = defaultdict(list) for info in additional_information: cls_name = info.__class__.__name__ - ai_data = json.loads(info.model_dump_json()) + # Clean for potential XSS hidden in the additional information fields. + ai_data = json.loads(nh3.clean(info.model_dump_json()).strip()) ai_data["uuid"] = f"{uuid4()}" add_inf[cls_name].append(ai_data) - s.additional_information = add_inf + new_s.additional_information = add_inf - s.save() + new_s.save() - return s + return new_s @transaction.atomic def add_additional_information(self, data: "EnviPyModel"): cls_name = data.__class__.__name__ - ai_data = json.loads(data.model_dump_json()) + # Clean for potential XSS hidden in the additional information fields. + ai_data = json.loads(nh3.clean(data.model_dump_json()).strip()) ai_data["uuid"] = f"{uuid4()}" if cls_name not in self.additional_information: @@ -3439,7 +3496,8 @@ class Scenario(EnviPathModel): new_ais = defaultdict(list) for k, vals in data.items(): for v in vals: - ai_data = json.loads(v.model_dump_json()) + # Clean for potential XSS hidden in the additional information fields. + ai_data = json.loads(nh3.clean(v.model_dump_json()).strip()) if hasattr(v, "uuid"): ai_data["uuid"] = str(v.uuid) else: diff --git a/epdb/templatetags/envipytags.py b/epdb/templatetags/templatetags.py similarity index 100% rename from epdb/templatetags/envipytags.py rename to epdb/templatetags/templatetags.py diff --git a/epdb/views.py b/epdb/views.py index 1a2ce23c..36bb0d6e 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -10,6 +10,7 @@ from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from envipy_additional_information import NAME_MAPPING from oauth2_provider.decorators import protected_resource +import nh3 from utilities.chem import FormatConverter, IndigoUtils from utilities.decorators import package_permission_required @@ -85,7 +86,10 @@ def login(request): from django.contrib.auth import authenticate from django.contrib.auth import login - username = request.POST.get("username") + username = request.POST.get("username").strip() + if username != request.POST.get("username"): + context["message"] = "Login failed!" + return render(request, "static/login.html", context) password = request.POST.get("password") # Get email for username and check if the account is active @@ -670,7 +674,8 @@ def search(request): if request.method == "GET": package_urls = request.GET.getlist("packages") - searchterm = request.GET.get("search") + searchterm = request.GET.get("search").strip() + mode = request.GET.get("mode") # add HTTP_ACCEPT check to differentiate between index and ajax call @@ -771,7 +776,6 @@ def package_models(request, package_uuid): elif request.method == "POST": log_post_params(request) - name = request.POST.get("model-name") description = request.POST.get("model-description") @@ -936,8 +940,14 @@ def package_model(request, package_uuid, model_uuid): else: return HttpResponseBadRequest() else: - name = request.POST.get("model-name", "").strip() - description = request.POST.get("model-description", "").strip() + # TODO: Move cleaning to property updater + name = request.POST.get("model-name") + if name is not None: + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + + description = request.POST.get("model-description") + if description is not None: + description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if any([name, description]): if name: @@ -1039,8 +1049,16 @@ def package(request, package_uuid): else: return HttpResponseBadRequest() + # TODO: Move cleaning to property updater new_package_name = request.POST.get("package-name") + if new_package_name is not None: + new_package_name = nh3.clean(new_package_name, tags=s.ALLOWED_HTML_TAGS).strip() + new_package_description = request.POST.get("package-description") + if new_package_description is not None: + new_package_description = nh3.clean( + new_package_description, tags=s.ALLOWED_HTML_TAGS + ).strip() grantee_url = request.POST.get("grantee") read = request.POST.get("read") == "on" @@ -1149,7 +1167,7 @@ def package_compounds(request, package_uuid): elif request.method == "POST": compound_name = request.POST.get("compound-name") - compound_smiles = request.POST.get("compound-smiles") + compound_smiles = request.POST.get("compound-smiles").strip() compound_description = request.POST.get("compound-description") c = Compound.create(current_package, compound_smiles, compound_name, compound_description) @@ -1202,8 +1220,16 @@ def package_compound(request, package_uuid, compound_uuid): return JsonResponse({"success": current_compound.url}) - new_compound_name = request.POST.get("compound-name", "").strip() - new_compound_description = request.POST.get("compound-description", "").strip() + # TODO: Move cleaning to property updater + new_compound_name = request.POST.get("compound-name") + if new_compound_name is not None: + new_compound_name = nh3.clean(new_compound_name, tags=s.ALLOWED_HTML_TAGS).strip() + + new_compound_description = request.POST.get("compound-description") + if new_compound_description is not None: + new_compound_description = nh3.clean( + new_compound_description, tags=s.ALLOWED_HTML_TAGS + ).strip() if new_compound_name: current_compound.name = new_compound_name @@ -1268,7 +1294,7 @@ def package_compound_structures(request, package_uuid, compound_uuid): elif request.method == "POST": structure_name = request.POST.get("structure-name") - structure_smiles = request.POST.get("structure-smiles") + structure_smiles = request.POST.get("structure-smiles").strip() structure_description = request.POST.get("structure-description") try: @@ -1339,8 +1365,16 @@ def package_compound_structure(request, package_uuid, compound_uuid, structure_u else: return HttpResponseBadRequest() - new_structure_name = request.POST.get("compound-structure-name", "").strip() - new_structure_description = request.POST.get("compound-structure-description", "").strip() + # TODO: Move cleaning to property updater + new_structure_name = request.POST.get("compound-structure-name") + if new_structure_name is not None: + new_structure_name = nh3.clean(new_structure_name, tags=s.ALLOWED_HTML_TAGS).strip() + + new_structure_description = request.POST.get("compound-structure-description") + if new_structure_description is not None: + new_structure_description = nh3.clean( + new_structure_description, tags=s.ALLOWED_HTML_TAGS + ).strip() if new_structure_name: current_structure.name = new_structure_name @@ -1442,11 +1476,11 @@ def package_rules(request, package_uuid): # Obtain parameters as required by rule type if rule_type == "SimpleAmbitRule": - params["smirks"] = request.POST.get("rule-smirks") + params["smirks"] = request.POST.get("rule-smirks").strip() params["reactant_filter_smarts"] = request.POST.get("rule-reactant-smarts") params["product_filter_smarts"] = request.POST.get("rule-product-smarts") elif rule_type == "SimpleRDKitRule": - params["reaction_smarts"] = request.POST.get("rule-reaction-smarts") + params["reaction_smarts"] = request.POST.get("rule-reaction-smarts").strip() elif rule_type == "ParallelRule": pass elif rule_type == "SequentialRule": @@ -1547,8 +1581,14 @@ def package_rule(request, package_uuid, rule_uuid): return JsonResponse({"success": current_rule.url}) - rule_name = request.POST.get("rule-name", "").strip() - rule_description = request.POST.get("rule-description", "").strip() + # TODO: Move cleaning to property updater + rule_name = request.POST.get("rule-name") + if rule_name is not None: + rule_name = nh3.clean(rule_name, tags=s.ALLOWED_HTML_TAGS).strip() + + rule_description = request.POST.get("rule-description") + if rule_description is not None: + rule_description = nh3.clean(rule_description, tags=s.ALLOWED_HTML_TAGS).strip() if rule_name: current_rule.name = rule_name @@ -1637,8 +1677,8 @@ def package_reactions(request, package_uuid): elif request.method == "POST": reaction_name = request.POST.get("reaction-name") reaction_description = request.POST.get("reaction-description") - reactions_smirks = request.POST.get("reaction-smirks") + reactions_smirks = request.POST.get("reaction-smirks").strip() educts = reactions_smirks.split(">>")[0].split(".") products = reactions_smirks.split(">>")[1].split(".") @@ -1699,8 +1739,16 @@ def package_reaction(request, package_uuid, reaction_uuid): return JsonResponse({"success": current_reaction.url}) - new_reaction_name = request.POST.get("reaction-name", "").strip() - new_reaction_description = request.POST.get("reaction-description", "").strip() + # TODO: Move cleaning to property updater + new_reaction_name = request.POST.get("reaction-name") + if new_reaction_name is not None: + new_reaction_name = nh3.clean(new_reaction_name, tags=s.ALLOWED_HTML_TAGS).strip() + + new_reaction_description = request.POST.get("reaction-description") + if new_reaction_description is not None: + new_reaction_description = nh3.clean( + new_reaction_description, tags=s.ALLOWED_HTML_TAGS + ).strip() if new_reaction_name: current_reaction.name = new_reaction_name @@ -1777,8 +1825,9 @@ def package_pathways(request, package_uuid): name = request.POST.get("name") description = request.POST.get("description") - pw_mode = request.POST.get("predict", "predict").strip() + smiles = request.POST.get("smiles", "").strip() + pw_mode = request.POST.get("predict", "predict").strip() if "smiles" in request.POST and smiles == "": return error( @@ -1787,8 +1836,6 @@ def package_pathways(request, package_uuid): "Pathway prediction failed due to missing or empty SMILES", ) - smiles = smiles.strip() - try: stand_smiles = FormatConverter.standardize(smiles) except ValueError: @@ -1947,8 +1994,14 @@ def package_pathway(request, package_uuid, pathway_uuid): return JsonResponse({"success": current_pathway.url}) + # TODO: Move cleaning to property updater pathway_name = request.POST.get("pathway-name") + if pathway_name is not None: + pathway_name = nh3.clean(pathway_name, tags=s.ALLOWED_HTML_TAGS).strip() + pathway_description = request.POST.get("pathway-description") + if pathway_description is not None: + pathway_description = nh3.clean(pathway_description, tags=s.ALLOWED_HTML_TAGS).strip() if any([pathway_name, pathway_description]): if pathway_name is not None and pathway_name.strip() != "": @@ -2036,8 +2089,8 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid): elif request.method == "POST": node_name = request.POST.get("node-name") node_description = request.POST.get("node-description") - node_smiles = request.POST.get("node-smiles") + node_smiles = request.POST.get("node-smiles").strip() current_pathway.add_node(node_smiles, name=node_name, description=node_description) return redirect(current_pathway.url) @@ -2199,9 +2252,9 @@ def package_pathway_edges(request, package_uuid, pathway_uuid): elif request.method == "POST": log_post_params(request) - edge_name = request.POST.get("edge-name") edge_description = request.POST.get("edge-description") + edge_substrates = request.POST.getlist("edge-substrates") edge_products = request.POST.getlist("edge-products") @@ -2288,7 +2341,7 @@ def package_scenarios(request, package_uuid): "all", False ): scens = Scenario.objects.filter(package=current_package).order_by("name") - res = [{"name": s.name, "url": s.url, "uuid": s.uuid} for s in scens] + res = [{"name": s_.name, "url": s_.url, "uuid": s_.uuid} for s_ in scens] return JsonResponse(res, safe=False) context = get_base_context(request) @@ -2336,21 +2389,21 @@ def package_scenarios(request, package_uuid): "name": "soil", "widgets": [ HTMLGenerator.generate_html(ai, prefix=f"soil_{0}") - for ai in [x for s in SOIL_ADDITIONAL_INFORMATION.values() for x in s] + for ai in [x for sv in SOIL_ADDITIONAL_INFORMATION.values() for x in sv] ], }, "Sludge Data": { "name": "sludge", "widgets": [ HTMLGenerator.generate_html(ai, prefix=f"sludge_{0}") - for ai in [x for s in SLUDGE_ADDITIONAL_INFORMATION.values() for x in s] + for ai in [x for sv in SLUDGE_ADDITIONAL_INFORMATION.values() for x in sv] ], }, "Water-Sediment System Data": { "name": "sediment", "widgets": [ HTMLGenerator.generate_html(ai, prefix=f"sediment_{0}") - for ai in [x for s in SEDIMENT_ADDITIONAL_INFORMATION.values() for x in s] + for ai in [x for sv in SEDIMENT_ADDITIONAL_INFORMATION.values() for x in sv] ], }, } @@ -2365,6 +2418,7 @@ def package_scenarios(request, package_uuid): scenario_name = request.POST.get("scenario-name") scenario_description = request.POST.get("scenario-description") + scenario_date_year = request.POST.get("scenario-date-year") scenario_date_month = request.POST.get("scenario-date-month") scenario_date_day = request.POST.get("scenario-date-day") @@ -2378,9 +2432,9 @@ def package_scenarios(request, package_uuid): scenario_type = request.POST.get("scenario-type") additional_information = HTMLGenerator.build_models(request.POST.dict()) - additional_information = [x for s in additional_information.values() for x in s] + additional_information = [x for sv in additional_information.values() for x in sv] - s = Scenario.create( + new_scen = Scenario.create( current_package, name=scenario_name, description=scenario_description, @@ -2389,7 +2443,7 @@ def package_scenarios(request, package_uuid): additional_information=additional_information, ) - return redirect(s.url) + return redirect(new_scen.url) else: return HttpResponseNotAllowed( [ @@ -2689,6 +2743,7 @@ def settings(request): name = request.POST.get("prediction-setting-name") description = request.POST.get("prediction-setting-description") + new_default = request.POST.get("prediction-setting-new-default", "off") == "on" max_nodes = min( diff --git a/pyproject.toml b/pyproject.toml index 26371296..347f1e04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "scikit-learn>=1.6.1", "sentry-sdk[django]>=2.32.0", "setuptools>=80.8.0", + "nh3==0.3.2", "polars==1.35.1", ] diff --git a/templates/collections/joblog.html b/templates/collections/joblog.html index 7075e08e..07e15e71 100644 --- a/templates/collections/joblog.html +++ b/templates/collections/joblog.html @@ -1,6 +1,5 @@ {% extends "framework.html" %} {% load static %} -{% load envipytags %} {% block content %}
diff --git a/templates/collections/objects_list.html b/templates/collections/objects_list.html index bfe98d63..34519ab4 100644 --- a/templates/collections/objects_list.html +++ b/templates/collections/objects_list.html @@ -192,7 +192,7 @@
{% if object_type == 'package' %} {% for obj in reviewed_objects %} - {{ obj.name }} + {{ obj.name|safe }} {{ obj.name }}{# ({{ obj.package.name }}) #} + {{ obj.name|safe }}{# ({{ obj.package.name }}) #}
{% if object_type == 'package' %} {% for obj in unreviewed_objects %} - {{ obj.name }} + {{ obj.name|safe }} {% endfor %} {% else %} {% for obj in unreviewed_objects|slice:":50" %} - {{ obj.name }} + {{ obj.name|safe }} {% endfor %} {% endif %}
@@ -236,9 +236,9 @@ diff --git a/templates/migration.html b/templates/migration.html index ad681b34..ea8da317 100644 --- a/templates/migration.html +++ b/templates/migration.html @@ -26,12 +26,12 @@ {% endif %}

{{ obj.name }} + href="#{{ obj.id }}">{{ obj.name|safe }}

{% endfor %} diff --git a/templates/migration_detail.html b/templates/migration_detail.html index 030dc73e..240ffea8 100644 --- a/templates/migration_detail.html +++ b/templates/migration_detail.html @@ -27,7 +27,7 @@ {% endif %}

{{ obj.name }} + href="#{{ obj.id }}">{{ obj.name|safe }}

diff --git a/templates/modals/collections/new_model_modal.html b/templates/modals/collections/new_model_modal.html index b5e903b6..faea4c17 100644 --- a/templates/modals/collections/new_model_modal.html +++ b/templates/modals/collections/new_model_modal.html @@ -1,3 +1,4 @@ +