From a74a81f5d0ef1d254e9ee9ee09ec807f57e1cff8 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sun, 4 Dec 2022 10:18:07 -0800 Subject: [PATCH 01/21] Migrate code from ossf/omega-triage-portal to alpha-omega. Signed-off-by: Michael Scovetta --- omega/triage-portal/.devcontainer/Dockerfile | 30 ++ .../.devcontainer/devcontainer.json | 59 +++ .../.devcontainer/docker-compose.yml | 54 +++ .../.devcontainer/postcreate-initialize.sh | 48 +++ omega/triage-portal/.gitignore | 144 +++++++ omega/triage-portal/.vscode/launch.json | 19 + omega/triage-portal/.vscode/settings.json | 42 ++ omega/triage-portal/.vscode/tasks.json | 93 +++++ omega/triage-portal/LICENSE | 21 + omega/triage-portal/README.md | 18 + omega/triage-portal/src/.env-template | 27 ++ omega/triage-portal/src/.yarnrc | 1 + omega/triage-portal/src/core/__init__.py | 24 ++ omega/triage-portal/src/core/asgi.py | 16 + omega/triage-portal/src/core/settings.py | 216 ++++++++++ omega/triage-portal/src/core/urls.py | 17 + omega/triage-portal/src/core/wsgi.py | 16 + omega/triage-portal/src/manage.py | 22 + omega/triage-portal/src/package.json | 24 ++ omega/triage-portal/src/pyproject.toml | 3 + omega/triage-portal/src/requirements.txt | 66 +++ omega/triage-portal/src/triage/__init__.py | 0 omega/triage-portal/src/triage/apps.py | 61 +++ .../src/triage/management/__init__.py | 0 .../triage/management/commands/__init__.py | 0 .../management/commands/clear_all_findings.py | 22 + .../management/commands/import_toolshed.py | 172 ++++++++ .../src/triage/migrations/0001_initial.py | 186 +++++++++ .../migrations/0002_auto_20211126_0713.py | 22 + .../migrations/0003_auto_20211127_0020.py | 32 ++ .../migrations/0004_tooldefect_assigned_to.py | 21 + .../migrations/0005_tool_friendly_name.py | 18 + ..._definition_remove_filter_name_and_more.py | 47 +++ .../src/triage/migrations/0007_filter_uuid.py | 19 + ..._remove_finding_analyst_impact_and_more.py | 37 ++ ...009_tooldefect_priority_tooldefect_tags.py | 25 ++ .../migrations/0010_tooldefect_description.py | 18 + .../migrations/0011_filter_last_executed.py | 18 + ..._remove_finding_file_path_file_and_more.py | 48 +++ .../migrations/0013_projectversion_files.py | 18 + ..._scan_finding_normalized_title_and_more.py | 48 +++ ...finding_analyst_severity_level_and_more.py | 43 ++ ...0016_rename_finding_tooldefect_findings.py | 18 + ...eated_at_tooldefect_created_by_and_more.py | 117 ++++++ ...0018_alter_tooldefect_managers_and_more.py | 39 ++ .../migrations/0019_case_reported_dt.py | 18 + .../0020_attachment_case_attachments.py | 27 ++ .../triage/migrations/0021_attachment_uuid.py | 19 + ...te_options_wikiarticlerevision_and_more.py | 50 +++ ...nt_wikiarticlerevision_article_and_more.py | 24 ++ ...articlerevision_change_comment_and_more.py | 28 ++ ...0025_alter_wikiarticle_options_and_more.py | 37 ++ .../src/triage/migrations/__init__.py | 0 omega/triage-portal/src/triage/models/File.py | 36 ++ .../src/triage/models/__init__.py | 30 ++ .../src/triage/models/attachment.py | 19 + omega/triage-portal/src/triage/models/base.py | 91 +++++ omega/triage-portal/src/triage/models/case.py | 56 +++ .../triage-portal/src/triage/models/filter.py | 157 ++++++++ .../src/triage/models/finding.py | 188 +++++++++ omega/triage-portal/src/triage/models/note.py | 17 + .../src/triage/models/project.py | 74 ++++ omega/triage-portal/src/triage/models/scan.py | 88 ++++ omega/triage-portal/src/triage/models/tool.py | 41 ++ .../src/triage/models/tool_defect.py | 52 +++ .../triage-portal/src/triage/models/triage.py | 38 ++ omega/triage-portal/src/triage/models/wiki.py | 77 ++++ .../triage/images/icon-nvd.nist.gov.png | Bin 0 -> 1923 bytes .../triage/images/icon-searchcode.com.png | Bin 0 -> 2972 bytes .../triage/images/icon-sourcegraph.com.svg | 1 + .../static/triage/images/icon-vscode.png | Bin 0 -> 114937 bytes .../src/triage/static/triage/images/nvd | Bin 0 -> 4018 bytes .../src/triage/static/triage/omega.js | 300 ++++++++++++++ .../src/triage/templates/triage/base.html | 155 ++++++++ .../triage/templates/triage/case_list.html | 82 ++++ .../templates/triage/case_show copy.html | 63 +++ .../triage/templates/triage/case_show.html | 207 ++++++++++ .../triage/templates/triage/filter_list.html | 86 ++++ .../triage/templates/triage/filter_show.html | 140 +++++++ .../templates/triage/findings_list.html | 109 +++++ .../templates/triage/findings_show copy.html | 175 ++++++++ .../templates/triage/findings_show.html | 375 ++++++++++++++++++ .../templates/triage/findings_upload.html | 36 ++ .../src/triage/templates/triage/home.html | 209 ++++++++++ .../templates/triage/tool_defect_list.html | 77 ++++ .../templates/triage/tool_defect_new.html | 8 + .../templates/triage/tool_defect_show.html | 123 ++++++ .../triage/widgets/icon_for_file.html | 25 ++ .../templates/triage/widgets/notes.html | 13 + .../triage/widgets/project_name_pretty.html | 30 ++ .../triage/templates/triage/wiki_edit.html | 95 +++++ .../triage/templates/triage/wiki_list.html | 70 ++++ .../triage/templates/triage/wiki_show.html | 24 ++ .../src/triage/templatetags/__init__.py | 0 .../src/triage/templatetags/gravatar.py | 21 + .../triage/templatetags/project_helpers.py | 11 + .../src/triage/templatetags/wiki.py | 22 + omega/triage-portal/src/triage/urls.py | 49 +++ .../src/triage/util/azure_blob_storage.py | 225 +++++++++++ .../util/finding_importers/file_importer.py | 73 ++++ .../util/finding_importers/sarif_importer.py | 186 +++++++++ .../triage-portal/src/triage/util/general.py | 63 +++ .../src/triage/util/scim/scim.py | 81 ++++ .../src/triage/util/search_parser.py | 245 ++++++++++++ .../src/triage/util/source_viewer/__init__.py | 130 ++++++ .../util/source_viewer/pathsimilarity.py | 134 +++++++ .../src/triage/util/source_viewer/viewer.py | 96 +++++ .../src/triage/views/attachments.py | 27 ++ omega/triage-portal/src/triage/views/cases.py | 120 ++++++ .../triage-portal/src/triage/views/filters.py | 106 +++++ .../src/triage/views/findings.py | 320 +++++++++++++++ omega/triage-portal/src/triage/views/home.py | 35 ++ .../src/triage/views/tool_defect.py | 115 ++++++ omega/triage-portal/src/triage/views/wiki.py | 151 +++++++ omega/triage-portal/src/yarn.lock | 187 +++++++++ 115 files changed, 7956 insertions(+) create mode 100644 omega/triage-portal/.devcontainer/Dockerfile create mode 100644 omega/triage-portal/.devcontainer/devcontainer.json create mode 100644 omega/triage-portal/.devcontainer/docker-compose.yml create mode 100644 omega/triage-portal/.devcontainer/postcreate-initialize.sh create mode 100644 omega/triage-portal/.gitignore create mode 100644 omega/triage-portal/.vscode/launch.json create mode 100644 omega/triage-portal/.vscode/settings.json create mode 100644 omega/triage-portal/.vscode/tasks.json create mode 100644 omega/triage-portal/LICENSE create mode 100644 omega/triage-portal/README.md create mode 100644 omega/triage-portal/src/.env-template create mode 100644 omega/triage-portal/src/.yarnrc create mode 100644 omega/triage-portal/src/core/__init__.py create mode 100644 omega/triage-portal/src/core/asgi.py create mode 100644 omega/triage-portal/src/core/settings.py create mode 100644 omega/triage-portal/src/core/urls.py create mode 100644 omega/triage-portal/src/core/wsgi.py create mode 100755 omega/triage-portal/src/manage.py create mode 100644 omega/triage-portal/src/package.json create mode 100644 omega/triage-portal/src/pyproject.toml create mode 100644 omega/triage-portal/src/requirements.txt create mode 100644 omega/triage-portal/src/triage/__init__.py create mode 100644 omega/triage-portal/src/triage/apps.py create mode 100644 omega/triage-portal/src/triage/management/__init__.py create mode 100644 omega/triage-portal/src/triage/management/commands/__init__.py create mode 100644 omega/triage-portal/src/triage/management/commands/clear_all_findings.py create mode 100644 omega/triage-portal/src/triage/management/commands/import_toolshed.py create mode 100644 omega/triage-portal/src/triage/migrations/0001_initial.py create mode 100644 omega/triage-portal/src/triage/migrations/0002_auto_20211126_0713.py create mode 100644 omega/triage-portal/src/triage/migrations/0003_auto_20211127_0020.py create mode 100644 omega/triage-portal/src/triage/migrations/0004_tooldefect_assigned_to.py create mode 100644 omega/triage-portal/src/triage/migrations/0005_tool_friendly_name.py create mode 100644 omega/triage-portal/src/triage/migrations/0006_remove_filter_definition_remove_filter_name_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0007_filter_uuid.py create mode 100644 omega/triage-portal/src/triage/migrations/0008_alter_finding_managers_remove_finding_analyst_impact_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0009_tooldefect_priority_tooldefect_tags.py create mode 100644 omega/triage-portal/src/triage/migrations/0010_tooldefect_description.py create mode 100644 omega/triage-portal/src/triage/migrations/0011_filter_last_executed.py create mode 100644 omega/triage-portal/src/triage/migrations/0012_filecontent_remove_finding_file_path_file_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0013_projectversion_files.py create mode 100644 omega/triage-portal/src/triage/migrations/0014_remove_finding_scan_finding_normalized_title_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0015_alter_finding_analyst_severity_level_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0016_rename_finding_tooldefect_findings.py create mode 100644 omega/triage-portal/src/triage/migrations/0017_tooldefect_created_at_tooldefect_created_by_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0018_alter_tooldefect_managers_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0019_case_reported_dt.py create mode 100644 omega/triage-portal/src/triage/migrations/0020_attachment_case_attachments.py create mode 100644 omega/triage-portal/src/triage/migrations/0021_attachment_uuid.py create mode 100644 omega/triage-portal/src/triage/migrations/0022_wikiarticle_alter_note_options_wikiarticlerevision_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0023_rename_parent_wikiarticlerevision_article_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0024_wikiarticlerevision_change_comment_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0025_alter_wikiarticle_options_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/__init__.py create mode 100644 omega/triage-portal/src/triage/models/File.py create mode 100644 omega/triage-portal/src/triage/models/__init__.py create mode 100644 omega/triage-portal/src/triage/models/attachment.py create mode 100644 omega/triage-portal/src/triage/models/base.py create mode 100644 omega/triage-portal/src/triage/models/case.py create mode 100644 omega/triage-portal/src/triage/models/filter.py create mode 100644 omega/triage-portal/src/triage/models/finding.py create mode 100644 omega/triage-portal/src/triage/models/note.py create mode 100644 omega/triage-portal/src/triage/models/project.py create mode 100644 omega/triage-portal/src/triage/models/scan.py create mode 100644 omega/triage-portal/src/triage/models/tool.py create mode 100644 omega/triage-portal/src/triage/models/tool_defect.py create mode 100644 omega/triage-portal/src/triage/models/triage.py create mode 100644 omega/triage-portal/src/triage/models/wiki.py create mode 100644 omega/triage-portal/src/triage/static/triage/images/icon-nvd.nist.gov.png create mode 100644 omega/triage-portal/src/triage/static/triage/images/icon-searchcode.com.png create mode 100644 omega/triage-portal/src/triage/static/triage/images/icon-sourcegraph.com.svg create mode 100644 omega/triage-portal/src/triage/static/triage/images/icon-vscode.png create mode 100644 omega/triage-portal/src/triage/static/triage/images/nvd create mode 100644 omega/triage-portal/src/triage/static/triage/omega.js create mode 100644 omega/triage-portal/src/triage/templates/triage/base.html create mode 100644 omega/triage-portal/src/triage/templates/triage/case_list.html create mode 100644 omega/triage-portal/src/triage/templates/triage/case_show copy.html create mode 100644 omega/triage-portal/src/triage/templates/triage/case_show.html create mode 100644 omega/triage-portal/src/triage/templates/triage/filter_list.html create mode 100644 omega/triage-portal/src/triage/templates/triage/filter_show.html create mode 100644 omega/triage-portal/src/triage/templates/triage/findings_list.html create mode 100644 omega/triage-portal/src/triage/templates/triage/findings_show copy.html create mode 100644 omega/triage-portal/src/triage/templates/triage/findings_show.html create mode 100644 omega/triage-portal/src/triage/templates/triage/findings_upload.html create mode 100644 omega/triage-portal/src/triage/templates/triage/home.html create mode 100644 omega/triage-portal/src/triage/templates/triage/tool_defect_list.html create mode 100644 omega/triage-portal/src/triage/templates/triage/tool_defect_new.html create mode 100644 omega/triage-portal/src/triage/templates/triage/tool_defect_show.html create mode 100644 omega/triage-portal/src/triage/templates/triage/widgets/icon_for_file.html create mode 100644 omega/triage-portal/src/triage/templates/triage/widgets/notes.html create mode 100644 omega/triage-portal/src/triage/templates/triage/widgets/project_name_pretty.html create mode 100644 omega/triage-portal/src/triage/templates/triage/wiki_edit.html create mode 100644 omega/triage-portal/src/triage/templates/triage/wiki_list.html create mode 100644 omega/triage-portal/src/triage/templates/triage/wiki_show.html create mode 100644 omega/triage-portal/src/triage/templatetags/__init__.py create mode 100644 omega/triage-portal/src/triage/templatetags/gravatar.py create mode 100644 omega/triage-portal/src/triage/templatetags/project_helpers.py create mode 100644 omega/triage-portal/src/triage/templatetags/wiki.py create mode 100644 omega/triage-portal/src/triage/urls.py create mode 100644 omega/triage-portal/src/triage/util/azure_blob_storage.py create mode 100644 omega/triage-portal/src/triage/util/finding_importers/file_importer.py create mode 100644 omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py create mode 100644 omega/triage-portal/src/triage/util/general.py create mode 100644 omega/triage-portal/src/triage/util/scim/scim.py create mode 100644 omega/triage-portal/src/triage/util/search_parser.py create mode 100644 omega/triage-portal/src/triage/util/source_viewer/__init__.py create mode 100644 omega/triage-portal/src/triage/util/source_viewer/pathsimilarity.py create mode 100644 omega/triage-portal/src/triage/util/source_viewer/viewer.py create mode 100644 omega/triage-portal/src/triage/views/attachments.py create mode 100644 omega/triage-portal/src/triage/views/cases.py create mode 100644 omega/triage-portal/src/triage/views/filters.py create mode 100644 omega/triage-portal/src/triage/views/findings.py create mode 100644 omega/triage-portal/src/triage/views/home.py create mode 100644 omega/triage-portal/src/triage/views/tool_defect.py create mode 100644 omega/triage-portal/src/triage/views/wiki.py create mode 100644 omega/triage-portal/src/yarn.lock diff --git a/omega/triage-portal/.devcontainer/Dockerfile b/omega/triage-portal/.devcontainer/Dockerfile new file mode 100644 index 00000000..809be5eb --- /dev/null +++ b/omega/triage-portal/.devcontainer/Dockerfile @@ -0,0 +1,30 @@ +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG VARIANT=3-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +ENV PYTHONUNBUFFERED 1 + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# Install OSS Gadget +# License: MIT +ARG OSSGADGET_VERSION="0.1.307" +RUN cd /opt && \ + wget -q https://github.com/microsoft/OSSGadget/releases/download/v${OSSGADGET_VERSION}/OSSGadget_linux_${OSSGADGET_VERSION}.tar.gz -O OSSGadget.tar.gz && \ + tar zxvf OSSGadget.tar.gz && \ + rm OSSGadget.tar.gz && \ + mv OSSGadget_linux_${OSSGADGET_VERSION} OSSGadget + +# [Optional] If your requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + + + diff --git a/omega/triage-portal/.devcontainer/devcontainer.json b/omega/triage-portal/.devcontainer/devcontainer.json new file mode 100644 index 00000000..bd251a71 --- /dev/null +++ b/omega/triage-portal/.devcontainer/devcontainer.json @@ -0,0 +1,59 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.203.0/containers/python-3-postgres +// Update the VARIANT arg in docker-compose.yml to pick a Python version +{ + "name": "Python 3 & PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + // Set *default* container specific settings.json values on container create. + "settings": { + "sqltools.connections": [ + { + "name": "Container database", + "driver": "PostgreSQL", + "previewLimit": 50, + "server": "localhost", + "port": 5432, + "database": "triage", + "username": "triage_user", + "password": "triage_password" + } + ], + "python.pythonPath": "/usr/local/bin/python", + "python.languageServer": "Pylance", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", + "python.testing.pytestPath": "/usr/local/py-utils/bin/pytest", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "mtxr.sqltools", + "mtxr.sqltools-driver-pg", + "GitHub.copilot" + ], + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 8000 + ], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "bash .devcontainer/postcreate-initialize.sh", + "remoteEnv": { + "DJANGO_SETTINGS_MODULE": "core.settings", + "PYTHONPATH": "/workspaces/omega-triage-portal/src" + }, + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/omega/triage-portal/.devcontainer/docker-compose.yml b/omega/triage-portal/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..9e0a600f --- /dev/null +++ b/omega/triage-portal/.devcontainer/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + args: + # Update 'VARIANT' to pick a version of Python: 3, 3.10, 3.9, 3.8, 3.7, 3.6 + # Append -bullseye or -buster to pin to an OS version. + # Use -bullseye variants on local arm64/Apple Silicon. + VARIANT: 3-bullseye + # Optional Node.js version to install + NODE_VERSION: "lts/*" + + volumes: + - ..:/workspace:cached + init: true + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Uncomment the next line to use a non-root user for all processes. + # user: vscode + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: triage_user + POSTGRES_DB: triage + POSTGRES_PASSWORD: triage_password + + redis: + image: redis:latest + restart: unless-stopped + network_mode: service:db + volumes: + - redis-data:/data + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: + redis-data: diff --git a/omega/triage-portal/.devcontainer/postcreate-initialize.sh b/omega/triage-portal/.devcontainer/postcreate-initialize.sh new file mode 100644 index 00000000..984d5837 --- /dev/null +++ b/omega/triage-portal/.devcontainer/postcreate-initialize.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +ROOT="/workspaces/omega-triage-portal" + +# Create and activate the virtual environment +echo "Creating virtual environment." +python -mvenv .venv +source .venv/bin/activate + +# Install Python dependencies +echo "Installing Python (back-end) dependencies." +cd $ROOT/src +python -m pip install --upgrade pip +pip install wheel +pip install -r ./requirements.txt + +# Install JavaScript dependencies +echo "Installing JavaScript (front-end) dependencies." +cd $ROOT/src +npm i -g yarn +yarn + +# Update default environment +echo "Creating development environment." +cd $ROOT/src +cp .env-template .env +SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(64))") +sed -i "s/%RANDOM_STRING%/$SECRET_KEY/" .env +unset SECRET_KEY + +# Create working directories +echo "Creating working directories." +mkdir $ROOT/logs + +# Set up database +echo "Setting up datatbase." +cd $ROOT/src +python manage.py migrate +python manage.py makemigrations +python manage.py migrate triage + +# Create superuser +DJANGO_SUPERUSER_USERNAME="admin" \ +DJANGO_SUPERUSER_PASSWORD="admin" \ +DJANGO_SUPERUSER_EMAIL="nobody@localhost" \ +python manage.py createsuperuser --noinput + +echo "Initialization completed." diff --git a/omega/triage-portal/.gitignore b/omega/triage-portal/.gitignore new file mode 100644 index 00000000..476f27cd --- /dev/null +++ b/omega/triage-portal/.gitignore @@ -0,0 +1,144 @@ +# Derived from https://github.com/github/gitignore/blob/218a941be92679ce67d0484547e3e142b2f5f6f0/Python.gitignore + +# Static Files (brought in via Yarn) +src/triage/static/triage/resources +logs/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ \ No newline at end of file diff --git a/omega/triage-portal/.vscode/launch.json b/omega/triage-portal/.vscode/launch.json new file mode 100644 index 00000000..f260e9a1 --- /dev/null +++ b/omega/triage-portal/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Django", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/src/manage.py", + "args": [ + "runserver", + "0.0.0.0:8000" + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/omega/triage-portal/.vscode/settings.json b/omega/triage-portal/.vscode/settings.json new file mode 100644 index 00000000..23fc804c --- /dev/null +++ b/omega/triage-portal/.vscode/settings.json @@ -0,0 +1,42 @@ +{ + "files.exclude": { + "**/__pycache__": true + }, + "python.formatting.provider": "black", + "python.formatting.blackArgs": [ + "--line-length", + "100", + "--force-exclude", + "\\.html" + ], + "editor.rulers": [ + 100 + ], + "editor.inlineSuggest.enabled": true, + "html.format.enable": false, + "python.terminal.activateEnvInCurrentTerminal": true, + "terminal.integrated.env.linux": { + "DJANGO_SETTINGS_MODULE": "core.settings", + "PYTHONPATH": "${workspaceFolder}/src" + }, + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.linting.enabled": false, + "python.linting.pylintPath": "pylint", + "python.linting.pylintEnabled": false, + "python.linting.pylintArgs": [ + "--init-hook=import sys;sys.path.append('${workspaceFolder}/src')", + "--load-plugins", + "pylint_django", + "--django-settings-module", + "core.settings" + ], + "python.linting.banditEnabled": false, + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, + "editor.bracketPairColorization.enabled": true, + "html.format.templating": true +} \ No newline at end of file diff --git a/omega/triage-portal/.vscode/tasks.json b/omega/triage-portal/.vscode/tasks.json new file mode 100644 index 00000000..2a5953ee --- /dev/null +++ b/omega/triage-portal/.vscode/tasks.json @@ -0,0 +1,93 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "PyLint: Scan entire project", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/.venv/bin/" + }, + "command": "${workspaceFolder}/.venv/bin/pylint", + "args": [ + "--msg-template", + "\"{path}:{line}:{column}:{category}:{symbol} - {msg}\"", + { + "value": "--init-hook=\"import sys;sys.path.append('${workspaceFolder}/src')\"", + "quoting": "strong" + }, + "--load-plugins", + "pylint_django", + "--django-settings-module", + "core.settings", + "--exit-zero", + "${workspaceFolder}/src/triage" + ], + "presentation": { + "reveal": "never", + "panel": "shared" + }, + "problemMatcher": { + "owner": "python", + "fileLocation": [ + "absolute" + ], + "pattern": { + "regexp": "^(.+):(\\d+):(\\d+):(\\w+):(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + } + }, + { + "label": "Django: Make Migrations (Triage)", + "type": "shell", + "command": "${config:python.defaultInterpreterPath}", + "args": [ + "manage.py", + "makemigrations", + "triage" + ], + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/src" + }, + }, + { + "label": "Django: Migrate Database (Triage)", + "type": "shell", + "command": "${config:python.defaultInterpreterPath}", + "args": [ + "manage.py", + "migrate" + ], + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/src" + }, + }, + { + "label": "Redis: Clear Local Cache", + "type": "shell", + "presentation": { + "reveal": "never", + "panel": "shared" + }, + "command": "${config:python.defaultInterpreterPath}", + "args": [ + "manage.py", + "shell", + "-c", + "from django.core.cache import cache; cache.clear()" + ], + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/src" + }, + }, + ] +} \ No newline at end of file diff --git a/omega/triage-portal/LICENSE b/omega/triage-portal/LICENSE new file mode 100644 index 00000000..912e1c2e --- /dev/null +++ b/omega/triage-portal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Open Source Security Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/omega/triage-portal/README.md b/omega/triage-portal/README.md new file mode 100644 index 00000000..9f140fa1 --- /dev/null +++ b/omega/triage-portal/README.md @@ -0,0 +1,18 @@ +# Omega Triage Portal + +The Omega Triage Portal is a web-application that can help manage automated vulnerability reports. +It was designed for scale, (millions of projects, many millions of findings), but may also be +useful at lower scale. + +## Getting Started + +To get started using VS Code, start a [GitHub Codespace](https://github.com/features/codespaces) or +[Remote Container](https://code.visualstudio.com/docs/remote/containers). Once started, run +the `Python: Django` launch task and navigate to http://localhost:8000. + +## Contributing + +See [How to Contribute](#) + +## Security + diff --git a/omega/triage-portal/src/.env-template b/omega/triage-portal/src/.env-template new file mode 100644 index 00000000..8ac1d4e2 --- /dev/null +++ b/omega/triage-portal/src/.env-template @@ -0,0 +1,27 @@ +BASE_URL='https://localhost' + +# Re-generate using `python -c "import secrets; print(secrets.token_hex(64))"` +SECRET_KEY = "%RANDOM_STRING%" + +DEBUG=True + +# Database +DATABASE_ENGINE='django.db.backends.postgresql_psycopg2' +DATABASE_NAME='triage' +DATABASE_USER='triage_user' +DATABASE_PASSWORD='triage_password' +DATABASE_HOST='db' +DATABASE_PORT=5432 + +# Cache +ENABLE_CACHE=True +CACHE_USE_REDIS=True +CACHE_REDIS_CONNECTION="redis://127.0.0.1:6379/1" +CACHE_REDIS_PASSWORD='' + +#APPINSIGHTS_IKEY = '' + +TOOLSHED_BLOB_STORAGE_URL = "" +TOOLSHED_BLOB_STORAGE_CONTAINER = "" + +OSSGADGET_PATH="/opt/OSSGadget" \ No newline at end of file diff --git a/omega/triage-portal/src/.yarnrc b/omega/triage-portal/src/.yarnrc new file mode 100644 index 00000000..6a06e36a --- /dev/null +++ b/omega/triage-portal/src/.yarnrc @@ -0,0 +1 @@ +--modules-folder triage/static/triage/resources diff --git a/omega/triage-portal/src/core/__init__.py b/omega/triage-portal/src/core/__init__.py new file mode 100644 index 00000000..91d1f8c3 --- /dev/null +++ b/omega/triage-portal/src/core/__init__.py @@ -0,0 +1,24 @@ +"""Basic helper functions""" + +import os +from django.core.exceptions import ImproperlyConfigured +import django + +def get_env_variable(var_name, optional=False): + """ + Retrieve an environment variable. Any failures will cause an exception + to be thrown. + """ + try: + return os.environ[var_name] + except KeyError as ex: + if optional: + return False + raise ImproperlyConfigured( + f"Error: You must set the {var_name} environment variable." + ) from ex + + +def to_bool(option: str) -> bool: + """Convert a string to a boolean.""" + return option and option.lower().strip() in ["true", "1"] diff --git a/omega/triage-portal/src/core/asgi.py b/omega/triage-portal/src/core/asgi.py new file mode 100644 index 00000000..9fbf1e38 --- /dev/null +++ b/omega/triage-portal/src/core/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_asgi_application() diff --git a/omega/triage-portal/src/core/settings.py b/omega/triage-portal/src/core/settings.py new file mode 100644 index 00000000..58762faa --- /dev/null +++ b/omega/triage-portal/src/core/settings.py @@ -0,0 +1,216 @@ +import os +from pathlib import Path + +from django.core.exceptions import ImproperlyConfigured + +from core import get_env_variable, to_bool + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Read environment variables from a file +try: + import dotenv + + dotenv.read_dotenv(os.path.join(BASE_DIR, ".env")) +except Exception: + raise ImproperlyConfigured("A .env file was not found. Environment variables are not set.") + +SECRET_KEY = get_env_variable("SECRET_KEY") +DEBUG = to_bool(get_env_variable("DEBUG")) + +INTERNAL_IPS = [ + "127.0.0.1", +] + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "taggit", + "triage", + "debug_toolbar", +] + +MIDDLEWARE = [ + "debug_toolbar.middleware.DebugToolbarMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "core.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "core.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": get_env_variable("DATABASE_ENGINE"), + "NAME": get_env_variable("DATABASE_NAME"), + "USER": get_env_variable("DATABASE_USER"), + "PASSWORD": get_env_variable("DATABASE_PASSWORD"), + "HOST": get_env_variable("DATABASE_HOST"), + "PORT": get_env_variable("DATABASE_PORT"), + "OPTIONS": {"options": "-c statement_timeout=5000"}, + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "America/Los_Angeles" +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +STATIC_URL = "/static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Set up caching +DEFAULT_CACHE_TIMEOUT = 60 * 30 # 30 minutes +CACHES = {} +if to_bool(get_env_variable("ENABLE_CACHE")): + if to_bool(get_env_variable("CACHE_USE_REDIS")): + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": get_env_variable("CACHE_REDIS_CONNECTION"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "TIMEOUT": DEFAULT_CACHE_TIMEOUT, + "PASSWORD": get_env_variable("CACHE_REDIS_PASSWORD"), + }, + } + } + else: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "unique-snowflake", + "TIMEOUT": DEFAULT_CACHE_TIMEOUT, + }, + } + +# Configure application logging +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": u"[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)s] %(message)s", + "datefmt": "%d/%b/%Y %H:%M:%S", + }, + "simple": {"format": u"%(levelname)s %(message)s"}, + }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "verbose", "level": "WARNING"}, + #'appinsights': { + # 'class': 'applicationinsights.django.LoggingHandler', + # 'level': 'INFO', + # }, + "file": { + "level": "DEBUG", + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(BASE_DIR, "..", "logs", "omega-triage.log"), + "maxBytes": 1024 * 1024 * 50, # 50 MB + "backupCount": 5, + "formatter": "verbose", + "encoding": "utf-8", + }, + "database-log": { + "level": "DEBUG", + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(BASE_DIR, "..", "logs", "database.log"), + "maxBytes": 1024 * 1024 * 50, # 50 MB + "backupCount": 1, + "formatter": "verbose", + "encoding": "utf-8", + }, + }, + "loggers": { + "": { + "handlers": ["file", "console"], + "level": "WARNING", + "propagate": True, + }, + "django": { + "level": "WARNING", + "handlers": ["console", "file"], + "propagate": False, + }, + "django.db": { + "level": "DEBUG", + "handlers": ["database-log"], + "propagate": True, + }, + "triage": { + "handlers": ["console", "file"], + "level": "DEBUG", + "propagate": False, + }, + }, +} + +TOOLSHED_BLOB_STORAGE_CONTAINER_SECRET = get_env_variable("TOOLSHED_BLOB_STORAGE_CONTAINER") +TOOLSHED_BLOB_STORAGE_URL_SECRET = get_env_variable("TOOLSHED_BLOB_STORAGE_URL") + +OSSGADGET_PATH = get_env_variable("OSSGADGET_PATH") + +AUTH_USER_MODEL = "auth.User" diff --git a/omega/triage-portal/src/core/urls.py b/omega/triage-portal/src/core/urls.py new file mode 100644 index 00000000..03ada66a --- /dev/null +++ b/omega/triage-portal/src/core/urls.py @@ -0,0 +1,17 @@ +""" +Main URL Configuration +""" +from django.contrib import admin +from django.urls import include, path + +from core.settings import DEBUG + +urlpatterns = [ + path("", include("triage.urls")), +] + +if DEBUG: + urlpatterns += [ + path("admin/", admin.site.urls), + path("__debug__/", include("debug_toolbar.urls")), + ] diff --git a/omega/triage-portal/src/core/wsgi.py b/omega/triage-portal/src/core/wsgi.py new file mode 100644 index 00000000..135b7a61 --- /dev/null +++ b/omega/triage-portal/src/core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_wsgi_application() diff --git a/omega/triage-portal/src/manage.py b/omega/triage-portal/src/manage.py new file mode 100755 index 00000000..f2a662cf --- /dev/null +++ b/omega/triage-portal/src/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/omega/triage-portal/src/package.json b/omega/triage-portal/src/package.json new file mode 100644 index 00000000..6fec9438 --- /dev/null +++ b/omega/triage-portal/src/package.json @@ -0,0 +1,24 @@ +{ + "dependencies": { + "@fortawesome/fontawesome-free": "^5.7.2", + "@popperjs/core": "^2.10.1", + "ace-builds": "^1.4.2", + "add": "^2.0.6", + "blueimp-file-upload": "^10.1.0", + "bootstrap": "^5.1.0", + "datatables.net-bs5": "^1.11.3", + "datatables.net-fixedheader-bs5": "^3.2.0", + "datatables.net-keytable-bs5": "^2.6.4", + "datatables.net-select": "^1.3.0", + "inconsolata": "^0.0.2", + "jquery": "^3.3.1", + "jquery-contextmenu": "^2.8.0", + "js-yaml": "^3.13.0", + "jstree": "^3.3.11", + "jstree-bootstrap-theme": "^1.0.1", + "select2": "^4.1.0-rc.0", + "source-code-pro": "^2.30.2", + "tablesorter": "^2.31.1", + "yarn": "^1.13.0" + } +} diff --git a/omega/triage-portal/src/pyproject.toml b/omega/triage-portal/src/pyproject.toml new file mode 100644 index 00000000..87e846d2 --- /dev/null +++ b/omega/triage-portal/src/pyproject.toml @@ -0,0 +1,3 @@ +[tool.isort] +profile = "black" +known_first_party = ["triage", "core"] diff --git a/omega/triage-portal/src/requirements.txt b/omega/triage-portal/src/requirements.txt new file mode 100644 index 00000000..ce112a4a --- /dev/null +++ b/omega/triage-portal/src/requirements.txt @@ -0,0 +1,66 @@ +asgiref==3.4.1 +astroid==2.9.0 +azure-core==1.21.1 +azure-storage-blob==12.9.0 +bandit==1.7.1 +black==21.11b1 +certifi==2021.10.8 +cffi==1.15.0 +charset-normalizer==2.0.9 +click==8.0.3 +cryptography==36.0.0 +Deprecated==1.2.13 +Django==4.0.1 +django-dotenv==1.4.2 +django-redis==5.1.0 +django-taggit==2.0.0 +dodgy==0.2.1 +flake8==4.0.1 +flake8-polyfill==1.0.2 +gitdb==4.0.9 +GitPython==3.1.24 +idna==3.3 +isodate==0.6.0 +isort==5.10.1 +lazy-object-proxy==1.6.0 +mccabe==0.6.1 +msrest==0.6.21 +mypy-extensions==0.4.3 +oauthlib==3.1.1 +packageurl-python==0.9.6 +pathspec==0.9.0 +pbr==5.8.0 +pep8-naming==0.12.1 +platformdirs==2.4.0 +prospector==0.12.2 +psycopg2==2.9.2 +pycodestyle==2.8.0 +pycparser==2.21 +pydocstyle==6.1.1 +pyflakes==2.4.0 +pylint==2.12.2 +pylint-celery==0.3 +pylint-common==0.2.5 +pylint-django==2.4.4 +pylint-flask==0.6 +pylint-plugin-utils==0.6 +pyparsing==3.0.6 +pytz==2021.3 +PyYAML==6.0 +redis==4.0.2 +regex==2021.11.10 +requests==2.26.0 +requests-oauthlib==1.3.0 +requirements-detector==0.7 +setoptconf==0.3.0 +setoptconf-tmp==0.3.1 +six==1.16.0 +smmap==5.0.0 +snowballstemmer==2.2.0 +sqlparse==0.4.2 +stevedore==3.5.0 +toml==0.10.2 +tomli==1.2.2 +typing_extensions==4.0.1 +urllib3==1.26.7 +wrapt==1.13.3 diff --git a/omega/triage-portal/src/triage/__init__.py b/omega/triage-portal/src/triage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/omega/triage-portal/src/triage/apps.py b/omega/triage-portal/src/triage/apps.py new file mode 100644 index 00000000..d7fce052 --- /dev/null +++ b/omega/triage-portal/src/triage/apps.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""This module configures application-level settings for the Triage Portal.""" + +import logging +import mimetypes + +from django.apps import AppConfig +from django.contrib import admin + +from core.settings import DEBUG + +logger = logging.getLogger(__name__) + + +class TriageConfig(AppConfig): + """ + Application configuration for Omega/Triage . + + This class gets called when Django is initialized, and takes care of + one-time initialization. + """ + + default_auto_field = "django.db.models.BigAutoField" + name = "triage" + verbose_name = "Triage" + + _is_init_completed = False + + def ready(self): + if self._is_init_completed: + return True # Only run once + logger.debug("TriageConfig initializing.") + + if DEBUG: + self._register_models_admin_config() + + mimetypes.init() + + self._is_init_completed = True + + return True + + def _register_models_admin_config(self): + """Registers all Triage models in the Django admin interface.""" + models = self.apps.get_models() + num_registered = 0 + + class TriageModelAdmin(admin.ModelAdmin): + """Custom model admin to show additional fields.""" + + readonly_fields = ("uuid",) + + for model in filter(lambda m: not admin.site.is_registered(m), models): + if model.__module__.startswith("triage.") and hasattr(model, "uuid"): + admin.site.register(model, TriageModelAdmin) + else: + admin.site.register(model, admin.ModelAdmin) + + num_registered += 1 + + logger.debug("Registered %d models to admin module.", num_registered) diff --git a/omega/triage-portal/src/triage/management/__init__.py b/omega/triage-portal/src/triage/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/omega/triage-portal/src/triage/management/commands/__init__.py b/omega/triage-portal/src/triage/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/omega/triage-portal/src/triage/management/commands/clear_all_findings.py b/omega/triage-portal/src/triage/management/commands/clear_all_findings.py new file mode 100644 index 00000000..c72183dc --- /dev/null +++ b/omega/triage-portal/src/triage/management/commands/clear_all_findings.py @@ -0,0 +1,22 @@ +import logging + +from django.core.management.base import BaseCommand + +from triage.models import File, FileContent, Finding, Project, Tool + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Clears all findings from the repository" + + def handle(self, *args, **options): + """Handle the 'clear_all_findings' command.""" + + Finding.objects.all().delete() + Project.objects.all().delete() + Tool.objects.all().delete() + File.objects.all().delete() + FileContent.objects.all().delete() + + print("Operation complete.") diff --git a/omega/triage-portal/src/triage/management/commands/import_toolshed.py b/omega/triage-portal/src/triage/management/commands/import_toolshed.py new file mode 100644 index 00000000..86b704a9 --- /dev/null +++ b/omega/triage-portal/src/triage/management/commands/import_toolshed.py @@ -0,0 +1,172 @@ +import json +import logging +import os +import re +from functools import lru_cache + +from azure.storage.blob import BlobProperties, BlobServiceClient +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from packageurl import PackageURL + +from core.settings import ( + TOOLSHED_BLOB_STORAGE_CONTAINER_SECRET, + TOOLSHED_BLOB_STORAGE_URL_SECRET, +) +from triage.models import ProjectVersion, Scan, Tool +from triage.util.finding_importers.file_importer import FileImporter +from triage.util.finding_importers.sarif_importer import SARIFImporter + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Imports a project from the Toolshed repository" + + def add_arguments(self, parser): + """Assembles arguments to the command.""" + parser.add_argument("--package", required=False, type=str, help="URL (PackageURL format)") + parser.add_argument( + "--import-all", required=False, action="store_true", help="SARIF file to load" + ) + parser.add_argument( + "--maximum", + required=False, + type=int, + help="Maximum number of entries to import", + default=0, + ) + + @classmethod + def prefix_for_package_url(self, package_url_str: str) -> str: + try: + package_url = PackageURL.from_string(package_url_str) + if package_url.namespace: + prefix = f"{package_url.type}/{package_url.namespace}/{package_url.name}/{package_url.version}" + else: + prefix = f"{package_url.type}/{package_url.name}/{package_url.version}" + except ValueError: + prefix = None + + return prefix + + def handle(self, *args, **options): + """Handle the 'import sarif' command.""" + package = options.get("package") + if not package and not options.get("import_all"): + raise ValueError("Must specify either --package or --import-all") + + sarif_importer = SARIFImporter() + + self.initialize_toolshed() + user = get_user_model().objects.get(pk=1) + scan_map = {} + + if package: + prefix = Command.prefix_for_package_url(package) + blobs = self.container.list_blobs(name_starts_with=prefix) + elif options.get("import_all"): + package_url = None + blobs = self.container.list_blobs( + name_starts_with="npm", + ) # TODO: temporary + else: + raise ValueError("Must specify either --package or --import-all") + + num_imported = 0 + for blob in blobs: # type: BlobProperties + print(f"Importing {blob.name}") + num_imported += 1 + if num_imported > options.get("maximum", 0) > 0: + logger.info("Maximum number of entries reached") + break + + package_url = Command.filename_to_package_url(blob.name) + + # match = re.match(r".*/tool-([^\.]+)\.sarif", blob.name, re.IGNORECASE) + # if not match: + # logger.info(f"Skipping {blob.name}") + # continue + # tool_name = match.group(1) + project_version = ProjectVersion.get_or_create_from_package_url(package_url, user) + + # Each tool result is a separate scan + # scan = Scan( + # project_version=project_version, + # tool=Tool.objects.get_or_create(name=tool_name)[0], + # created_by=user, + # updated_by=user, + # ) + # scan.save() + + file_importer = FileImporter() + + if blob.name.endswith(".sarif"): + logger.debug("Importing %s", blob.name) + try: + blob_contents = self.container.download_blob(blob.name).content_as_text() + sarif = json.loads(blob_contents) + SARIFImporter.import_sarif_file(package_url, sarif, user) + FileImporter.import_file( + f"tool/{os.path.basename(blob.name)}", + blob.name, + blob_contents.encode("utf-8", errors="ignore"), + user, + ) + except Exception as msg: + logger.error("Unable to import %s: %s", blob.name, msg, exc_info=True) + continue + + elif "/reference-binaries/" in blob.name: + print(f"Importing {blob.name}") + logger.debug("Importing %s", blob.name) + try: + blob_contents = self.container.download_blob(blob.name).content_as_bytes() + FileImporter.import_file( + f"src/{blob.name}", blob.name, blob_contents, project_version + ) + except Exception as msg: + logger.error("Unable to import %s: %s", blob.name, msg, exc_info=True) + continue + + @classmethod + @lru_cache + def filename_to_package_url(cls, filename): + """Convert a filename to a PackageURL.""" + print(filename) + match = re.match( + r"^(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(reference-binaries|summary-|tool-|admin-).*$", + filename, + ) + print(f"Match={bool(match)}") + if not match: + match = re.match( + r"^(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(reference-binaries|summary-|tool-|admin-).*$", + filename, + ) + if not match: + logger.debug("Unable to parse filename [%s]", filename) + return None + + try: + package_url = PackageURL( + type=match.group("type"), + namespace=match.group("namespace") if "namespace" in match.groupdict() else None, + name=match.group("name"), + version=match.group("version"), + ) + print(package_url) + return package_url + except Exception as msg: + logger.debug("Unable to create PackageURL from %s: %s", filename, msg) + return None + + def initialize_toolshed(self): + if not TOOLSHED_BLOB_STORAGE_URL_SECRET or not TOOLSHED_BLOB_STORAGE_CONTAINER_SECRET: + raise ValueError("TOOLSHED_BLOB_STORAGE_URL and TOOLSHED_BLOB_CONTAINER must be set") + + self.blob_service = BlobServiceClient(TOOLSHED_BLOB_STORAGE_URL_SECRET) + + self.container = self.blob_service.get_container_client( + TOOLSHED_BLOB_STORAGE_CONTAINER_SECRET + ) diff --git a/omega/triage-portal/src/triage/migrations/0001_initial.py b/omega/triage-portal/src/triage/migrations/0001_initial.py new file mode 100644 index 00000000..f1ae4db4 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0001_initial.py @@ -0,0 +1,186 @@ +# Generated by Django 3.2.9 on 2021-11-26 06:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('name', models.CharField(db_index=True, max_length=1024)), + ('package_url', models.CharField(blank=True, db_index=True, max_length=1024, null=True)), + ('metadata', models.JSONField(null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ProjectVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('package_url', models.CharField(blank=True, db_index=True, max_length=1024, null=True)), + ('metadata', models.JSONField(null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='triage.project')), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TriageRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(choices=[('FN', 'On New Finding'), ('FM', 'On Modified Finding')], default='FN', max_length=2)), + ('condition', models.TextField(blank=True, max_length=2048, null=True)), + ('action', models.TextField(blank=True, max_length=2048, null=True)), + ('active', models.BooleanField(db_index=True, default=True)), + ('priority', models.PositiveSmallIntegerField(default=1000)), + ('type', models.CharField(choices=[('PY', 'Python Function')], default='PY', max_length=2)), + ], + ), + migrations.CreateModel( + name='Tool', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('name', models.CharField(max_length=128)), + ('version', models.CharField(blank=True, max_length=64)), + ('type', models.CharField(choices=[('NS', 'Not Specified'), ('MA', 'Manual'), ('SA', 'Static Analysis'), ('OT', 'Other')], default='NS', max_length=2)), + ('active', models.BooleanField(default=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Scan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('artifact_uuid', models.UUIDField(blank=True, editable=False, null=True)), + ('active', models.BooleanField(default=True)), + ('created_dt', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('project_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='triage.projectversion')), + ('tool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='triage.tool')), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Note', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('content', models.TextField()), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Finding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('title', models.CharField(max_length=1024)), + ('file_path', models.CharField(max_length=2048)), + ('file_line', models.PositiveIntegerField(blank=True, null=True)), + ('impact_usage', models.PositiveBigIntegerField(blank=True, null=True)), + ('impact_context', models.PositiveBigIntegerField(blank=True, null=True)), + ('analyst_impact', models.PositiveBigIntegerField(blank=True, null=True)), + ('confidence', models.CharField(choices=[('NS', 'Not Specified'), ('VL', 'Very Low'), ('L', 'Low'), ('M', 'Medium'), ('H', 'High'), ('VH', 'Very High')], default='NS', max_length=2)), + ('severity_level', models.CharField(choices=[('NS', 'Not Specified'), ('NO', 'None'), ('IN', 'Informational'), ('VL', 'Very Low'), ('L', 'Low'), ('M', 'Medium'), ('H', 'High'), ('VH', 'Very High')], default='NS', max_length=2)), + ('analyst_severity_level', models.CharField(choices=[('NS', 'Not Specified'), ('NO', 'None'), ('IN', 'Informational'), ('VL', 'Very Low'), ('L', 'Low'), ('M', 'Medium'), ('H', 'High'), ('VH', 'Very High')], default='NS', max_length=2)), + ('state', models.CharField(choices=[('N', 'New'), ('A', 'Active'), ('R', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], default='N', max_length=2)), + ('assigned_dt', models.DateTimeField(auto_now_add=True)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('scan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='triage.scan')), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Filter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=128)), + ('definition', models.TextField()), + ('active', models.BooleanField(default=True)), + ('priority', models.PositiveSmallIntegerField(default=1000)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='triage.project')), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Case', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('state', models.CharField(choices=[('N', 'New'), ('R', 'Reported'), ('A', 'Active'), ('RS', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], default='N', max_length=2)), + ('title', models.CharField(max_length=1024)), + ('description', models.TextField(blank=True, null=True)), + ('assigned_dt', models.DateTimeField(auto_now_add=True)), + ('reported_to', models.CharField(blank=True, max_length=2048, null=True)), + ('reporting_partner', models.CharField(blank=True, choices=[('N', 'None'), ('GS', 'GitHub Security Lab'), ('CT', 'CERT'), ('MS', 'MSRC'), ('NS', 'Not Specified')], default='NS', max_length=2, null=True)), + ('report_foreign_reference', models.CharField(blank=True, max_length=1024, null=True)), + ('resolved_target_dt', models.DateTimeField(blank=True, null=True)), + ('resolved_dt', models.DateTimeField(blank=True, null=True)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_cases', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('findings', models.ManyToManyField(related_name='cases', to='triage.Finding')), + ('notes', models.ManyToManyField(related_name='cases', to='triage.Note')), + ('updated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0002_auto_20211126_0713.py b/omega/triage-portal/src/triage/migrations/0002_auto_20211126_0713.py new file mode 100644 index 00000000..2127e7be --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0002_auto_20211126_0713.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.9 on 2021-11-26 07:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='scan', + name='artifact_uuid', + ), + migrations.AddField( + model_name='scan', + name='artifact_url_base', + field=models.CharField(blank=True, max_length=1024, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0003_auto_20211127_0020.py b/omega/triage-portal/src/triage/migrations/0003_auto_20211127_0020.py new file mode 100644 index 00000000..805c6b6f --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0003_auto_20211127_0020.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.9 on 2021-11-27 00:20 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0002_auto_20211126_0713'), + ] + + operations = [ + migrations.AlterField( + model_name='case', + name='state', + field=models.CharField(choices=[('N', 'New'), ('A', 'Active'), ('R', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], default='N', max_length=2), + ), + migrations.CreateModel( + name='ToolDefect', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('title', models.CharField(max_length=1024)), + ('state', models.CharField(choices=[('N', 'New'), ('A', 'Active'), ('R', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], default='N', max_length=2)), + ('finding', models.ManyToManyField(to='triage.Finding')), + ('notes', models.ManyToManyField(to='triage.Note')), + ('tool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='triage.tool')), + ], + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0004_tooldefect_assigned_to.py b/omega/triage-portal/src/triage/migrations/0004_tooldefect_assigned_to.py new file mode 100644 index 00000000..9251b693 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0004_tooldefect_assigned_to.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.9 on 2021-11-27 00:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('triage', '0003_auto_20211127_0020'), + ] + + operations = [ + migrations.AddField( + model_name='tooldefect', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0005_tool_friendly_name.py b/omega/triage-portal/src/triage/migrations/0005_tool_friendly_name.py new file mode 100644 index 00000000..f17b0ea3 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0005_tool_friendly_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2021-11-27 01:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0004_tooldefect_assigned_to'), + ] + + operations = [ + migrations.AddField( + model_name='tool', + name='friendly_name', + field=models.CharField(blank=True, max_length=128, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0006_remove_filter_definition_remove_filter_name_and_more.py b/omega/triage-portal/src/triage/migrations/0006_remove_filter_definition_remove_filter_name_and_more.py new file mode 100644 index 00000000..26c5ae80 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0006_remove_filter_definition_remove_filter_name_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.0 on 2021-12-07 22:37 + +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0004_alter_taggeditem_content_type_alter_taggeditem_tag'), + ('triage', '0005_tool_friendly_name'), + ] + + operations = [ + migrations.RemoveField( + model_name='filter', + name='definition', + ), + migrations.RemoveField( + model_name='filter', + name='name', + ), + migrations.RemoveField( + model_name='filter', + name='project', + ), + migrations.AddField( + model_name='filter', + name='action', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='filter', + name='condition', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='filter', + name='title', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='finding', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0007_filter_uuid.py b/omega/triage-portal/src/triage/migrations/0007_filter_uuid.py new file mode 100644 index 00000000..d0b0d8ed --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0007_filter_uuid.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0 on 2021-12-08 02:22 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0006_remove_filter_definition_remove_filter_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='filter', + name='uuid', + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0008_alter_finding_managers_remove_finding_analyst_impact_and_more.py b/omega/triage-portal/src/triage/migrations/0008_alter_finding_managers_remove_finding_analyst_impact_and_more.py new file mode 100644 index 00000000..451e5edc --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0008_alter_finding_managers_remove_finding_analyst_impact_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.0 on 2021-12-12 01:22 + +from django.db import migrations, models +import django.db.models.manager + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0007_filter_uuid'), + ] + + operations = [ + migrations.AlterModelManagers( + name='finding', + managers=[ + ('active_findings', django.db.models.manager.Manager()), + ], + ), + migrations.RemoveField( + model_name='finding', + name='analyst_impact', + ), + migrations.RemoveField( + model_name='finding', + name='impact_context', + ), + migrations.RemoveField( + model_name='finding', + name='impact_usage', + ), + migrations.AddField( + model_name='finding', + name='estimated_impact', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0009_tooldefect_priority_tooldefect_tags.py b/omega/triage-portal/src/triage/migrations/0009_tooldefect_priority_tooldefect_tags.py new file mode 100644 index 00000000..aa3c7753 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0009_tooldefect_priority_tooldefect_tags.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0 on 2021-12-13 21:00 + +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0004_alter_taggeditem_content_type_alter_taggeditem_tag'), + ('triage', '0008_alter_finding_managers_remove_finding_analyst_impact_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='tooldefect', + name='priority', + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name='tooldefect', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0010_tooldefect_description.py b/omega/triage-portal/src/triage/migrations/0010_tooldefect_description.py new file mode 100644 index 00000000..bdb02c25 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0010_tooldefect_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0 on 2021-12-13 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0009_tooldefect_priority_tooldefect_tags'), + ] + + operations = [ + migrations.AddField( + model_name='tooldefect', + name='description', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0011_filter_last_executed.py b/omega/triage-portal/src/triage/migrations/0011_filter_last_executed.py new file mode 100644 index 00000000..dc788156 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0011_filter_last_executed.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0 on 2021-12-19 05:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0010_tooldefect_description'), + ] + + operations = [ + migrations.AddField( + model_name='filter', + name='last_executed', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0012_filecontent_remove_finding_file_path_file_and_more.py b/omega/triage-portal/src/triage/migrations/0012_filecontent_remove_finding_file_path_file_and_more.py new file mode 100644 index 00000000..bc69d624 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0012_filecontent_remove_finding_file_path_file_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.0 on 2021-12-23 06:23 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0011_filter_last_executed'), + ] + + operations = [ + migrations.CreateModel( + name='FileContent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hash', models.BinaryField(db_index=True, max_length=32)), + ('content_type', models.CharField(blank=True, db_index=True, max_length=64, null=True)), + ('data', models.BinaryField(blank=True, null=True)), + ], + ), + migrations.RemoveField( + model_name='finding', + name='file_path', + ), + migrations.CreateModel( + name='File', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('name', models.CharField(db_index=True, max_length=512)), + ('path', models.CharField(db_index=True, max_length=4096)), + ('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='triage.filecontent')), + ], + ), + migrations.AddField( + model_name='finding', + name='file', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='triage.file'), + ), + migrations.AddField( + model_name='scan', + name='files', + field=models.ManyToManyField(blank=True, to='triage.File'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0013_projectversion_files.py b/omega/triage-portal/src/triage/migrations/0013_projectversion_files.py new file mode 100644 index 00000000..c1fb4d87 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0013_projectversion_files.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0 on 2021-12-23 07:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0012_filecontent_remove_finding_file_path_file_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='projectversion', + name='files', + field=models.ManyToManyField(blank=True, to='triage.File'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0014_remove_finding_scan_finding_normalized_title_and_more.py b/omega/triage-portal/src/triage/migrations/0014_remove_finding_scan_finding_normalized_title_and_more.py new file mode 100644 index 00000000..28c426e9 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0014_remove_finding_scan_finding_normalized_title_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.0 on 2021-12-23 23:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0013_projectversion_files'), + ] + + operations = [ + migrations.RemoveField( + model_name='finding', + name='scan', + ), + migrations.AddField( + model_name='finding', + name='normalized_title', + field=models.CharField(blank=True, max_length=1024, null=True), + ), + migrations.AddField( + model_name='finding', + name='project_version', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='triage.projectversion'), + ), + migrations.AddField( + model_name='finding', + name='tool', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='triage.tool'), + ), + migrations.AlterField( + model_name='file', + name='content', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='triage.filecontent'), + ), + migrations.AlterField( + model_name='projectversion', + name='files', + field=models.ManyToManyField(blank=True, editable=False, to='triage.File'), + ), + migrations.AlterField( + model_name='tool', + name='version', + field=models.CharField(blank=True, max_length=64, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0015_alter_finding_analyst_severity_level_and_more.py b/omega/triage-portal/src/triage/migrations/0015_alter_finding_analyst_severity_level_and_more.py new file mode 100644 index 00000000..983b3a2e --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0015_alter_finding_analyst_severity_level_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.0 on 2021-12-24 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0014_remove_finding_scan_finding_normalized_title_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='finding', + name='analyst_severity_level', + field=models.CharField(choices=[('NS', 'Not Specified'), ('NO', 'None'), ('IN', 'Informational'), ('VL', 'Very Low'), ('L', 'Low'), ('M', 'Medium'), ('H', 'High'), ('VH', 'Very High')], db_index=True, default='NS', max_length=2), + ), + migrations.AlterField( + model_name='finding', + name='normalized_title', + field=models.CharField(blank=True, db_index=True, max_length=1024, null=True), + ), + migrations.AlterField( + model_name='finding', + name='severity_level', + field=models.CharField(choices=[('NS', 'Not Specified'), ('NO', 'None'), ('IN', 'Informational'), ('VL', 'Very Low'), ('L', 'Low'), ('M', 'Medium'), ('H', 'High'), ('VH', 'Very High')], db_index=True, default='NS', max_length=2), + ), + migrations.AlterField( + model_name='finding', + name='state', + field=models.CharField(choices=[('N', 'New'), ('A', 'Active'), ('R', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], db_index=True, default='N', max_length=2), + ), + migrations.AlterField( + model_name='finding', + name='title', + field=models.CharField(db_index=True, max_length=1024), + ), + migrations.AlterField( + model_name='projectversion', + name='files', + field=models.ManyToManyField(blank=True, to='triage.File'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0016_rename_finding_tooldefect_findings.py b/omega/triage-portal/src/triage/migrations/0016_rename_finding_tooldefect_findings.py new file mode 100644 index 00000000..c55c3216 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0016_rename_finding_tooldefect_findings.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0 on 2021-12-25 03:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0015_alter_finding_analyst_severity_level_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='tooldefect', + old_name='finding', + new_name='findings', + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0017_tooldefect_created_at_tooldefect_created_by_and_more.py b/omega/triage-portal/src/triage/migrations/0017_tooldefect_created_at_tooldefect_created_by_and_more.py new file mode 100644 index 00000000..70c3277b --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0017_tooldefect_created_at_tooldefect_created_by_and_more.py @@ -0,0 +1,117 @@ +# Generated by Django 4.0 on 2021-12-25 18:36 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('triage', '0016_rename_finding_tooldefect_findings'), + ] + + operations = [ + migrations.AddField( + model_name='tooldefect', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='tooldefect', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AddField( + model_name='tooldefect', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='tooldefect', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='case', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='case', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='filter', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='filter', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='finding', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='finding', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='note', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='note', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='project', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='project', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='projectversion', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='projectversion', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='scan', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='scan', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='tool', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + migrations.AlterField( + model_name='tool', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0018_alter_tooldefect_managers_and_more.py b/omega/triage-portal/src/triage/migrations/0018_alter_tooldefect_managers_and_more.py new file mode 100644 index 00000000..5b91984e --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0018_alter_tooldefect_managers_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.0 on 2021-12-31 06:49 + +from django.db import migrations, models +import django.db.models.manager + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0017_tooldefect_created_at_tooldefect_created_by_and_more'), + ] + + operations = [ + migrations.AlterModelManagers( + name='tooldefect', + managers=[ + ('active_tool_defects', django.db.models.manager.Manager()), + ], + ), + migrations.RenameField( + model_name='case', + old_name='report_foreign_reference', + new_name='reporting_reference', + ), + migrations.RenameField( + model_name='case', + old_name='resolved_dt', + new_name='resolved_actual_dt', + ), + migrations.RemoveField( + model_name='case', + name='assigned_dt', + ), + migrations.AlterField( + model_name='case', + name='reporting_partner', + field=models.CharField(blank=True, choices=[('N', 'None'), ('GS', 'GitHub Security Lab'), ('CT', 'CERT'), ('MS', 'Microsoft Security Response Center'), ('NS', 'Not Specified')], default='NS', max_length=2, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0019_case_reported_dt.py b/omega/triage-portal/src/triage/migrations/0019_case_reported_dt.py new file mode 100644 index 00000000..dac70a07 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0019_case_reported_dt.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0 on 2022-01-01 05:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0018_alter_tooldefect_managers_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='case', + name='reported_dt', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0020_attachment_case_attachments.py b/omega/triage-portal/src/triage/migrations/0020_attachment_case_attachments.py new file mode 100644 index 00000000..9aa4e5fb --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0020_attachment_case_attachments.py @@ -0,0 +1,27 @@ +# Generated by Django 4.0 on 2022-01-01 06:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0019_case_reported_dt'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('filename', models.CharField(max_length=1024)), + ('content', models.BinaryField()), + ('content_type', models.CharField(max_length=255)), + ], + ), + migrations.AddField( + model_name='case', + name='attachments', + field=models.ManyToManyField(related_name='cases', to='triage.Attachment'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0021_attachment_uuid.py b/omega/triage-portal/src/triage/migrations/0021_attachment_uuid.py new file mode 100644 index 00000000..e4715926 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0021_attachment_uuid.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0 on 2022-01-01 08:14 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0020_attachment_case_attachments'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='uuid', + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0022_wikiarticle_alter_note_options_wikiarticlerevision_and_more.py b/omega/triage-portal/src/triage/migrations/0022_wikiarticle_alter_note_options_wikiarticlerevision_and_more.py new file mode 100644 index 00000000..592f693f --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0022_wikiarticle_alter_note_options_wikiarticlerevision_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.0 on 2022-01-01 23:58 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('triage', '0021_attachment_uuid'), + ] + + operations = [ + migrations.CreateModel( + name='WikiArticle', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('slug', models.SlugField(max_length=1024)), + ], + ), + migrations.AlterModelOptions( + name='note', + options={'ordering': ['-created_at']}, + ), + migrations.CreateModel( + name='WikiArticleRevision', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=1024)), + ('content', models.TextField(blank=True, null=True)), + ('state', models.CharField(choices=[('N', 'New'), ('A', 'Active'), ('R', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], default='N', max_length=2)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user')), + ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='triage.wikiarticle')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='auth.user')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='wikiarticle', + name='current', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='triage.wikiarticlerevision'), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0023_rename_parent_wikiarticlerevision_article_and_more.py b/omega/triage-portal/src/triage/migrations/0023_rename_parent_wikiarticlerevision_article_and_more.py new file mode 100644 index 00000000..5e820e28 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0023_rename_parent_wikiarticlerevision_article_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0 on 2022-01-02 04:18 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0022_wikiarticle_alter_note_options_wikiarticlerevision_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='wikiarticlerevision', + old_name='parent', + new_name='article', + ), + migrations.AddField( + model_name='wikiarticlerevision', + name='uuid', + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0024_wikiarticlerevision_change_comment_and_more.py b/omega/triage-portal/src/triage/migrations/0024_wikiarticlerevision_change_comment_and_more.py new file mode 100644 index 00000000..9f8e2158 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0024_wikiarticlerevision_change_comment_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.0 on 2022-01-02 04:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0023_rename_parent_wikiarticlerevision_article_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='wikiarticlerevision', + name='change_comment', + field=models.CharField(blank=True, max_length=512, null=True), + ), + migrations.AlterField( + model_name='wikiarticle', + name='slug', + field=models.SlugField(unique=True), + ), + migrations.AlterField( + model_name='wikiarticlerevision', + name='state', + field=models.CharField(choices=[('N', 'New'), ('A', 'Active'), ('R', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], default='A', max_length=2), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0025_alter_wikiarticle_options_and_more.py b/omega/triage-portal/src/triage/migrations/0025_alter_wikiarticle_options_and_more.py new file mode 100644 index 00000000..555f171b --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0025_alter_wikiarticle_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.0 on 2022-01-03 19:31 + +from django.db import migrations, models +import django.db.models.manager + + +class Migration(migrations.Migration): + + dependencies = [ + ('triage', '0024_wikiarticlerevision_change_comment_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='wikiarticle', + options={'ordering': ['slug']}, + ), + migrations.AlterModelOptions( + name='wikiarticlerevision', + options={'ordering': ['-created_at']}, + ), + migrations.AlterModelManagers( + name='wikiarticle', + managers=[ + ('active_wiki_articles', django.db.models.manager.Manager()), + ], + ), + migrations.RemoveField( + model_name='wikiarticlerevision', + name='state', + ), + migrations.AddField( + model_name='wikiarticle', + name='state', + field=models.CharField(choices=[('N', 'New'), ('A', 'Active'), ('R', 'Resolved'), ('D', 'Deleted'), ('CL', 'Closed'), ('NS', 'Not Specified')], default='A', max_length=2), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/__init__.py b/omega/triage-portal/src/triage/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/omega/triage-portal/src/triage/models/File.py b/omega/triage-portal/src/triage/models/File.py new file mode 100644 index 00000000..482563d8 --- /dev/null +++ b/omega/triage-portal/src/triage/models/File.py @@ -0,0 +1,36 @@ +import base64 +import logging +import uuid + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core import settings +from triage.models import BaseTimestampedModel, BaseUserTrackedModel, WorkItemState + +logger = logging.getLogger(__name__) + + +class File(models.Model): + """ + Represents a file that is associated with an analyzed project. + """ + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + name = models.CharField(max_length=512, db_index=True) + path = models.CharField(max_length=4096, db_index=True) + content = models.ForeignKey("FileContent", on_delete=models.CASCADE, editable=False) + + def __str__(self): + return self.path + + +class FileContent(models.Model): + """Represents file content.""" + + hash = models.BinaryField(max_length=32, db_index=True) + content_type = models.CharField(max_length=64, db_index=True, null=True, blank=True) + data = models.BinaryField(null=True, blank=True) + + def __str__(self): + return base64.b64encode(self.hash).decode("ascii") diff --git a/omega/triage-portal/src/triage/models/__init__.py b/omega/triage-portal/src/triage/models/__init__.py new file mode 100644 index 00000000..96c33759 --- /dev/null +++ b/omega/triage-portal/src/triage/models/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +""" +This file is required so that individual modules can be referenced from files within +this directory. +""" + +from triage.models.attachment import Attachment + +# import triage.models.project +# import triage.models.tool_defect +from triage.models.base import BaseTimestampedModel, BaseUserTrackedModel, WorkItemState +from triage.models.case import Case +from triage.models.File import File, FileContent +from triage.models.filter import Filter +from triage.models.finding import Finding +from triage.models.note import Note +from triage.models.project import Project, ProjectVersion +from triage.models.scan import Scan +from triage.models.tool import Tool +from triage.models.tool_defect import ToolDefect +from triage.models.triage import TriageRule +from triage.models.wiki import WikiArticle, WikiArticleRevision + +# class File(models.Model): +# uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) +# scan = models.ForeignKey(Scan, on_delete=models.CASCADE)# + +# content = models.FileField(upload_to="file_archive") +# content_hash = models.CharField(max_length=128, db_index=True) +# path = models.CharField(max_length=4096) diff --git a/omega/triage-portal/src/triage/models/attachment.py b/omega/triage-portal/src/triage/models/attachment.py new file mode 100644 index 00000000..961e821a --- /dev/null +++ b/omega/triage-portal/src/triage/models/attachment.py @@ -0,0 +1,19 @@ +import logging +import uuid + +from django.db import models + +logger = logging.getLogger(__name__) + + +class Attachment(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + filename = models.CharField(max_length=1024) + content = models.BinaryField() + content_type = models.CharField(max_length=255) + + def __str__(self): + return self.filename + + def get_absolute_url(self): + return f"/attachment/{self.uuid}" diff --git a/omega/triage-portal/src/triage/models/base.py b/omega/triage-portal/src/triage/models/base.py new file mode 100644 index 00000000..16c80644 --- /dev/null +++ b/omega/triage-portal/src/triage/models/base.py @@ -0,0 +1,91 @@ +from django.contrib.auth.models import User +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core import settings + + +class BaseTimestampedModel(models.Model): + """A mixin that adds a created/updated date field to a model.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class BaseUserTrackedModel(models.Model): + """A mixin that adds a created/updated by field to a model.""" + + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+", null=True, blank=True + ) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+", null=True, blank=True + ) + + class Meta: + abstract = True + + +class WorkItemState(models.TextChoices): + """ + This class contains work item state fields, which can be used as choice values + in other models. + """ + + NEW = "N", _("New") + ACTIVE = "A", _("Active") + RESOLVED = "R", _("Resolved") + DELETED = "D", _("Deleted") + CLOSED = "CL", _("Closed") + NOT_SPECIFIED = "NS", _("Not Specified") + + @classmethod + def parse(cls, state: str, strict: bool = False) -> "WorkItemState": + """Convert a string into a WorkItemState. + + Args: + state (str): The string to parse. + strict (bool): If True, then only parse strict equality (case-insensitive) values. + + Returns: + WorkItemState: The WorkItemState corresponding to the string. + If the string is not a valid WorkItemState, returns WorkItemState.NOT_SPECIFIED. + + If strict is False (default), then this method maps related strings to a close + approximation, so "very high" and "critical" are both mapped to "VERY_HIGH", etc. + """ + if state is None or not isinstance(state, str): + return cls.NOT_SPECIFIED + state = state.lower().strip() + if strict: + if state == "new": + return cls.NEW + if state == "active": + return cls.ACTIVE + if state == "resolved": + return cls.RESOLVED + if state == "deleted": + return cls.DELETED + if state == "closed": + return cls.CLOSED + if state == "not specified": + return cls.NOT_SPECIFIED + if state == "none": + return cls.NOT_SPECIFIED + else: + if state in ["new", "n"]: + return cls.NEW + if state in ["active", "a"]: + return cls.ACTIVE + if state in ["resolved", "r"]: + return cls.RESOLVED + if state in ["deleted", "d"]: + return cls.DELETED + if state in ["closed", "c", "cl"]: + return cls.CLOSED + if state in ["not specified", "ns", "none"]: + return cls.NOT_SPECIFIED + return cls.NOT_SPECIFIED diff --git a/omega/triage-portal/src/triage/models/case.py b/omega/triage-portal/src/triage/models/case.py new file mode 100644 index 00000000..2049dd22 --- /dev/null +++ b/omega/triage-portal/src/triage/models/case.py @@ -0,0 +1,56 @@ +import logging +import uuid + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core import settings +from triage.models import BaseTimestampedModel, BaseUserTrackedModel, WorkItemState + +logger = logging.getLogger(__name__) + + +class Case(BaseTimestampedModel, BaseUserTrackedModel): + """ + Represents a case that is being reported to a maintainer for a fix. + """ + + class CasePartner(models.TextChoices): + NONE = "N", _("None") + GITHUB_SECURITY_LAB = "GS", _("GitHub Security Lab") + CERT = "CT", _("CERT") + MSRC = "MS", _("Microsoft Security Response Center") + NOT_SPECIFIED = "NS", _("Not Specified") + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + findings = models.ManyToManyField("Finding", related_name="cases") + state = models.CharField(max_length=2, choices=WorkItemState.choices, default=WorkItemState.NEW) + title = models.CharField(max_length=1024) + description = models.TextField(null=True, blank=True) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="assigned_cases", + on_delete=models.SET_NULL, + ) + reported_to = models.CharField(max_length=2048, null=True, blank=True) + reported_dt = models.DateTimeField(null=True, blank=True) + reporting_partner = models.CharField( + max_length=2, + choices=CasePartner.choices, + default=CasePartner.NOT_SPECIFIED, + null=True, + blank=True, + ) + reporting_reference = models.CharField(max_length=1024, null=True, blank=True) + resolved_target_dt = models.DateTimeField(null=True, blank=True) + resolved_actual_dt = models.DateTimeField(null=True, blank=True) + notes = models.ManyToManyField("Note", related_name="cases") + attachments = models.ManyToManyField("Attachment", related_name="cases") + + def __str__(self): + return self.title + + def get_absolute_url(self): + return f"/case/{self.uuid}" diff --git a/omega/triage-portal/src/triage/models/filter.py b/omega/triage-portal/src/triage/models/filter.py new file mode 100644 index 00000000..037ff125 --- /dev/null +++ b/omega/triage-portal/src/triage/models/filter.py @@ -0,0 +1,157 @@ +import datetime +import logging +import uuid +from typing import Any + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from wrapt import synchronized + +from triage.models import BaseTimestampedModel, BaseUserTrackedModel + +logger = logging.getLogger(__name__) + + +class Filter(BaseTimestampedModel, BaseUserTrackedModel): + """ + Represents a filter that is automatically applied to a Finding. + + The purpose of this is to allow users to improve the quality of findings in a + programmatic way. + + All filters are run automatically when a Finding is created or modified. + If a filter is modified, it will be re-run on all Findings. + """ + + class FilterEvent(models.TextChoices): + ON_FINDING_NEW = "FN", _("On New Finding") + ON_FINDING_MODIFIED = "FM", _("On Modified Finding") + + class RuleType(models.TextChoices): + PYTHON_FUNCTION = "PY", _("Python Function") + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + title = models.CharField(max_length=255, null=True, blank=True) + condition = models.TextField(null=True, blank=True) + action = models.TextField(null=True, blank=True) + active = models.BooleanField(default=True) + priority = models.PositiveSmallIntegerField(default=1000) + last_executed = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return self.title + + def clean(self): + """ + Validate that the condition and action are valid Python code. + """ + if not Filter.get_filter_function(self.condition, "condition"): + raise ValidationError(_("Condition is not valid Python code.")) + + if not Filter.get_filter_function(self.action, "action"): + raise ValidationError(_("Action is not valid Python code.")) + + if not self.title: + raise ValidationError(_("Title is required.")) + + if self.priority < 0 or self.priority > 1000: + raise ValidationError(_("Priority must be between 0 and 1000.")) + + @classmethod + def get_filter_function(cls, function_body: str, type: str) -> Any | None: + """ + Creates a dynamic function with the body provided, of the type provided. + In addition to sanity checking, this function also ensures that the function + is "safe" using the is_safe_function call. + + Args: + function_body: The body of the function to create. + type: The type of function to create, either "condition" or "action". + + Returns: + The function created, or None if the function body is invalid. + """ + if not function_body or not type: + return None + + try: + function_str = ( + f"def {type}(finding):\n" + + "\n return_value = None\n" + + "\n from triage.models import Finding\n" + + "\n".join([" " + line for line in function_body.splitlines()]) + + "\n return return_value" + ) + logger.debug(function_str) + if Filter.is_safe_function(function_str): + return compile(function_str, type, "exec") + else: + raise Exception("Function is not safe.") + except Exception as msg: + logger.warning("Invalid %s function: %s", type, msg) + return None + + @classmethod + def is_safe_function(self, code): + """ + Helper method to check if a function is safe, meaning whether it uses unsafe + functionality, like exec, eval, imports, or other potentially dangerous strings. + + Since dynamic function creation is inherently dangerous, we don't expect this + to be fool-proof, but it should be good enough for most cases. + + TODO: We need to actually implement this function. + """ + import ast + + body = ast.parse(code) + return True + + @classmethod + def execute_all(cls): + """ + Execute all filters. + """ + for filter in Filter.objects.filter(active=True).order_by("priority"): + filter.execute() + + @synchronized + def execute(self): + """ + Execute the filter's condition and action. + """ + if not self.active: + logger.info("Filter #%d is not active.", self.pk) + return + + from triage.models import Finding + + try: + condition_bytecode = Filter.get_filter_function(self.condition, "condition") + exec(condition_bytecode, globals()) + + action_bytecode = Filter.get_filter_function(self.action, "action") + exec(action_bytecode, globals()) + + for finding in Finding.active_findings.all(): + try: + if condition(finding): # type: ignore (dynamic variable) + logger.debug( + "Executing filter action %s on finding %s", self.title, finding + ) + action(finding) # type: ignore (dynamic valiable) + except Exception as msg: + logger.error( + "Error executing filter action %s on finding %s: %s", + self.title, + finding, + msg, + ) + except Exception as msg: + logger.exception("Error executing filter %s: %s", self.title, msg) + + self.last_executed = timezone.now() + self.save() diff --git a/omega/triage-portal/src/triage/models/finding.py b/omega/triage-portal/src/triage/models/finding.py new file mode 100644 index 00000000..19f6f916 --- /dev/null +++ b/omega/triage-portal/src/triage/models/finding.py @@ -0,0 +1,188 @@ +import logging +import os +import uuid + +from django.contrib.auth.models import User +from django.db import models +from django.utils.translation import gettext_lazy as _ +from taggit.managers import TaggableManager + +from triage.models import BaseTimestampedModel, BaseUserTrackedModel, WorkItemState + +logger = logging.getLogger(__name__) + + +class ActiveFindingsManager(models.Manager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + state__in=[WorkItemState.NEW, WorkItemState.ACTIVE, WorkItemState.NOT_SPECIFIED] + ) + ) + + +class Finding(BaseTimestampedModel, BaseUserTrackedModel): + class ConfidenceLevel(models.TextChoices): + NOT_SPECIFIED = "NS", _("Not Specified") + VERY_LOW = "VL", _("Very Low") + LOW = "L", _("Low") + MEDIUM = "M", _("Medium") + HIGH = "H", _("High") + VERY_HIGH = "VH", _("Very High") + + class SeverityLevel(models.TextChoices): + NOT_SPECIFIED = "NS", _("Not Specified") + NONE = "NO", _("None") + INFORMATIONAL = "IN", _("Informational") + VERY_LOW = "VL", _("Very Low") + LOW = "L", _("Low") + MEDIUM = "M", _("Medium") + HIGH = "H", _("High") + VERY_HIGH = "VH", _("Very High") + + @classmethod + def parse(cls, severity: str, strict: bool = False) -> "Finding.SeverityLevel": + """Convert a string into a SeverityLevel. + + Args: + severity (str): The string to parse. + strict (bool): If True, then only parse strict equality (case-insensitive) values. + + Returns: + SeverityLevel: The SeverityLevel corresponding to the string. + If the string is not a valid SeverityLevel, returns SeverityLevel.NOT_SPECIFIED. + + If strict is False (default), then this method maps related strings to a close + approximation, so "very high" and "critical" are both mapped to "VERY_HIGH", etc. + """ + if severity is None or not isinstance(severity, str): + return cls.NOT_SPECIFIED + severity = severity.lower().strip() + if strict: + if severity == "very_high": + return cls.VERY_HIGH + if severity == "high": + return cls.HIGH + if severity == "medium": + return cls.MEDIUM + if severity == "low": + return cls.LOW + if severity == "very_low": + return cls.VERY_LOW + if severity == "informational": + return cls.INFORMATIONAL + if severity == "none": + return cls.NONE + else: + if severity in ["critical", "fatal", "very high", "very_high", "veryhigh", "vh"]: + return cls.VERY_HIGH + if severity in ["important", "error", "high", "h"]: + return cls.HIGH + if severity in ["moderate", "warn", "warning", "medium", "m"]: + return cls.MEDIUM + if severity in ["low", "l"]: + return cls.LOW + if severity in ["defense-in-depth", "verylow", "very_low", "very low"]: + return cls.VERY_LOW + if severity in ["info", "informational"]: + return cls.INFORMATIONAL + if severity in ["fp", "false positive", "none"]: + return cls.NONE + return cls.NOT_SPECIFIED + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + project_version = models.ForeignKey( + "ProjectVersion", on_delete=models.CASCADE, null=True, blank=True + ) + + title = models.CharField(max_length=1024, db_index=True) + normalized_title = models.CharField(max_length=1024, null=True, blank=True, db_index=True) + + file = models.ForeignKey("File", null=True, blank=True, on_delete=models.SET_NULL) + file_line = models.PositiveIntegerField(null=True, blank=True) + + # Impact showing how important a finding is to the larger community. + # The larger the number, the higher the impact. Used for sorting. + estimated_impact = models.PositiveIntegerField(null=True, blank=True) + + # Confidence showing how certain a finding is. + confidence = models.CharField( + max_length=2, choices=ConfidenceLevel.choices, default=ConfidenceLevel.NOT_SPECIFIED + ) + + # Severity showing how important a finding is to the security quality of a project. + severity_level = models.CharField( + max_length=2, + choices=SeverityLevel.choices, + default=SeverityLevel.NOT_SPECIFIED, + db_index=True, + ) + analyst_severity_level = models.CharField( + max_length=2, + choices=SeverityLevel.choices, + default=SeverityLevel.NOT_SPECIFIED, + db_index=True, + ) + + state = models.CharField( + max_length=2, choices=WorkItemState.choices, default=WorkItemState.NEW, db_index=True + ) + + tool = models.ForeignKey( + "Tool", null=True, blank=True, on_delete=models.SET_NULL, db_index=True + ) + + # Who the finding is currently assigned to + assigned_to = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + assigned_dt = models.DateTimeField(auto_now_add=True) + + tags = TaggableManager() + + active_findings = ActiveFindingsManager() + objects = models.Manager() + + def __str__(self): + return f"{self.normalized_title} in {self.file.name}:{self.file_line}" + + def get_absolute_url(self): + return f"/finding/{self.uuid}" + + @property + def get_filename_display(self): + """Render the filename or a placeholder where one does not exist.""" + if self.file: + return self.file.name or "-" + else: + return "-" + + @property + def get_calculated_severity(self): + """Gets the best severity level (analyst estimate taking precedence)""" + if self.analyst_severity_level == Finding.SeverityLevel.NOT_SPECIFIED: + return self.severity_level + return self.analyst_severity_level + + @property + def get_severity_display(self): + """Gets the best severity level (analyst estimate taking precedence)""" + if self.analyst_severity_level == Finding.SeverityLevel.NOT_SPECIFIED: + return self.get_severity_level_display() + return self.get_analyst_severity_level_display() + + @property + def get_impact_display(self): + """Gets the best impact level (analyst estimate taking precedence)""" + if self.estimated_impact is not None: + return self.estimated_impact + else: + return None + + def get_source_code(self): + """Retrieve source code pertaining to this finding.""" + if self.file: + return self.file.content + + logger.debug("No source code available (file is empty)") + return None diff --git a/omega/triage-portal/src/triage/models/note.py b/omega/triage-portal/src/triage/models/note.py new file mode 100644 index 00000000..e177c44e --- /dev/null +++ b/omega/triage-portal/src/triage/models/note.py @@ -0,0 +1,17 @@ +import logging + +from django.db import models + +from triage.models import BaseTimestampedModel, BaseUserTrackedModel + +logger = logging.getLogger(__name__) + + +class Note(BaseTimestampedModel, BaseUserTrackedModel): + content = models.TextField() + + def __str__(self): + return self.content + + class Meta: + ordering = ["-created_at"] diff --git a/omega/triage-portal/src/triage/models/project.py b/omega/triage-portal/src/triage/models/project.py new file mode 100644 index 00000000..e4280595 --- /dev/null +++ b/omega/triage-portal/src/triage/models/project.py @@ -0,0 +1,74 @@ +import logging +import uuid + +from django.contrib.auth.models import User +from django.db import models +from django.utils.translation import gettext_lazy as _ +from packageurl import PackageURL + +from triage.models import BaseTimestampedModel, BaseUserTrackedModel +from triage.models.base import BaseTimestampedModel, BaseUserTrackedModel, WorkItemState +from triage.util.azure_blob_storage import ( + AzureBlobStorageAccessor, + ToolshedBlobStorageAccessor, +) +from triage.util.general import modify_purl + +logger = logging.getLogger(__name__) + + +class Project(BaseTimestampedModel, BaseUserTrackedModel): + """An abstract project undergoing analysis.""" + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + name = models.CharField(max_length=1024, db_index=True) + package_url = models.CharField(max_length=1024, null=True, blank=True, db_index=True) + metadata = models.JSONField(null=True) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return f"/projects/{self.uuid}" + + +class ProjectVersion(BaseTimestampedModel, BaseUserTrackedModel): + """A version of a project.""" + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + project = models.ForeignKey("Project", on_delete=models.CASCADE) + package_url = models.CharField(max_length=1024, null=True, blank=True, db_index=True) + files = models.ManyToManyField("File", blank=True, editable=True) + metadata = models.JSONField(null=True) + + def __str__(self): + return self.package_url + + def get_absolute_url(self): + return f"/projects/{self.project.uuid}/{self.uuid}" + + @classmethod + def get_or_create_from_package_url( + cls, package_url: PackageURL, created_by: User + ) -> "ProjectVersion": + """Retrieves or create a PackageVersion from the given package_url.""" + if package_url is None: + raise ValueError("'package_url' cannot be None.") + + package_url_no_version = modify_purl(package_url, version=None) + + if package_url.namespace: + package_name = f"{package_url.namespace}/{package_url.name}" + else: + package_name = package_url.name + + project, _ = Project.objects.get_or_create( + package_url=str(package_url_no_version), + defaults={"name": package_name, "created_by": created_by, "updated_by": created_by}, + ) + project_version, _ = cls.objects.get_or_create( + project=project, + package_url=package_url, + defaults={"created_by": created_by, "updated_by": created_by}, + ) + return project_version diff --git a/omega/triage-portal/src/triage/models/scan.py b/omega/triage-portal/src/triage/models/scan.py new file mode 100644 index 00000000..20f9b0d2 --- /dev/null +++ b/omega/triage-portal/src/triage/models/scan.py @@ -0,0 +1,88 @@ +import logging +import uuid +from typing import Optional + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from packageurl import PackageURL + +from triage.models import BaseTimestampedModel, BaseUserTrackedModel +from triage.util.azure_blob_storage import ( + AzureBlobStorageAccessor, + ToolshedBlobStorageAccessor, +) +from triage.util.general import modify_purl +from triage.util.source_viewer.viewer import SourceViewer + +logger = logging.getLogger(__name__) + + +class Scan(BaseTimestampedModel, BaseUserTrackedModel): + """A scan of a project version.""" + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + project_version = models.ForeignKey("ProjectVersion", on_delete=models.CASCADE) + tool = models.ForeignKey("Tool", on_delete=models.CASCADE) + + artifact_url_base = models.CharField(max_length=1024, null=True, blank=True) + active = models.BooleanField(default=True) + + files = models.ManyToManyField("File", blank=True) + created_dt = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"An execution of {self.tool} against {self.project_version}" + + def get_absolute_url(self): + return f"/scan/{self.uuid}" + + def get_file_content(self, filename: str) -> Optional[str]: + if filename is None: + return None + + accessor = ToolshedBlobStorageAccessor(self) + for _filename in accessor.get_all_files(): + print(f"Comparing {_filename} to {filename}") + if _filename == filename: + return accessor.get_file_content(_filename) + return None + + def get_source_files(self) -> list: + """Retreives the source code for this scan.""" + accessor = ToolshedBlobStorageAccessor(self) + return accessor.get_source_files() + + def get_source_code(self, filename: str) -> Optional[str]: + """Retreives the source code for a file.""" + + viewer = SourceViewer(self.project_version.package_url) + res = viewer.get_file(filename) + if res: + return res.get("content") + return None + + def get_file_list(self) -> list: + """Retreives a list of files in the scan.""" + viewer = SourceViewer(self.project_version.package_url) + return viewer.get_file_list() + + def get_blob_list(self) -> list: + """Retreives a list of blobs in the scan.""" + purl = PackageURL.from_string(self.project_version.package_url) + if purl.namespace: + prefix = f"{purl.type}/{purl.namespace}/{purl.name}/{purl.version}" + else: + prefix = f"{purl.type}/{purl.name}/{purl.version}" + + accessor = AzureBlobStorageAccessor(prefix) + return accessor.get_blob_list() + + def get_file_contents(self, filename) -> Optional[str]: + """Retreives the contents of a file.""" + accessor = ToolshedBlobStorageAccessor(self) + return accessor.get_file_contents(filename) + + def get_package_contents(self, filename) -> Optional[str]: + """Retreives the contents of a file.""" + accessor = ToolshedBlobStorageAccessor(self) + return accessor.get_package_contents(filename) diff --git a/omega/triage-portal/src/triage/models/tool.py b/omega/triage-portal/src/triage/models/tool.py new file mode 100644 index 00000000..681c0699 --- /dev/null +++ b/omega/triage-portal/src/triage/models/tool.py @@ -0,0 +1,41 @@ +import logging +import uuid + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from triage.models.base import BaseTimestampedModel, BaseUserTrackedModel, WorkItemState + +logger = logging.getLogger(__name__) + + +class Tool(BaseTimestampedModel, BaseUserTrackedModel): + """A tool used to create a finding.""" + + class ToolType(models.TextChoices): + NOT_SPECIFIED = "NS", _("Not Specified") + MANUAL = "MA", _("Manual") + STATIC_ANALYSIS = "SA", _("Static Analysis") + OTHER = "OT", _("Other") + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + name = models.CharField(max_length=128) + friendly_name = models.CharField(max_length=128, blank=True, null=True) + version = models.CharField(max_length=64, null=True, blank=True) + type = models.CharField(max_length=2, choices=ToolType.choices, default=ToolType.NOT_SPECIFIED) + active = models.BooleanField(default=True) + + def __str__(self) -> str: + parts = [] + if self.friendly_name: + parts.append(self.friendly_name) + else: + parts.append(self.name) + if self.version: + parts.append(self.version) + return " ".join(parts) + + def save(self, *args, **kwargs) -> None: + if self.friendly_name is None: + self.friendly_name = self.name + super().save(*args, **kwargs) diff --git a/omega/triage-portal/src/triage/models/tool_defect.py b/omega/triage-portal/src/triage/models/tool_defect.py new file mode 100644 index 00000000..e7d9c368 --- /dev/null +++ b/omega/triage-portal/src/triage/models/tool_defect.py @@ -0,0 +1,52 @@ +import logging +import uuid + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.db import models +from django.utils.translation import gettext_lazy as _ +from taggit.managers import TaggableManager + +from triage.models import ( + BaseTimestampedModel, + BaseUserTrackedModel, + Note, + Tool, + WorkItemState, +) + +logger = logging.getLogger(__name__) + + +class ActiveToolDefectsManager(models.Manager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + state__in=[WorkItemState.NEW, WorkItemState.ACTIVE, WorkItemState.NOT_SPECIFIED] + ) + ) + + +class ToolDefect(BaseTimestampedModel, BaseUserTrackedModel): + """ + A tool defect is a defect that is filed against a tool. + """ + + tool = models.ForeignKey(Tool, on_delete=models.CASCADE) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + title = models.CharField(max_length=1024) + description = models.TextField(null=True, blank=True) + findings = models.ManyToManyField("Finding") + state = models.CharField(choices=WorkItemState.choices, max_length=2, default=WorkItemState.NEW) + assigned_to = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + priority = models.PositiveSmallIntegerField(default=0) + notes = models.ManyToManyField(Note) + tags = TaggableManager() + + active_tool_defects = ActiveToolDefectsManager() + objects = models.Manager() + + def __str__(self): + return self.title diff --git a/omega/triage-portal/src/triage/models/triage.py b/omega/triage-portal/src/triage/models/triage.py new file mode 100644 index 00000000..528c0288 --- /dev/null +++ b/omega/triage-portal/src/triage/models/triage.py @@ -0,0 +1,38 @@ +import logging + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from triage.util.general import modify_purl + +logger = logging.getLogger(__name__) + + +class TriageRule(models.Model): + """ + ??? + def applies(finding) -> bool + def action(finding) -> void + + if applies(f): action(f) + """ + + class TriageEvent(models.TextChoices): + ON_FINDING_NEW = "FN", _("On New Finding") + ON_FINDING_MODIFIED = "FM", _("On Modified Finding") + + class RuleType(models.TextChoices): + PYTHON_FUNCTION = "PY", _("Python Function") + + event = models.CharField( + max_length=2, choices=TriageEvent.choices, default=TriageEvent.ON_FINDING_NEW + ) + condition = models.TextField(max_length=2048, null=True, blank=True) + action = models.TextField(max_length=2048, null=True, blank=True) + + active = models.BooleanField(db_index=True, default=True) + priority = models.PositiveSmallIntegerField(default=1000) + + type = models.CharField( + max_length=2, choices=RuleType.choices, default=RuleType.PYTHON_FUNCTION + ) diff --git a/omega/triage-portal/src/triage/models/wiki.py b/omega/triage-portal/src/triage/models/wiki.py new file mode 100644 index 00000000..775ac6b5 --- /dev/null +++ b/omega/triage-portal/src/triage/models/wiki.py @@ -0,0 +1,77 @@ +import logging +import uuid + +from django.db import models + +from triage.models import BaseTimestampedModel, BaseUserTrackedModel, WorkItemState + +logger = logging.getLogger(__name__) + + +class WikiArticleRevision(BaseTimestampedModel, BaseUserTrackedModel): + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + article = models.ForeignKey("WikiArticle", on_delete=models.CASCADE, related_name="revisions") + title = models.CharField(max_length=1024) + content = models.TextField(null=True, blank=True) + change_comment = models.CharField(max_length=512, null=True, blank=True) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.article.current = self + self.article.save() + + def __str__(self): + return self.title + + def get_absolute_url(self): + return f"/wiki/{self.article.slug}/{self.uuid}" + + def get_absolute_edit_url(self): + return f"/wiki/{self.article.slug}/{self.uuid}/edit" + + class Meta: + ordering = ["-created_at"] + + +class ActiveWikiArticleManager(models.Manager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + state__in=[WorkItemState.NEW, WorkItemState.ACTIVE, WorkItemState.NOT_SPECIFIED] + ) + ) + + +class WikiArticle(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + slug = models.SlugField(unique=True) + state = models.CharField( + max_length=2, choices=WorkItemState.choices, default=WorkItemState.ACTIVE + ) + current = models.ForeignKey( + WikiArticleRevision, on_delete=models.CASCADE, null=True, blank=True + ) + + active_wiki_articles = ActiveWikiArticleManager() + objects = models.Manager() + + def __str__(self): + if self.current: + return self.current.title + else: + return "(No article)" + + def get_absolute_url(self): + return f"/wiki/{self.slug}" + + def get_absolute_edit_url(self): + return f"/wiki/{self.slug}/edit" + + @property + def versions(self): + return WikiArticleRevision.objects.filter(article=self).order_by("-created_at") + + class Meta: + ordering = ["slug"] diff --git a/omega/triage-portal/src/triage/static/triage/images/icon-nvd.nist.gov.png b/omega/triage-portal/src/triage/static/triage/images/icon-nvd.nist.gov.png new file mode 100644 index 0000000000000000000000000000000000000000..de118d1b277e65d57796e06dc3358ea36c1406a9 GIT binary patch literal 1923 zcmV-}2YmR6P)*~72O`A4T5Cl$i?=1TG;{VgoNN;AbShy%LQElPl z1KcjYzrUZyYiek}d~1!@cT4sd^pyZFJuLGIqOm(ek?tUo_mPFo9~Ra>|x zz|xcl*wE-H-q+Wcrn!-9zSJ^20%YEXjcN;b2N+MhpPxSgFeY{$ul)CaBuS^!$+c_O zsx5pJU};LqgyI448~5D9tF5ghNxR+7HJO=e3m@`0vzc`tpYs&&@9!2EAAc{~+uKQ! z0|Nuhc=&tjiw7v!Y&O)@)xjl-usa+m`Sov5v~M2<2L>?K0_V(upML-VK$2wG9S)3V zf*?Q;1k8r%`g(h@YuEFTBnkcf{U|Oj!r&P3=Z18{-9Q?#S>3F z37t-d!-vZuisFnz5CDDs{OIH3Gwx8OKll*8>hC8>Ub}XU2?+@vFgr;5 zcQ-e0*+RWuKl_c$o3C26nuCLOlH})~w=pg*&Z~Hv&8Ez!=H^TM=7I&(Xf!i72kvy3 z3B{Z2o`IDz8 zNfJr2z7Y(l^-K+slmwo2b+2)GMKB(0sDT)rYzy%O^C^;NFBySm_VI*^l-i{j!jWn;Bs z>$YvEs;mG2Bqx6h@n$msz~ywIr@LFd5-R-J&y_5HcI6896&3NujT>V!-@!-Lt#g~O zMMcHR_ivwghrWJ(>Xp#Sv~>3L^eBR4SyryEcXaTPN8QDThK2E?x{sBZUp%*6-PfqE zuP^uS{~eu9m)qv%+81AVhzG!Le`h6odV5Kd-Q7K0kvjeLKP3PF3=R%vVd1m<*Vzx* z*wn+72^CLtjKNlA+V01l@UwY9ZVeas!(j4T^v+^ckHW)ET$z^6T`%nB`zKGa^TrKIl1y)An*zkNmrzJ>F!cJN zjSpqw`>R%CV?hB@Q&SNiZ${9ZIUe^ooKCb`+7TQa1e3`GqtS?xl41ZrdHJ6qOVWhT zoYoO|VA(R>9J9FdmSdS@-(`I8%ZXN|e zJ9{PMg`7bPY#ATZD^HY_ZRWo2b_I-M*pKTMO! z#MNsurvfoE9a19*3z33lA#RR92amZIWtkRBJ9q5d$>5L>SSt}Yvznm)ma6K~^kOADkiJG8qF;C2|j!3R!<-A(jZ`#;?W@defoBk%wK002ov JPDHLkV1j$?oB#j- literal 0 HcmV?d00001 diff --git a/omega/triage-portal/src/triage/static/triage/images/icon-searchcode.com.png b/omega/triage-portal/src/triage/static/triage/images/icon-searchcode.com.png new file mode 100644 index 0000000000000000000000000000000000000000..2f7e71cc294d64b652a7c1b357730d4f47f2137a GIT binary patch literal 2972 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 S$G~6!0000 \ No newline at end of file diff --git a/omega/triage-portal/src/triage/static/triage/images/icon-vscode.png b/omega/triage-portal/src/triage/static/triage/images/icon-vscode.png new file mode 100644 index 0000000000000000000000000000000000000000..37a084767a4432f1e2d317bedd1057660372d31e GIT binary patch literal 114937 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 { + if (file_name.indexOf('.sarif')) { + return `${file_name}.json`; + } + return file_name; + } + let mode = ace.require("ace/ext/modelist").getModeForPath(get_mode_filename(file_name)).mode; + editor.session.setMode(mode); + editor.resize(); + // Show the editor if needed + $('#editor-container').removeClass('d-none'); + + // Set the editor title + const file_line = $(document.body).data('current_finding').file_line; + if (file_line !== undefined && options['first'] === true) { + $('#file_path').text(file_name + ":" + file_line); + } else { + $('#file_path').text(file_name); + } + + //var path_abbrev = path; + //if (path_abbrev.length > 150) { + // path_abbrev = '...' + path_abbrev.substring(path_abbrev.length - 150, path_abbrev.length); + //} + //$('#editor-title .text').text(path_abbrev).attr('title', path); + const file_path = $(document.body).data('current_finding').file_path; + var finding_title = ($(document.body).data('current_finding').finding_title || '').substring(0, 40); + if (file_path !== undefined && file_line !== undefined && options['first'] === true) { + let session = ace.edit('editor').getSession(); + session.clearAnnotations(); + session.setAnnotations([{ + row: file_line - 1, + column: 0, + text: finding_title, + type: 'error' + }]); + } else { + ace.edit('editor').getSession().clearAnnotations(); + } + + // @TODO Are these necessary? + //$('#package-source-external-link').data('package-url', $row.data('package-url')); + //$('#issue-metadata-link').data('issue-id', $row.data('issue-id')); + + $(window).trigger('resize'); + $('#editor').css('height', $(window).height() - $('#editor').offset().top - 10); + + if (options['first'] === true) { + window.setTimeout(function() { + ace.edit('editor').scrollToLine(file_line, true, false); + }, 50); + } + }, + 'error': function (jqXHR, textStatus, errorThrown) { + ace.edit('editor').getSession().clearAnnotations(); + ace.edit('editor').getSession().setMode('ace/mode/text') + set_editor_text(`Error ${jqXHR.status}: ${jqXHR.responseJSON.message}.`); + }, + 'complete': function () { + $('#finding_center').css('opacity', '1.0'); + } + }); +}; +const initialize_editor = function () { + try { + let editor = ace.edit("editor"), + session = editor.getSession(); + editor.setOptions({ + useWorker: false + }); + editor.setShowPrintMargin(false); + editor.setTheme("ace/theme/cobalt"); + editor.setReadOnly(true) + editor.setOptions({ + 'fontFamily': 'Inconsolata', + 'fontSize': localStorage.getItem('last-used-editor-font-size') || '1.1rem', + }); + } catch (e) { + console.log(e); + } +} + +const set_editor_text = function (text) { + let editor = ace.edit("editor"); + editor.getSession().setValue(text); + editor.resize(); +} + +const load_file_listing = function (options, callback) { + $.ajax({ + 'url': '/api/findings/get_files', + 'method': 'GET', + 'data': options, + 'success': function (data, textStatus, jqXHR) { + if ($('#data').jstree(true)) { + $('#data').jstree(true).destroy(); + } + $('#data').jstree({ + 'core': { + 'data': data.data, + 'multiple': false, + 'themes': { + 'dblclick_toggle': false, + 'icons': true, + 'name': 'proton', + 'responsive': true + } + }, + 'animation': 40, + 'plugins': ['sort', 'contextmenu'], + 'sort': function (a, b) { + a1 = this.get_node(a); + b1 = this.get_node(b); + if (a1.children.length === 0 && b1.children.length === 0) { + return a1.text.localeCompare(b1.text); + } else if (a1.children.length === 0) { + return 1; + } else if (b1.children.length === 0) { + return -1; + } else { + return a1.text.localeCompare(b1.text); + } + }, + 'contextmenu': { + items: function (node) { + var tree = $('#data').jstree(true); + if (node.id == '#') { + return {}; + } + return { + "Download": { + "separator_before": false, + "separator_after": false, + "label": "Download", + "icon": "fa fa-download", + "_class": "file_tree_context_menu_item", + "action": function (obj) { + var node = tree.get_node(obj.reference); + if (node.children.length === 0) { + document.location.href = `/api/findings/download_file?file_uuid=${node.original.file_uuid}`; + } else { + //document.location.href = `/api/findings/download_file?finding_uuid=${options.finding_uuid}&file_path=${node.id}&recursive=true`; + alert('not implemented'); + } + } + } + } + } + } + }); + $('#data').on({ + "loaded.jstree": function (event, data) { + $(this).jstree("open_node", $(this).find('li:first')); + }, + "changed.jstree": function (event, data) { + if (data && data.node && data.node.children && data.node.children.length === 0) { + const original_file_uuid = $.data(document.body, 'current_finding').file_uuid; + if (data.node.original.file_uuid != original_file_uuid) { + $('#finding_center').removeClass('col-lg-8').addClass('col-lg-10'); + $('#finding_right').remove(); + } + load_source_code({ + 'file_uuid': data.node.original.file_uuid, + 'first': data.event === undefined // Event is undefined when changed is called manually upon paage load (@magic) + }); + } + } + }); + } + }); +} + +const beautify_source_code = () => { + const beautify = ace.require("ace/ext/beautify"); + const editor = ace.edit("editor"); + if (!!beautify && !!editor) { + beautify.beautify(editor.session); + } +}; + +const toggle_word_wrap = () => { + const session = ace.edit('editor').getSession(); + session.setUseWrapMode(!session.getUseWrapMode()); +} + +const change_font_size = (size) => { + const editor = ace.edit('editor'); + let fontSize = editor.getFontSize(); + if (fontSize.indexOf('rem') > -1) { + fontSize = fontSize.replace('rem', ''); + fontSize = parseFloat(fontSize) * size; + fontSize = fontSize + 'rem'; + } else if (fontSize.indexOf('px') > -1) { + fontSize = fontSize.replace('px', ''); + fontSize = parseFloat(fontSize) * size; + fontSize = fontSize + 'px'; + } + editor.setFontSize(fontSize); + localStorage.setItem('last-used-editor-font-size', fontSize); +} + +const IS_SUCCESS = (data) => { + if (!data) return false; + const status = data.status + ''; + if (status === 'success') return true; + if (status === 'ok' || status.startsWith('ok')) return true; + return false; +} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/base.html b/omega/triage-portal/src/triage/templates/triage/base.html new file mode 100644 index 00000000..dbc46fa4 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/base.html @@ -0,0 +1,155 @@ +{% load static %} + + + + + + + + + + + + + + + + + + + {% block style_include %} + {% endblock style_include %} + + {% block header %} + {% endblock header %} + + + + {{ page_title|default:"Omega | Open Source Security Foundation" }} + + + {% block full_body %} + + +
+ {% block body %} + {% endblock body %} +
+ + {% endblock full_body %} + + {% block footer %} + {% endblock footer %} + + + + + + + + + + + + + + + + + + + + + + + + {% block javascript_include %} + {% endblock javascript_include %} + + \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/case_list.html b/omega/triage-portal/src/triage/templates/triage/case_list.html new file mode 100644 index 00000000..afea0f04 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/case_list.html @@ -0,0 +1,82 @@ +{% extends "./base.html" %} + +{% block body %} + + +
+
+ +
+
+ + + + +
+
+
+
+
+
+

Cases

+ {% if cases %} + + + + + + + + + + + + + + {% for case in cases %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
TitleAssigned ToStateReporting PartnerReportedResolution Estimate
{{ case.title }}{{ case.assigned_to.get_full_name|default:'Unassigned' }}{{ case.get_state_display }}{{ case.get_reporting_partner_display }}{{ case.reported_dt|date:"SHORT_DATE_FORMAT" }}{{ case.resolved_target_dt|date:"SHORT_DATE_FORMAT" }}
No cases found.
+ {% else %} + There are no cases available. Try expanding your query. + {% endif %} +
+
+{% endblock body %} + +{% block javascript %} + /* Handle row clicks */ + $('tr.data_row').on('click', (e) => { + let case_uuid = $(e.target).closest('tr').data('case_uuid') + if (case_uuid !== undefined) { + document.location.href = `/cases/${case_uuid}`; + } + }); +{% endblock %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/case_show copy.html b/omega/triage-portal/src/triage/templates/triage/case_show copy.html new file mode 100644 index 00000000..53a89fc3 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/case_show copy.html @@ -0,0 +1,63 @@ +{% extends "./base.html" %} + +{% block body %} + {% block main %} + + +
+
+ +
+
+ + + + +
+
+
+
+
+
+

Active Cases

+ + + + + + + + + + + {% for case in cases %} + + + + + + + {% empty %} + + + + {% endfor %} + +
Case IDTitleAssigned ToStatus
{{ case.pk }}{{ case.title }}{{ case.assigned_to }}{{ case.get_status_display }}
No cases found.
+
+
+ + {% include "triage/case_part_edit.html" %} + {% endblock main %} +{% endblock body %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/case_show.html b/omega/triage-portal/src/triage/templates/triage/case_show.html new file mode 100644 index 00000000..f7bec1d7 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/case_show.html @@ -0,0 +1,207 @@ +{% extends "./base.html" %} +{% load gravatar %} +{% block body %} +
+
+ {% csrf_token %} + + + {% if error_messages %} + + {% endif %} + +
+
+ {% if case %} + + {% else %} + + {% endif %} + New + Back to List +
+ + {% if case %} +

Modify Case

+ {% else %} +

Add Case

+ {% endif %} +
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+ +
+
+ To associate a finding with this case, navigate to the finding and select "Add to Case" and choose this case. +
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
Notes
+ + + + + {% include "triage/widgets/notes.html" with notes=case.notes.all %} + +
+
+
+{% endblock %} + +{% block javascript %} + $(document).ready(function() { + $('.upload_container') + .on('drop', dropHandler) + .on('dragover', dragOver) + .on('dragleave', dragEnd); + }); + function dragEnd(e) { + e.preventDefault(); + e.stopPropagation(); + $(e.target).closest('.upload_container').css('border', ''); + } + function dragOver(e) { + e.preventDefault(); + e.stopPropagation(); + e.originalEvent.dataTransfer.dropEffect = 'copy'; + $(e.target).closest('.upload_container').css('border', '2px dashed #ccc'); + } + function dropHandler(e) { + e.stopPropagation(); + e.preventDefault(); + const container = $(e.target).closest('.upload_container'); + container.css('border', ''); + + var files = e.originalEvent.dataTransfer.files; + var formData = new FormData(); + for (var i = 0; i < files.length; i++) { + formData.append('attachment', files[i]); + } + formData.append('target_type', container.data('target_type')); + formData.append('target_uuid', container.data('target_uuid')); + $.ajax({ + url: '/api/upload', + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(data) { + for (let attachment of data.attachments) { + $('#case_attachments').append( + '
  • ' + + '' + + ' ' + + attachment.filename + + '' + + '
  • ' + ) + } + }, + error: function(data) { + console.log(data); + } + }); + } +{% endblock %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/filter_list.html b/omega/triage-portal/src/triage/templates/triage/filter_list.html new file mode 100644 index 00000000..9a3299e6 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/filter_list.html @@ -0,0 +1,86 @@ +{% extends "./base.html" %} + +{% block body %} + {% block main %} + + +
    +
    + +
    +
    + + + + +
    +
    +
    +
    +
    +
    + +

    Filters

    + {% if filters %} + + + + + + + + + + + + + {% for filter in filters %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    TitleActivePriorityLast ExecutedLast UpdatedActions
    {{ filter.title }}{{ filter.active }}{{ filter.priority }}{{ filter.last_executed|date:"SHORT_DATETIME_FORMAT" }}{{ filter.updated_at|date:"SHORT_DATETIME_FORMAT" }} + Execute +
    No filters found.
    + {% else %} + There are no filters available. Try expanding your query. + {% endif %} +
    +
    + {% endblock main %} +{% endblock body %} + +{% block javascript %} + /* Handle clicks of the "New Tool Finding" button */ + $('tr.data_row').on('click', (e) => { + let filter_uuid = $(e.target).closest('tr').data('filter_uuid') + if (filter_uuid !== undefined) { + document.location.href = `/filter/${filter_uuid}`; + } + }); +{% endblock %} diff --git a/omega/triage-portal/src/triage/templates/triage/filter_show.html b/omega/triage-portal/src/triage/templates/triage/filter_show.html new file mode 100644 index 00000000..af9e7543 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/filter_show.html @@ -0,0 +1,140 @@ +{% extends "./base.html" %} + +{% block body %} + {% block main %} + + + +
    +
    +
    + {% csrf_token %} + + + {% if error_messages %} + + {% endif %} + +
    +
    + {% if filter %} + + {% else %} + + {% endif %} + New + Delete + Back to List +
    + {% if filter %} +

    Modify Filter

    + {% else %} +

    Add Filter

    + {% endif %} +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + + 0=highest priority, 1000=lowest priority +
    +
    + +
    + +
    +
    + + +
    +
    +
    +
    +
    + + {% endblock %} +{% endblock %} + +{% block javascript %} + +// Source: https://stackoverflow.com/a/19513428/1384352 +// Hook up ACE editor to all textareas with data-editor attribute +$(function() { + $('textarea[data-editor]').each(function() { + var textarea = $(this); + var mode = textarea.data('editor') || 'text'; + var editDiv = $('
    ', { + position: 'absolute', + width: '100%', + height: textarea.height(), + }).insertBefore(textarea); + textarea.css('display', 'none'); + var editor = ace.edit(editDiv[0]); + editor.setShowPrintMargin(false); + editor.renderer.setShowGutter(textarea.data('gutter')); + editor.getSession().setValue(textarea.val()); + editor.getSession().setMode("ace/mode/" + mode); + editor.setOptions({ + 'fontFamily': 'Inconsolata', + 'fontSize': localStorage.getItem('last-used-editor-font-size') || '1.1rem', + tabSize: 2, + useSoftTabs: true + }); + editor.setTheme("ace/theme/cobalt"); + + // copy back to textarea on form submit... + textarea.closest('form').on('submit', () => { + textarea.val(editor.getSession().getValue()); + }); + }); + $('a#delete_filter').on('click', (e) => { + e.preventDefault(); + if (confirm('Are you sure you want to delete this filter? This action cannot be undone.')) { + $.ajax({ + url: 'filter/delete', + type: 'POST', + data: { + filter_uuid: '{{ filter.uuid }}' + }, + success: function(data) { + window.location.href = '/filter'; + }, + error: function(data) { + alert('Error deleting filter: ' + data.responseText); + } + }); + } + }); +}); +{% endblock javascript %} diff --git a/omega/triage-portal/src/triage/templates/triage/findings_list.html b/omega/triage-portal/src/triage/templates/triage/findings_list.html new file mode 100644 index 00000000..ea0c2cd9 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/findings_list.html @@ -0,0 +1,109 @@ +{% extends "./base.html" %} + +{% block body %} +{% block main %} + + +
    +
    + +
    +
    + + + + +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + {% for finding in findings %} + + + + + + + + + {% endfor %} + +
    PackageIssueFilenameToolSeverityState
    + {% include "triage/widgets/project_name_pretty.html" with project=finding.project_version only %} + {{ finding.normalized_title|truncatechars:100 }}{{ finding.get_filename_display }}:{{ finding.file_line }}{{ finding.tool.name }}{{ finding.get_severity_display }}{{ finding.get_state_display }}
    + +
    + +
    + {% endblock main %} +{% endblock body %} + +{% block javascript %} + /* Handle clicks of the "New Tool Finding" button */ + $('#action_new_tooling_bug').on('click', (e) => { + let finding_uuid = $('#finding_list').data('finding_uuid'); + if (finding_uuid !== undefined) { + document.location.href = `/tool_defect/new?finding_uuid=${finding_uuid}`; + } + }); +{% endblock %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/findings_show copy.html b/omega/triage-portal/src/triage/templates/triage/findings_show copy.html new file mode 100644 index 00000000..d112f4f6 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/findings_show copy.html @@ -0,0 +1,175 @@ +{% extends "./base.html" %} + +{% block body %} + {% block main %} + +
    +
    + +
    +
    + + + + +
    +
    +
    +
    + + + + + +
    +
    + + + + + + + + + + {% for finding in findings %} + + + + + + {% endfor %} + +
    PackageIssueFilename
    {{ finding.scan.project_version }}{{ finding.title }}{{ finding.get_filename_display }}
    +
    + + +
    + +
    +
    +
    +
    +
    +

    + +

    +
    +
    +
    +
    +
    +
    +
    +

    + +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
      +
    • +
      +

      Assignee

      + {% if finding.assigned_to %} + {{ finding.assigned_to }} + {% else %} + No one—assign yourself + {% endif %} +
    • +
    • +
      +

      Severity

      + {{ finding.get_severity_display }} +
    • +
    • A third item
    • +
    • A fourth item
    • +
    • And a fifth one
    • +
    +
    +
    + Triage Updates +
    +
    +
    +
    Severity
    + +
    + +
    +
    True/False Positive
    + +
    + +
    +
    Notes
    + +
    +
    +
    +
    +
    + {% endblock main %} +{% endblock body %} + +{% block javascript %} + /* Handle clicks of the "New Tool Finding" button */ + $('#action_new_tooling_bug').on('click', (e) => { + let finding_uuid = $('#finding_list').data('finding_uuid'); + if (finding_uuid !== undefined) { + document.location.href = `/tool_defect/new?finding_uuid=${finding_uuid}`; + } + }); +{% endblock %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/findings_show.html b/omega/triage-portal/src/triage/templates/triage/findings_show.html new file mode 100644 index 00000000..06be386b --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/findings_show.html @@ -0,0 +1,375 @@ +{% extends "./base.html" %} + +{% load gravatar %} + +{% block body %} + +
    +
    +
    + {{ finding.title }} + #{{ finding.pk }} +
    +
    + +
    +
    + +   + {{ finding.file_path }} + {% if finding.file_line %} + :{{finding.file_line }} + {% endif %} + +
    +
    +
    +
    +
    +
    + +
      +
    • + + +

      Assignee

      +
      + {% if finding.assigned_to %} + {{ finding.assigned_to.username }} + {{ finding.assigned_to.first_name }} {{ finding.assigned_to.last_name }} + {% else %} + No one—assign yourself + {% endif %} +
      +
    • + +
    • + + + +

      Triage Status

      +
      + + + + + + + + +
      +
    • +
    • +

      Severity

      + +
    • + +
    • + + +

      Estimated Impact

      + {{ finding.estimated_impact }} +
    • +
    • + + +

      Labels

      + {% for label in finding.labels %} + {{ label }} + {% empty %} + No labels + {% endfor %} +
    • +
    • +

      Notes

      + +
    • +
    • + +
    • +
    +
    +
    +{% endblock body %} + +{% block javascript %} + /* Stash some page-level data */ + $(document.body).data('current_finding', { + 'finding_uuid': '{{ finding.uuid }}', + 'project_version_uuid': '{{ finding.project_version.uuid }}', + 'file_line': '{{ finding.file_line }}', + 'file_path': '{{ finding.file.path }}', + 'file_uuid': '{{ finding.file.uuid }}', + 'finding_title': '{{ finding.title }}' + }); + + /* Handle clicks of the "New Tool Finding" button */ + $('#action_new_tooling_bug').on('click', (e) => { + let finding_uuid = $('#finding_list').data('finding_uuid'); + if (finding_uuid !== undefined) { + document.location.href = `/tool_defect/new?finding_uuid=${finding_uuid}`; + } + }); + + /** + * Responds to the "assign self" link. + */ + $('#assign_self').on('click', (e) => { + const finding_uuid = '{{ finding.uuid }}'; + $.ajax({ + url: `/api/1/findings/update`, + data: { + 'finding_uuid': finding_uuid, + 'assigned_to': '$self' + }, + type: 'POST', + success: (data) => { + if (IS_SUCCESS(data)) { + $('#assigned_to_content').clear(); + const anchor = $(''); + anchor.attr('href', '#'); + anchor.text(data.assigned_to); + $('#assigned_to_content').append(anchor); + } + else { + console.log('Error: Unsuccessful response from server.'); + } + } + }); + }); + + $('#assigned_to').on('change', (e) => { + const finding_uuid = '{{ finding.uuid }}'; + $.ajax({ + url: `/api/1/findings/update`, + data: { + 'finding_uuid': finding_uuid, + 'assigned_to': $(e.target).val() + }, + type: 'POST', + success: (data) => { + if (IS_SUCCESS(data)) { + $('#assigned_to_content').clear(); + const anchor = $(''); + anchor.attr('href', '#'); + anchor.text(data.assigned_to); + $('#assigned_to_content').append(anchor); + } + else { + console.log('Error: Unsuccessful response from server.'); + } + } + }); + }); + $('#estimated_impact').on('keypress', (e) => { + if (e.keyCode === 13) { + e.preventDefault(); + $('#estimated_impact').trigger('change'); + } + }); + $('#estimated_impact').on('change', (e) => { + const finding_uuid = '{{ finding.uuid }}'; + $.ajax({ + url: `/api/1/findings/update`, + data: { + 'finding_uuid': finding_uuid, + 'estimated_impact': $(e.target).val() + }, + type: 'POST', + success: (data) => { + if (IS_SUCCESS(data)) { + $('#estimated_impact_content').text($(e.target).val()); + } + else { + console.log('Error: Unsuccessful response from server.'); + } + } + }); + $('ul.dropdown-menu.show').hide(); + }); + + $(document).ready(() => { + load_file_listing({"project_version_uuid": "{{ finding.project_version.uuid }}"}); + $('#data').on({ + "loaded.jstree": function (event, data) { + $('#data').jstree('select_node', '{{ finding.file.path }}', false); + + } + }); + + /* Resize the file and editor */ + $(window).on('resize', (e) => { + $('#editor').css('height', $(window).height() - $('#editor').offset().top - 10); + $('#data').css('height', $(window).height() - $('#data').offset().top - 10); + }); + $(window).trigger('resize'); + }); + + var update_finding = (options) => { + $.ajax({ + url: `/api/1/findings/update`, + type: 'POST', + data: options, + success: (data) => { + console.log(data); + } + }); + }; + + $('#severity_button_group input').on('click', (e) => { + let severity = $(e.target).val(); + update_finding({ + 'finding_uuid': '{{ finding.uuid }}', + 'analyst_severity_level': severity + }); + }); + +{% endblock %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/findings_upload.html b/omega/triage-portal/src/triage/templates/triage/findings_upload.html new file mode 100644 index 00000000..ef0cead5 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/findings_upload.html @@ -0,0 +1,36 @@ +{% extends "./base.html" %} + +{% block body %} + {% block main %} + + +{% if status == "ok" %} + +{% endif %} + +

    Upload SARIF

    +
    +
    +
    +
    + {% csrf_token %} +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    + {% endblock main %} +{% endblock body %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/home.html b/omega/triage-portal/src/triage/templates/triage/home.html new file mode 100644 index 00000000..bb406d16 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/home.html @@ -0,0 +1,209 @@ +{% extends "./base.html" %} +{% load static %} +{% block body %} + {% block main %} + +
    +
    +
    +
    +
    +

    Omega

    +

    + Omega is a security analysis triage and management suite created and + maintained by the Open Source Security Foundation. +

    + +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    Active Cases
    +

    View all reported but unfixed vulnerabilities.

    + View Active Cases +
    + +
    +
    + +
    +
    +
    +
    Vulnerability Search
    +

    + Search for a vulnerability by its CVE or description. Service provided by third-parties. +

    + +
    + + + +
    +
    +

    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    Code Search
    +

    + Code search is provided by third-parties. +

    + +
    + + + +
    +
    +

    +
    +
    +
    + +
    +
    +
    +
    My Work
    +

    +

      +
    • + My Cases + {{ my_work.num_cases|floatformat:"g" }} +
    • +
    • + My Findings + {{ my_work.num_findings|floatformat:"g" }} +
    • +
    • + My Tool Defects + {{ my_work.num_tool_defects|floatformat:"g" }} +
    • +
    +

    +
    +
    +
    + +
    +
    +
    +
    Metrics
    +

    +

      +
    • + Total Findings + {{ metrics.num_findings|floatformat:"g" }} +
    • +
    • + Active Findings + {{ metrics.num_active_findings|floatformat:"g" }} +
    • +
    • + New This Week + {{ metrics.num_new_findings|floatformat:"g" }} +
    • +
    +

    +
    +
    +
    +
    + +
    +
    +
    + + {% endblock main %} +{% endblock body %} + +{% block javascript %} + /* Handle clicks of the "Code Search" buttons */ + $('.code_search').on('click', (e) => { + e.preventDefault(); + const query = $('#code_search_query').val(); + if (!query || query.trim() === '') { + return; + } + + const target = $(e.target).closest('button,a').data('target'); + switch (target) { + case 'github': + window.open('https://cs.github.com/?q=' + encodeURIComponent(query), '_omega_cs', 'noopener,noreferrer'); + break; + case 'sourcegraph': + window.open('https://sourcegraph.com/search?patternType=literal&q=context:global+' + encodeURIComponent(query), '_omega_cs', 'noopener,noreferrer'); + break; + case 'searchcode': + window.open('https://searchcode.com/codesearch?q=' + encodeURIComponent(query), '_omega_cs', 'noopener,noreferrer'); + break; + case 'debian': + window.open('https://codesearch.debian.net/search?literal=1&q=' + encodeURIComponent(query), '_omega_cs', 'noopener,noreferrer'); + break; + default: + } + }); + + /* Handle clicks of the "Vulnerability Search" buttons */ + $('.vulnerability_search').on('click', (e) => { + e.preventDefault(); + const query = $('#vulnerability_query').val(); + if (!query || query.trim() === '') { + return; + } + + const target = $(e.target).closest('button,a').data('target'); + switch (target) { + case 'nvd.nist.gov': + window.open('https://nvd.nist.gov/vuln/search/results?form_type=Basic&results_type=overview&search_type=all&isCpeNameSearch=false&query=' + encodeURIComponent(query), '_omega_vs', 'noopener,noreferrer'); + break; + case 'cve.circl.lu': + $('
    ').appendTo('body').submit().remove(); + break; + case 'exploit-db.com': + window.open('https://www.exploit-db.com/search?q=' + encodeURIComponent(query), '_omega_vs', 'noopener,noreferrer'); + break; + default: + } + }); +{% endblock %} diff --git a/omega/triage-portal/src/triage/templates/triage/tool_defect_list.html b/omega/triage-portal/src/triage/templates/triage/tool_defect_list.html new file mode 100644 index 00000000..4997a48f --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/tool_defect_list.html @@ -0,0 +1,77 @@ +{% extends "./base.html" %} + +{% block body %} + {% block main %} + + +
    +
    + +
    +
    + + + + +
    +
    +
    +
    +
    +
    +

    Tool Defects

    + + + + + + + + + + + + + {% for tool_defect in tool_defects %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    Tool Defect IDTitleToolAssigned ToStatusCreated
    {{ tool_defect.pk }}{{ tool_defect.title }}{{ tool_defect.tool.name }}{{ tool_defect.assigned_to|default:"No one" }}{{ tool_defect.get_state_display }}{{ tool_defect.created_at|timesince }} ago
    No defects found.
    +
    +
    + + {% endblock main %} +{% endblock body %} + +{% block javascript %} + /* Handle row clicks */ + $('tr.data_row').on('click', (e) => { + let tool_defect_uuid = $(e.target).closest('tr').data('tool_defect_uuid') + if (tool_defect_uuid !== undefined) { + document.location.href = `/tool_defect/${tool_defect_uuid}`; + } + }); +{% endblock %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/tool_defect_new.html b/omega/triage-portal/src/triage/templates/triage/tool_defect_new.html new file mode 100644 index 00000000..a9110ef9 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/tool_defect_new.html @@ -0,0 +1,8 @@ +{% extends "./base.html" %} + +{% block body %} + {% block main %} +
    + {% include "triage/tool_defect_part_edit.html" %} + {% endblock main %} +{% endblock body %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/tool_defect_show.html b/omega/triage-portal/src/triage/templates/triage/tool_defect_show.html new file mode 100644 index 00000000..783fff0e --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/tool_defect_show.html @@ -0,0 +1,123 @@ +{% extends "./base.html" %} + +{% load gravatar %} + +{% block body %} + {% block main %} +
    +
    + {% csrf_token %} + {% if tool_defect %} + + + {% else %} + + {% endif %} + +
    +
    + {% if tool_defect %} + + {% else %} + + {% endif %} + Back to List +
    + + {% if tool_defect %} +

    Edit Tool Defect

    + {% else %} +

    Add Tool Defect

    + {% endif %} +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + + {% include "triage/widgets/notes.html" with notes=tool_defect.notes.all %} + + + +
    +
    +
    + +
    + + {% if tool_defect.findings or finding %} + + + + + + + + + + + + {% if finding %} + + + + + + + + + {% endif %} + {% for finding in tool_defect.findings.all %} + + + + + + + + {% endfor %} + +
    TitleProjectStateSeverityActions
    {{ finding.normalized_title }}{{ finding.project_version.package_url }}{{ finding.get_state_display }}{{ finding.get_severity_display }} + +    + +
    {{ finding.normalized_title }}{{ finding.project_version.package_url }}{{ finding.get_state_display }}{{ finding.get_severity_display }} + +    + +
    + {% else %} +

    No findings associated with this defect.

    + {% endif %} +
    +
    + + {% endblock main %} +{% endblock body %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/widgets/icon_for_file.html b/omega/triage-portal/src/triage/templates/triage/widgets/icon_for_file.html new file mode 100644 index 00000000..fbc382b5 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/widgets/icon_for_file.html @@ -0,0 +1,25 @@ +{% spaceless %} + {% with ext4=filename|slice:"-4:" ext3=filename|slice:"-3:" ext2=filename|slice:"-2" %} + {% if ext3 == "pdf" %} + + {% elif ext3 == "doc" or ext4 == "docx" %} + + {% elif ext3 == "zip" or ext3 == "tar" %} + + {% elif ext3 == "xls" or ext4 == "xlsx" %} + + {% elif ext3 == "ppt" or ext4 == "pptx" %} + + {% elif ext3 == "mp3" or ext3 == "wav" or ext3 == "ogg" or ext4 == "flac" %} + + {% elif ext3 == "mp4" or ext3 == "avi" or ext3 == "mkv" or ext3 == "mov" or ext3 == "wmv" or ext3 == "mpg" or ext3 == "mpeg" %} + + {% elif ext3 == "gif" or ext3 == "jpg" or ext3 == "png" or ext3 == "ico" %} + + {% elif ext3 == "txt" or ext2 == "md" or ext3 == "rst" or ext2 == "py" or ext2 == "sh" or ext3 == "xml" or ext4 == "html" or ext3 == "css" or ext2 == "js" %} + + {% else %} + + {% endif %} + {% endwith %} +{% endspaceless %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/widgets/notes.html b/omega/triage-portal/src/triage/templates/triage/widgets/notes.html new file mode 100644 index 00000000..192481c5 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/widgets/notes.html @@ -0,0 +1,13 @@ +{% if notes %} + {% load gravatar %} + {% for note in notes %} +
    + +
    +
    + {{ note.created_by.get_full_name }} + commented {{ note.created_at|date:"SHORT_DATETIME_FORMAT" }} +
    {{ note.content }}
    +
    + {% endfor %} +{% endif %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/widgets/project_name_pretty.html b/omega/triage-portal/src/triage/templates/triage/widgets/project_name_pretty.html new file mode 100644 index 00000000..3f66807f --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/widgets/project_name_pretty.html @@ -0,0 +1,30 @@ +{% load project_helpers %} +{% parse_package_url project.package_url %} +{% spaceless %} + {% if package_url.type == "npm" %} + + {% elif package_url.type == "gem" %} + + {% elif package_url.type == "nuget" %} + + {% elif package_url.type == "pypi" %} + + {% elif package_url.type == "github" %} + + {% elif package_url.type == "ubuntu" %} + + {% else %} +
    {{ package_url.type }}
    + {% endif %} + + + {% if package_url.namespace %} + {{ package_url.namespace }}/{{ package_url.name }} + {% else %} + {{ package_url.name }} + {% endif %} + + {{ package_url.version }} + + +{% endspaceless %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/wiki_edit.html b/omega/triage-portal/src/triage/templates/triage/wiki_edit.html new file mode 100644 index 00000000..ff08b687 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/wiki_edit.html @@ -0,0 +1,95 @@ +{% extends "./base.html" %} +{% load gravatar %} +{% block body %} +
    + {% csrf_token %} + + +
    +
    +
    + {% if wiki_article %} + + {% else %} + + {% endif %} + New + Cancel +
    + + {% if wiki_article %} +

    Edit Wiki Article

    + {% else %} +

    Add Wiki Article

    + {% endif %} +
    + + {% if wiki_article.versions %} +
    + {% else %} +
    + {% endif %} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    +
    +
    + {% if wiki_article.versions %} +
    +
    + + +
    +
    + {% endif %} +
    + +{% endblock body %} + +{% block javascript %} +$('#minor-edit').on('click', (e) => { + $('#wiki_article_change_comment').val('Minor edit'); +}); +{% endblock javascript %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/wiki_list.html b/omega/triage-portal/src/triage/templates/triage/wiki_list.html new file mode 100644 index 00000000..3390be70 --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/wiki_list.html @@ -0,0 +1,70 @@ +{% extends "./base.html" %} + +{% block body %} + + +
    +
    + +
    +
    + + + + +
    +
    +
    +
    +
    +
    +

    Wiki Articles

    + {% if wiki_articles %} + + + + + + + + + + + {% for wiki_article in wiki_articles %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    TitleStateLast UpdatedLast Updated by
    {{ wiki_article.current.title }}{{ wiki_article.get_state_display }}{{ wiki_article.current.updated_at|date:"SHORT_DATETIME_FORMAT" }}{{ wiki_article.current.updated_by.get_full_name }}
    No wiki articles found.
    + {% else %} + There are no wiki articles available. Try expanding your query. + {% endif %} +
    +
    +{% endblock body %} + +{% block javascript %} + /* Handle row clicks */ + $('tr.data_row').on('click', (e) => { + let slug = $(e.target).closest('tr').data('wiki_article_slug') + if (slug !== undefined) { + document.location.href = `/wiki/${slug}`; + } + }); +{% endblock %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/wiki_show.html b/omega/triage-portal/src/triage/templates/triage/wiki_show.html new file mode 100644 index 00000000..9edf86ef --- /dev/null +++ b/omega/triage-portal/src/triage/templates/triage/wiki_show.html @@ -0,0 +1,24 @@ +{% extends "./base.html" %} +{% load wiki %} +{% block body %} +
    + {% csrf_token %} + + +
    +
    +
    + Edit + New + View All +
    + +

    {{ wiki_article.current.title }}

    +
    + +
    + {{ wiki_article.current.content|wiki_markdown|safe }} +
    +
    +
    +{% endblock body %} \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templatetags/__init__.py b/omega/triage-portal/src/triage/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/omega/triage-portal/src/triage/templatetags/gravatar.py b/omega/triage-portal/src/triage/templatetags/gravatar.py new file mode 100644 index 00000000..003ace87 --- /dev/null +++ b/omega/triage-portal/src/triage/templatetags/gravatar.py @@ -0,0 +1,21 @@ +import hashlib +import logging + +from django import template + +register = template.Library() +logger = logging.getLogger(__name__) + + +@register.filter +def gravatar(user, size=128): + """Convert the user's email address passed in into a gravatar URL.""" + root = "https://gravatar.com/avatar/{0}?r=g&d=mp&s={1}&{2}" + if user and user.email: + email = user.email.strip().lower().encode("utf-8") + # MD5 is mandated by the Gravatar spec - gravatar.com + hash_value = hashlib.md5(email).hexdigest() # noqa # nosec + return root.format(hash_value, str(size), "") + else: + # Mystery Person + return root.format("", str(size), "f=y") diff --git a/omega/triage-portal/src/triage/templatetags/project_helpers.py b/omega/triage-portal/src/triage/templatetags/project_helpers.py new file mode 100644 index 00000000..7d50b088 --- /dev/null +++ b/omega/triage-portal/src/triage/templatetags/project_helpers.py @@ -0,0 +1,11 @@ +from django import template +from packageurl import PackageURL + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def parse_package_url(context, package_url): + package_url_obj = PackageURL.from_string(package_url) + context.update({"package_url": package_url_obj.to_dict()}) + return "" diff --git a/omega/triage-portal/src/triage/templatetags/wiki.py b/omega/triage-portal/src/triage/templatetags/wiki.py new file mode 100644 index 00000000..080ad9be --- /dev/null +++ b/omega/triage-portal/src/triage/templatetags/wiki.py @@ -0,0 +1,22 @@ +import hashlib +import logging + +import markdown +from django import template +from markdown.extensions.wikilinks import WikiLinkExtension + +register = template.Library() +logger = logging.getLogger(__name__) + + +@register.filter +def wiki_markdown(context): + if not context: + return "" + try: + extensions = [WikiLinkExtension(base_url="/wiki/", end_url="")] + result = markdown.markdown(context, extensions=extensions) + return result + except Exception as msg: + logger.warn("Error in wiki_markdown: %s" % msg) + return context diff --git a/omega/triage-portal/src/triage/urls.py b/omega/triage-portal/src/triage/urls.py new file mode 100644 index 00000000..ac3a1b12 --- /dev/null +++ b/omega/triage-portal/src/triage/urls.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""This module URL patterns specific to the Triage Portal.""" + +from django.urls import path + +from triage.views import attachments, cases, filters, findings, home, tool_defect, wiki + +urlpatterns = [ + # Cases + path("cases/", cases.show_case), + path("cases/new", cases.new_case), + path("cases/save", cases.save_case), + path("cases/", cases.show_cases), + # Tooling Defects + path("tool_defect/", tool_defect.show_tool_defect), + path("tool_defect/new", tool_defect.show_add_tool_defect), + path("tool_defect/save", tool_defect.save_tool_defect), + path("tool_defect/", tool_defect.show_tool_defects), + # Findings + path("api/findings/add", findings.api_add), + path("api/findings/get_files", findings.api_get_files), + path("api/findings/get_source_code", findings.api_get_source_code), + path("api/findings/download_file", findings.api_download_file), + path("api/upload", findings.api_upload_attachment), + # path("api/findings/get_file_list", findings.api_get_file_list), + path("api/findings/get_blob_list", findings.api_get_blob_list), + path("api/1/findings/update", findings.api_update_finding), + # Attachments + path("attachment/", attachments.download_attachment), + path("findings/", findings.show_finding_by_uuid), + path("findings/upload", findings.show_upload), + path("findings/", findings.show_findings), + # Filters + path("filter/", filters.show_filter), + path("filter/new", filters.new_filter), + path("filter/save", filters.save_filter), + path("filter/execute", filters.execute_filter), + path("filter/delete", filters.delete_filter), + path("filter/", filters.show_filters), + # Wiki + path("wiki/special:list", wiki.show_wiki_article_list), + path("wiki/save", wiki.save_wiki_article), + path("wiki/", wiki.show_wiki_article), + path("wiki//", wiki.show_wiki_article_revision), + path("wiki//edit", wiki.edit_wiki_article), + path("wiki///edit", wiki.edit_wiki_article_revision), + path("wiki/", wiki.home), + path("", home.home), +] diff --git a/omega/triage-portal/src/triage/util/azure_blob_storage.py b/omega/triage-portal/src/triage/util/azure_blob_storage.py new file mode 100644 index 00000000..0228be10 --- /dev/null +++ b/omega/triage-portal/src/triage/util/azure_blob_storage.py @@ -0,0 +1,225 @@ +import io +import logging +import os +import tarfile +import urllib.parse +import uuid +from typing import List, Optional + +from azure.storage.blob import BlobClient, BlobServiceClient +from django.core.cache import cache +from packageurl import PackageURL + +import triage +from core.settings import ( + DEFAULT_CACHE_TIMEOUT, + TOOLSHED_BLOB_STORAGE_CONTAINER_SECRET, + TOOLSHED_BLOB_STORAGE_URL_SECRET, +) +from triage.util.source_viewer.pathsimilarity import PathSimilarity +from triage.util.source_viewer.viewer import SourceViewer + +logger = logging.getLogger(__name__) + + +class AzureBlobStorageAccessor: + """ + This class is used to access blob stored in the Toolshed container. To use + it, pass in a name prefix, which is usually {type}/{name}/{version} or + {type}/{namespace}/{name}/{version}. + + Example: + >>> blob_storage = AzureBlobStorageAccessor('npm/left-pad/1.3.0') + >>> blob_storage.get_blob_list() + >>> blob_storage.get_tool_contents('tool-codeql-results.json') + >>> blob_storage.get_package_contents(') + >>> blob_storage.get_blob_contents('tool-codeql-results.json') + """ + + def __init__(self, name_prefix: str): + """Initialize AzureBlobStorageAccessor.""" + if not name_prefix or not name_prefix.strip(): + raise ValueError("name_prefix cannot be empty") + + if not TOOLSHED_BLOB_STORAGE_URL_SECRET or not TOOLSHED_BLOB_STORAGE_CONTAINER_SECRET: + raise ValueError("TOOLSHED_BLOB_STORAGE_URL and TOOLSHED_BLOB_CONTAINER must be set") + + self.blob_service = BlobServiceClient(TOOLSHED_BLOB_STORAGE_URL_SECRET) + self.container = self.blob_service.get_container_client( + TOOLSHED_BLOB_STORAGE_CONTAINER_SECRET + ) + self.name_prefix = name_prefix + + def get_blob_list(self) -> List[dict]: + """Get list of blobs in the Toolshed container.""" + try: + cache_key = f"AzureBlobStorageAccessor[name_prefix={self.name_prefix}].blob_list" + if cache.has_key(cache_key): + return cache.get(cache_key) + else: + data = list( + map( + lambda b: { + "full_path": b.name, + "relative_path": b.name[len(self.name_prefix) + 1 :], + }, + self.container.list_blobs(name_starts_with=self.name_prefix), + ) + ) + cache.set(cache_key, data, timeout=DEFAULT_CACHE_TIMEOUT) + return data + except: + logger.exception("Failed to get blob list") + return [] + + def get_blob_contents(self, blob_name: str) -> str | bytes: + """Load blob contents from Toolshed.""" + try: + blob = self.container.get_blob_client(blob_name) + if blob.exists(): + logger.info("Blob exists, downloading: %s", blob_name) + return blob.download_blob().readall() + else: + logger.warning("Blob %s does not exist.", blob_name) + return None + except: + logger.exception("Failed to get blob contents") + return None + + +class ToolshedBlobStorageAccessor: + def __init__(self, scan: "triage.models.Scan"): + if not scan: + raise ValueError("scan cannot be empty") + self.scan = scan + self.package_url = PackageURL.from_string(scan.project_version.package_url) + name_prefix = self.get_toolshed_prefix(self.package_url) + if not name_prefix: + raise ValueError("Invalid package_url") + + self.blob_accessor = AzureBlobStorageAccessor(name_prefix) + + def get_toolshed_prefix(self, package_url: PackageURL): + if not package_url: + return None + + if package_url.namespace: + parts = [package_url.type, package_url.namespace, package_url.name, package_url.version] + else: + parts = [package_url.type, package_url.name, package_url.version] + + # Escape reserved URL characters + prefix = "/".join(map(urllib.parse.quote, parts)) + + # TODO: Add validation based on https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata + return prefix + + def get_tool_files(self, path_prefix="/tools"): + """Retrieve all tool findings files from Toolshed.""" + results = [] # type: List[dict] + for blob in self.blob_accessor.get_blob_list(): + results.append(os.path.join(path_prefix, blob.get("relative_path"))) + return results + + def get_package_files(self, path_prefix="/package"): + """Retreive all package file contents from any available source.""" + results = [] + for blob in self.blob_accessor.get_blob_list(): + if blob.get("relative_path").startswith("reference-binaries"): + if blob.get("relative_path").endswith(".tgz"): + contents = self.blob_accessor.get_blob_contents(blob.get("full_path")) + tar = tarfile.open(fileobj=io.BytesIO(contents), mode="r") + for member in tar.getmembers(): + results.append(os.path.join(path_prefix, member.name)) + + if not results: + viewer = SourceViewer(self.package_url) + viewer.load_if_needed() + for filename in viewer.get_file_list(): + results.append(os.path.join(path_prefix, filename)) + return results + + def get_package_contents(self, filename): + """Retrieve package file contents from Toolshed.""" + cache_key = f"Storage[purl={self.package_url}].filename={filename}" + if cache.has_key(cache_key): + return cache.get(cache_key) + + try: + logger.info("Attempting to retrieve file contents for %s", filename) + if filename.startswith("package/"): + filename = filename[len("package/") :] + clean_filename = self.clean_filename(filename) + + for blob in self.blob_accessor.get_blob_list(): + if not blob.get("relative_path").startswith("reference-binaries") or not blob.get( + "relative_path" + ).endswith(".tgz"): + continue + contents = self.blob_accessor.get_blob_contents(blob.get("full_path")) + tar = tarfile.open(fileobj=io.BytesIO(contents), mode="r") + for member in tar.getmembers(): + if member.name == clean_filename: + contents = tar.extractfile(member).read() + logger.info("Content length: %d bytes", len(contents)) + cache.set(cache_key, contents, timeout=DEFAULT_CACHE_TIMEOUT) + return contents + + # No exact match, try for fuzzy ones + member_names = [t.name for t in tar.getmembers()] + most_similar = PathSimilarity.find_most_similar_path(member_names, clean_filename) + if most_similar: + logger.info("Most similar path: %s", most_similar) + contents = tar.extractfile(most_similar).read() + logger.info("Content length: %d bytes", len(contents)) + cache.set(cache_key, contents, timeout=DEFAULT_CACHE_TIMEOUT) + return contents + + logger.warning("File %s not found in package", filename) + cache.set(cache_key, "", timeout=DEFAULT_CACHE_TIMEOUT) + return None + except: + logger.exception("Failed to get blob contents") + cache.set(cache_key, "", timeout=DEFAULT_CACHE_TIMEOUT) + return None + + def get_file_contents(self, filename): + """Retrieve contents of a file from the Toolshed.""" + cache_key = f"Storage[purl={self.package_url}].filename={filename}" + if cache.has_key(cache_key): + return cache.get(cache_key) + try: + logger.info("Attempting to retrieve file contents for %s", filename) + if filename.startswith("tools/"): + filename = filename[len("tools/") :] + clean_filename = self.clean_filename(filename) + full_path = os.path.join(self.get_toolshed_prefix(self.package_url), filename) + contents = self.blob_accessor.get_blob_contents(full_path) + logger.info("Content length: %d bytes", len(contents)) + cache.set(cache_key, contents, timeout=DEFAULT_CACHE_TIMEOUT) + return contents + except: + logger.exception("Failed to get blob contents") + cache.set(cache_key, "", timeout=DEFAULT_CACHE_TIMEOUT) + return None + + def get_all_files(self): + """Retrieve all files about the scan.""" + return self.get_tool_files() + self.get_package_files() + self.get_intermediate_files() + + def get_intermediate_files(self): + return [] + + def clean_filename(self, filename: str) -> Optional[str]: + if not filename: + return None + + if filename.startswith("pkg:"): + return None + + if filename.startswith("/opt/"): + parts = filename.split("/")[3:] + parts = parts[parts.index("src") + 1 :] + return "/".join(parts) + + return filename diff --git a/omega/triage-portal/src/triage/util/finding_importers/file_importer.py b/omega/triage-portal/src/triage/util/finding_importers/file_importer.py new file mode 100644 index 00000000..bf68b3bd --- /dev/null +++ b/omega/triage-portal/src/triage/util/finding_importers/file_importer.py @@ -0,0 +1,73 @@ +import hashlib +import io +import logging +import mimetypes +import os +import tarfile +import zipfile + +from triage.models import File, FileContent, ProjectVersion, Scan + +logger = logging.getLogger(__name__) + + +class FileImporter: + @classmethod + def import_file( + cls, root: str, file_path: str, file_content: bytes, target: Scan | ProjectVersion + ) -> bool: + """ + Adds a file to the database. + """ + if root is None: + root = "" + + for file_info in cls.extract_archive(file_path, file_content): + logger.debug("Saving file %s", file_info.get("name")) + mime_type = mimetypes.guess_type(file_info.get("name"), strict=False)[0] + if mime_type is None: + mime_type = "application/octet-stream" + + content_hash = hashlib.sha256(file_info.get("content")).digest() + file_content = FileContent.objects.get_or_create( + hash=content_hash, + defaults={"content_type": mime_type, "data": file_info.get("content")}, + )[0] + file = File.objects.get_or_create( + name=os.path.basename(file_info.get("name")), + path=file_info.get("name"), + content=file_content, + )[0] + + if isinstance(target, (Scan, ProjectVersion)): + target.files.add(file) + + @classmethod + def extract_archive(cls, file_path: str, file_content: bytes) -> dict: + """ + Extracts contents of an archive file. + """ + if file_path.endswith(".tgz"): + logger.debug("Detected archive type: tar.gz (%s)", file_path) + with tarfile.open(fileobj=io.BytesIO(file_content), mode="r") as tar: + for member in tar.getmembers(): + if member.isfile(): + content = tar.extractfile(member) + yield { + "name": member.name, + "size": member.size, + "content": content.read() if content is not None else None, + } + elif file_path.endswith(".zip"): + logger.debug("Detected archive type: zip (%s)", file_path) + with zipfile.ZipFile(io.BytesIO(file_content), mode="r") as zip: + for member in zip.infolist(): + if member.is_file(): + yield { + "name": member.filename, + "size": member.file_size, + "content": zip.read(member), + } + else: + logger.debug("File %s is not an archive", file_path) + return {"name": file_path, "size": len(file_content), "content": file_content} diff --git a/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py b/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py new file mode 100644 index 00000000..305d3bf9 --- /dev/null +++ b/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""This module provides support for import SARIF files into the Triage Portal's data model.""" + +import hashlib +import json +import logging +import os +import re +import uuid +from typing import Optional, Type + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from packageurl import PackageURL + +from triage.models import Finding, ProjectVersion, Scan, Tool, WorkItemState +from triage.util.general import get_complex + +logger = logging.getLogger(__name__) + + +class SARIFImporter: + """ + This class handles importing SARIF files into the database. + """ + + @classmethod + def import_sarif_file( + cls, package_url: PackageURL | str, sarif: dict, user: Optional[Type[AbstractUser]] + ) -> bool: + """ + Imports a SARIF file containing tool findings into the database. + + Args: + package_url: The PackageURL to attach all findings to. This PackageURL must + contain a version. + sarif: The SARIF content (as a dict) to import. + file_archive: The file archive containing the SARIF file. + + Returns: + True if the SARIF content was successfully imported, False otherwise. + """ + if package_url is None: + raise TypeError("The package_url must not be None") + + if isinstance(package_url, str): + package_url = PackageURL.from_string(package_url) + + if package_url.version is None: + raise TypeError( + f"The package_url ({package_url}) does not contain a version. Unable to import." + ) + + if sarif is None: + raise TypeError("The sarif content must not be None.") + + if sarif.get("version") != "2.1.0": + raise ValueError("Only SARIF version 2.1.0 is supported.") + + user = get_user_model().objects.get(id=1) # TODO: Fix this hardcoding + project_version = ProjectVersion.get_or_create_from_package_url(package_url, user) + + num_imported = 0 + processed = set() # Reduce duplicates + + # First load all of the rules + for run in sarif.get("runs"): + tool_name = get_complex(run, "tool.driver.name") + tool_version = get_complex(run, "tool.driver.version") + tool = Tool.objects.get_or_create( + name=tool_name, + version=tool_version, + defaults={ + "created_by": user, + "updated_by": user, + "type": Tool.ToolType.STATIC_ANALYSIS, + }, + )[0] + + logger.debug("Processing run for tool: %s", tool) + + rule_description_map = {} + for rule in get_complex(run, "tool.driver.rules"): + rule_id = get_complex(rule, "id") + rule_description = get_complex(rule, "shortDescription.text") + if rule_id and rule_description: + rule_description_map[rule_id] = rule_description + + for result in run.get("results", []): + rule_id = result.get("ruleId") + logger.debug("Saving result for rule #%s", rule_id) + + message = get_complex(result, "message.text") + level = get_complex(result, "level") + for location in get_complex(result, "locations"): + artifact_location = get_complex(location, "physicalLocation.artifactLocation") + + src_root = get_complex(artifact_location, "uriBaseId", "%SRCROOT%") + if src_root.upper() not in ["%SRCROOT%", "SRCROOT"]: + continue + + uri = get_complex(artifact_location, "uri") + + # Ensure we only insert the same message once + key = { + "title": message, + "path": uri, + "line_number": get_complex(location, "physicalLocation.region.startLine"), + } + key = hashlib.sha256(json.dumps(key).encode("utf-8")).digest() + + if key not in processed: + logger.debug("New key for issue %s, adding.", message) + processed.add(key) + + file_path = get_complex(artifact_location, "uri") + file_path = cls.normalize_file_path(file_path) + + file = project_version.files.filter(path=file_path).first() + if not file: + logger.debug("File %s not found, skipping.", file_path) + continue + + # Create the issue + finding = Finding() + finding.title = message + finding.normalized_title = cls.normalize_title(message) + finding.state = WorkItemState.NEW + finding.file = file + finding.tool = tool + finding.project_version = project_version + + finding.file_line = get_complex( + location, "physicalLocation.region.startLine", None + ) + finding.severity_level = Finding.SeverityLevel.parse(level) + finding.analyst_severity_level = Finding.SeverityLevel.NOT_SPECIFIED + finding.confidence = Finding.ConfidenceLevel.NOT_SPECIFIED + + finding.created_by = user + finding.updated_by = user + + if Finding.objects.filter( + title=finding.title, + file=finding.file, + file_line=finding.file_line, + project_version=finding.project_version, + ).exists(): + logger.debug("Duplicate finding, skipping.") + continue + + finding.save() + + num_imported += 1 + + if num_imported: + logger.debug("SARIF file successfully imported.") + return True + else: + logger.debug("SARIF file processed, but no issues were found.") + return False + + @classmethod + def normalize_file_path(self, path): + """Normalizes a file path to be relative to the root.""" + logger.debug("normalize_file_path(%s)", path) + try: + result = path + if path.split("/")[2] == "package": + result = "/".join(path.split("/")[2:]) + logger.debug("Normalizing file path [%s] -> [%s]", path, result) + return result + except: + return path + + @classmethod + def normalize_title(self, title): + norm = { + r"^Bracket object notation with user input is present.*": "Bracket object notation", + r"^Object injection via bracket notation.*": "Object injection", + r"^`ref` usage found.*": "Use of `ref`", + } + for regex, replacement in norm.items(): + if re.match(regex, title, re.IGNORECASE): + return replacement + return title diff --git a/omega/triage-portal/src/triage/util/general.py b/omega/triage-portal/src/triage/util/general.py new file mode 100644 index 00000000..c202a325 --- /dev/null +++ b/omega/triage-portal/src/triage/util/general.py @@ -0,0 +1,63 @@ +import logging +from datetime import datetime + +from django.utils import timezone +from django.utils.dateparse import parse_date as django_parse_date +from packageurl import PackageURL + +logger = logging.getLogger(__name__) + + +def get_complex(obj, key, default_value=""): + """Get a value from the dictionary d by nested.key.value. + If keys contain periods, then use key=['a','b','c'] instead.""" + if not obj or not isinstance(obj, dict): + return default_value + _data = obj + try: + parts = key.split(".") if isinstance(key, str) else key + + for inner_key in parts: + _data = _data[inner_key] + return _data + except Exception: + return default_value + + +def modify_purl(purl: PackageURL, **kwargs) -> PackageURL: + """Modify a PackageURL by adding or replacing values in kwargs.""" + return PackageURL(**(purl.to_dict() | kwargs)) + + +def strtobool(value: str, default: bool) -> bool: + """Convert a string representation of truth to True or False. + True values are 'y', 'yes', 't', 'true', 'on', and '1'; + False values are 'n', 'no', 'f', 'false', 'off', and '0'. + Raises ValueError if the string is anything else. + """ + if isinstance(value, bool): + return value + value = str(value).lower() + if value in ("y", "yes", "t", "true", "on", "1"): + return True + elif value in ("n", "no", "f", "false", "off", "0"): + return False + return default + + +def parse_date(date_str: str) -> datetime: + """Converts a date string to a timezone-aware datetime object.""" + if date_str: + try: + parsed = django_parse_date(date_str) + if parsed: + parsed_dt = datetime(parsed.year, parsed.month, parsed.day) + if parsed_dt: + return timezone.make_aware(parsed_dt) + except Exception as msg: + logger.warning(f"Failed to parse date: {msg}") + return None + +def clamp(value, min_value, max_value): + """Clamp a value between a minimum and maximum value.""" + return max(min_value, min(float(value), max_value)) diff --git a/omega/triage-portal/src/triage/util/scim/scim.py b/omega/triage-portal/src/triage/util/scim/scim.py new file mode 100644 index 00000000..0ef47390 --- /dev/null +++ b/omega/triage-portal/src/triage/util/scim/scim.py @@ -0,0 +1,81 @@ +import uuid +from datetime import datetime, timezone + +from triage.models import Finding, Package, PackageVersion, Scan + + +class ScimManager: + def __init__(self): + self.evidence = {} + + def process_all_packages(self): + for package in Package.objects.filter(active=True): + self.process_package(package) + + def process_package(self, package: Package): + SIX_MONTHS_AGO = timezone.now() - datetime.timedelta(days=180) + latest_scans = ( + Scan.objects.filter(package_version__package=package, analysis_dt__gte=SIX_MONTHS_AGO) + .order_by("package_version", "-analysis_dt") + .distinct("package_version") + ) + evidence = {} + for scan in latest_scans: + self.process_scan(scan, evidence) + + def process_scan(self, scan: Scan, evidence: dict): + tool = scan.tool + tool_evidence = { + "id": f"https://scim.openssf.org/v1/tool/{tool.name}/{tool.version}", + "type": "tool", + "name": str(scan.tool), + "externalReferences": [ + { + "externalReferenceType": "OTHER", + "locator": f"https://omega.openssf.org/tool/{tool.name}/{tool.version}", + } + ], + } + if tool_evidence not in evidence: + evidence.append(tool_evidence) + + if scan.findings.filter(severity=Finding.SeverityLevel.VERY_HIGH).count() == 0: + evidence.append( + { + "id": uuid.uuid4().hex, + "type": "Claim", + "claimant": "https://scim.openssf.org/v1/tool/{tool.name}/{tool.version}", + "subjects": [scan.project_version.package_url], + "predicateType": "https://scim.openssf.org/v1/types/predicate/conformance", + "predicate": { + "requirement": f"https://omega.openssf.org/scim/requirement/{tool.name}/no-very-high-severity-findings", + }, + } + ) + + if ( + scan.findings.filter( + severity__in=[ + Finding.SeverityLevel.VERY_HIGH, + Finding.SeverityLevel.HIGH, + Finding.SeverityLevel.MEDIUM, + ] + ).count() + == 0 + ): + evidence.append( + { + "id": uuid.uuid4().hex, + "type": "Claim", + "claimant": "https://scim.openssf.org/v1/tool/{tool.name}/{tool.version}", + "subjects": [scan.project_version.package_url], + "predicateType": "https://scim.openssf.org/v1/types/predicate/conformance", + "predicate": { + "requirement": f"https://omega.openssf.org/scim/requirement/{tool.name}/no-medium-or-higher-severity-findings", + }, + } + ) + + package_version = scan.package_version + findings = Finding.objects.filter(package_version=package_version) + self.process_findings(findings) diff --git a/omega/triage-portal/src/triage/util/search_parser.py b/omega/triage-portal/src/triage/util/search_parser.py new file mode 100644 index 00000000..11fcde56 --- /dev/null +++ b/omega/triage-portal/src/triage/util/search_parser.py @@ -0,0 +1,245 @@ +import datetime +import logging + +import django.db.models.base +import pyparsing as pp +from django.db.models import Model, Q +from django.utils import timezone + +from triage.models import Finding, WikiArticle, WorkItemState + +logger = logging.getLogger(__name__) + + +def parse_query_to_Q(model: Model, query: str) -> Q: + """ + Parse a query string into a Q object. + """ + + # Define the grammar + assigned_to_clause = pp.Group( + pp.Keyword("assigned_to").suppress() + + pp.Literal(":").suppress() + + pp.Word(pp.alphanums).setResultsName("username") + ).setResultsName("assigned_to") + + priority_clause = pp.Group( + pp.Keyword("priority").suppress() + + pp.Literal(":").suppress() + + pp.one_of(["<", ">", "<=", ">=", "=="]).setResultsName("op") + + pp.Word(pp.nums).setResultsName("value") + ).setResultsName("priority") + + severity_clause = pp.Group( + pp.Keyword("severity").suppress() + + pp.Literal(":").suppress() + + pp.delimited_list( + pp.one_of( + [ + "critical", + "very high", + "vh", + "veryhigh", + "high", + "h", + "medium", + "m", + "low", + "very low", + "very_low", + "verylow", + "vl", + "informational", + "unknown", + ] + ) + ) + ).setResultsName("severity") + + updated_dt_clause = pp.Group( + pp.Keyword("updated").suppress() + + pp.Literal(":").suppress() + + pp.one_of(["<", ">", "<=", ">=", "=="]).setResultsName("op") + + pp.pyparsing_common.iso8601_date("datetime") + ).setResultsName("updated_dt") + + created_dt_clause = pp.Group( + pp.Keyword("created").suppress() + + pp.Literal(":").suppress() + + pp.one_of(["<", ">", "<=", ">=", "=="]).setResultsName("op") + + ( + pp.pyparsing_common.iso8601_date("datetime") + ^ ( + pp.one_of(["@today"]).setResultsName("anchor") + + pp.one_of(["+", "-"]).setResultsName("anchor_op") + + pp.Word(pp.nums).setResultsName("anchor_value") + ) + ) + ).setResultsName("created_dt") + + state_clause = pp.Group( + pp.Keyword("state").suppress() + + pp.Literal(":").suppress() + + pp.delimited_list( + pp.one_of( + [str(c[0]) for c in WorkItemState.choices] + + [str(c[1]) for c in WorkItemState.choices], + caseless=True, + ) + ).setResultsName("states") + ).setResultsName("state") + + purl_clause = pp.Group( + pp.Keyword("purl").suppress() + + pp.Literal(":").suppress() + + pp.Word(pp.alphanums + ":@/?=-.").setResultsName("purl") + ).setResultsName("purl") + + other_clause = pp.Word(pp.printables).setResultsName("text_search") + + available_attributes = [ + getattr(model, key).field.name + for key in dir(model) + if isinstance(getattr(model, key), django.db.models.query_utils.DeferredAttribute) + ] + + parser_elements = [] + if "assigned_to" in available_attributes: + parser_elements.append(assigned_to_clause) + if "severity_level" in available_attributes: + parser_elements.append(severity_clause) + if "created_at" in available_attributes: + parser_elements.append(created_dt_clause) + if "updated_at" in available_attributes: + parser_elements.append(updated_dt_clause) + if "priority" in available_attributes: + parser_elements.append(priority_clause) + if "state" in available_attributes: + parser_elements.append(state_clause) + if "package_url" in available_attributes: + parser_elements.append(purl_clause) + if model == Finding: # Special case for foreign key + parser_elements.append(purl_clause) + + parser_elements.append(other_clause) + parser_elements = list(dict.fromkeys(parser_elements)) # Unique only + + clause = parser_elements[0] + + if len(parser_elements) > 1: + for element in parser_elements[1:]: + clause |= element + + full_clause = pp.OneOrMore(clause) + + # Parse the query + try: + results = full_clause.parse_string(query) + except: + logger.error("Failed to parse query: %s", query) + return None + + # Assemble the Q object + q = Q() + if results.assigned_to: + q = q & Q(assigned_to__username=results.assigned_to.username) + + if results.severity: + severities = map(Finding.SeverityLevel.parse, results.severity.asList()) + q = q & Q(severity_level__in=list(severities)) + + if results.state: + states = map(WorkItemState.parse, results.state.asList()) + q = q & Q(state__in=list(states)) + + # Handle updated:$op$date or updated:$op@today[+-]$num + if results.updated_dt: + if results.updated_dt.datetime: + target = results.updated_dt.datetime + elif results.updated_dt.anchor: + if results.updated_dt.anchor == "@today": + target = timezone.now() + if results.updated_dt.anchor_op == "-": + target -= datetime.timedelta(days=int(results.updated_dt.anchor_value)) + elif results.updated_dt.anchor_op == "+": + target += datetime.timedelta(days=int(results.updated_dt.anchor_value)) + else: + raise ValueError("Unknown anchor: %s" % results.updated_dt.anchor) + else: + raise ValueError("Unknown updated_dt: %s" % results.updated_dt) + + if results.updated_dt.op == "<": + q = q & Q(updated_at__lt=target) + elif results.updated_dt.op == ">": + q = q & Q(updated_at__gt=target) + elif results.updated_dt.op == "<=": + q = q & Q(updated_at__lte=target) + elif results.updated_dt.op == ">=": + q = q & Q(updated_at__gte=target) + elif results.updated_dt.op == "==": + q = q & Q(updated_at__exact=target) + + if results.created_dt: + if results.created_dt.datetime: + target = results.created_dt.datetime + elif results.created_dt.anchor: + if results.created_dt.anchor == "@today": + target = timezone.now() + if results.created_dt.anchor_op == "-": + target -= datetime.timedelta(days=int(results.created_dt.anchor_value)) + elif results.created_dt.anchor_op == "+": + target += datetime.timedelta(days=int(results.created_dt.anchor_value)) + else: + raise ValueError("Unknown anchor: %s" % results.created_dt.anchor) + else: + raise ValueError("Unknown created_dt: %s" % results.created_dt) + + if results.created_dt.op == "<": + q = q & Q(created_at__lt=target) + elif results.created_dt.op == ">": + q = q & Q(created_at__gt=target) + elif results.created_dt.op == "<=": + q = q & Q(created_at__lte=target) + elif results.created_dt.op == ">=": + q = q & Q(created_at__gte=target) + elif results.created_dt.op == "==": + q = q & Q(created_at__exact=target) + + if results.priority: + if results.priority.op == "<": + q = q & Q(priority__lt=results.priority.value) + elif results.priority.op == ">": + q = q & Q(priority__gt=results.priority.value) + elif results.priority.op == "<=": + q = q & Q(priority__lte=results.priority.value) + elif results.priority.op == ">=": + q = q & Q(priority__gte=results.priority.value) + elif results.priority.op == "==": + q = q & Q(priority__exact=results.priority.value) + + if results.purl: + if "project_version" in available_attributes: + q = q & ( + Q(project_version__project__package_url=results.purl.purl) + | Q(project_version__package_url=results.purl.purl) + ) + if "package_url" in available_attributes: + q = q & Q(package_url=results.purl.purl) + + # Handle full text search a little differently + if results.text_search: + text_qq = Q() + if "project_version" in available_attributes: + text_qq |= Q(project_version__project__name__icontains=results.text_search) + if "title" in available_attributes: + text_qq |= Q(title__icontains=results.text_search) + if "description" in available_attributes: + text_qq |= Q(description__icontains=results.text_search) + + if model == WikiArticle: # @HACK: Special case for foreign key + text_qq |= Q(current__content__icontains=results.text_search) + + q = q & text_qq + + logger.debug("Query: %s", q) + return q diff --git a/omega/triage-portal/src/triage/util/source_viewer/__init__.py b/omega/triage-portal/src/triage/util/source_viewer/__init__.py new file mode 100644 index 00000000..f9683e9d --- /dev/null +++ b/omega/triage-portal/src/triage/util/source_viewer/__init__.py @@ -0,0 +1,130 @@ +import logging + +from django.db.models.query import QuerySet + +logger = logging.getLogger(__name__) + +""" +Example: +input: +["/foo", "/foo/bar", "/foo/bar/baz"] +output: +[{ + id: "/foo" + full_path: "/foo" +] + +""" + + +def path_to_graph(files: QuerySet, package_url, separator="/", root=None): + """ + Converts a list of paths into a graph suitable for jstree. + + Args: + paths: list of paths + separator: path separator + root: root directory to pin the graph to + + Returns: + a list of dictionaries containing the relevant + fields for jstree. + """ + if not files: + return [] + + result = [] + seen_nids = set() + if root: + result.append( + { + "id": root, # TODO minimize this via a lookup table + "full_path": "#", + "text": root, + "parent": "#", + "package_url": None, + "path": "/", + "file_id": None, + "icon": "fa fa-folder", + } + ) + else: + root = "#" + + for file in files: + path = file.path + if not isinstance(path, str) or not path or path.startswith("pkg:"): + logger.debug("Ignoring invalid path [%s]", path) + continue + + if not path.startswith(separator): + path = separator + path + + path_parts = path.split(separator)[1:] + + logging.debug(f"Analyzing: %s", path_parts) + for (part_id, part) in enumerate(path_parts): + if part_id == 0: + parent_id = root + node_id = part + else: + parent_id = separator.join(path_parts[:part_id]) + node_id = separator.join(path_parts[: (part_id + 1)]) + node_name = part + + if node_name and node_id not in seen_nids: + result.append( + { + "id": node_id, # TODO minimize this via a lookup table + "full_path": node_id, + "text": node_name, + "parent": parent_id, + "package_url": package_url, + "file_uuid": file.uuid, + "li_attr": {"package_url": package_url}, + "path": node_id, + "icon": get_icon_for_path(node_name, part_id == len(path_parts)), + } + ) + seen_nids.add(node_id) + return result + + +def get_icon_for_path(path: str, is_leaf_node: bool) -> str: + # if not is_leaf_node: + # return "fa fa-folder" + + icon_map = { + "application/javascript": "fa fa-code", + "text/x-python": "fa fa-code", + "application/json": "fa fa-code", + "text/html": "fab fa-html5", + "text/css": "fab fa-css3", + "text/markdown": "fab fa-markdown", + "text/plain": "fas fa-file-alt", + "application/pdf": "fas fa-file-pdf", + "application/zip": "far fa-file-archive", + "application/x-tar": "far fa-file-archive", + "text/csv": "fas fa-file-csv", + } + extension_map = { + ".cs": "fa fa-code", + ".log": "far fa-file-alt", + ".gz": "fa fa-file-archive", + ".error": "fas fa-exclamation-triangle", + ".sarif": "fas fa-bug", + } + import mimetypes + + for mime_type, css in icon_map.items(): + if mimetypes.guess_type(path)[0] == mime_type: + return css + + for extension, css in extension_map.items(): + if path.endswith(extension): + return css + + if "." not in path: + return "far fa-folder-open" + + return "fa fa-file-alt" # fallback, default diff --git a/omega/triage-portal/src/triage/util/source_viewer/pathsimilarity.py b/omega/triage-portal/src/triage/util/source_viewer/pathsimilarity.py new file mode 100644 index 00000000..07be428f --- /dev/null +++ b/omega/triage-portal/src/triage/util/source_viewer/pathsimilarity.py @@ -0,0 +1,134 @@ +import math +import os +import logging +from typing import Optional, List + +logger = logging.getLogger(__name__) + +class PathSimilarity: + def __init__(self): + raise NotImplementedError("This class is not intended to be instantiated.") + + @classmethod + def _normalize_path(cls, path: str) -> str: + """ + Attempts to normalize a path to aid searching. The results + are not necessarily valid paths (i.e. case insensitivity). + """ + if not path: + return None + path = path.replace("\\", "/") + + if not path.startswith("/"): + path = "/" + path + + if path.endswith("/"): + path = path[:-1] + + path = path.strip().lower() + return path + + + @classmethod + def get_path_similarity(cls, path1: str, path2: str) -> float: + """Estimates how similar the two paths are. + + Args: + path1: The first path to compare. + path2: The second path to compare. + + Returns: + A float between 0 and 1 indicating how similar the two paths are. + """ + logger.debug('get_path_similarity(%s, %s)', path1, path2) + if path1 == path2: + return 1.0 + + path1 = cls._normalize_path(path1) + path2 = cls._normalize_path(path2) + + if ( + not path1 # Invalid + or not path2 # Invalid + or path1.startswith("pkg:") # Not a path + or path2.startswith("pkg:") # Not a path + ): + return 0.0 + + # The paths must share the same basename + if os.path.basename(path1) != os.path.basename(path2): + return 0.0 + + # If one is a suffix of the other, then it's the best we can do. + if path1.endswith(path2) or path2.endswith(path1): + return 0.80 + + longest_suffix = cls.get_longest_common_suffix(path1, path2) + if longest_suffix: + suffix_dirs = longest_suffix.count('/') + 1 + min_common_dirs = min(path1.count('/'), path2.count('/')) + 1 + if suffix_dirs == min_common_dirs: + return 0.90 + else: + return min(math.sqrt(suffix_dirs / min_common_dirs), 0.90) + else: + return 0.0 + + @classmethod + def get_longest_common_suffix(cls, path1: str, path2: str) -> str: + """ + Calculate the longest common suffix of two strings. + Since these strings are going to be relatively small (path lengths), + we'll use a simple naiive algorithm. + + A common suffix is defined as the longest string that is a suffix of + both strings, but is also the start of a filename or directory. This + means that "foo.txt" is the common suffix of "/bar/foo.txt" and "/quux/foo.txt", + but is not the common suffix of "/barfoo.txt" and "/quux/foo.txt". + + Args: + path1: The first path to compare. + path2: The second path to compare. + + Returns: + The longest common suffix of the two paths, or None if there + is no common suffix. + """ + # Simplify the algorithm to reduce copy/paste. + cases = [(path1, path2), (path2, path1)] + longest_common_suffix = None + + for case in cases: + for index in range(len(case[0]), 0, -1): + subpath = case[0][index:] # Progressively longer suffixes + + is_suffix = case[1].endswith(subpath) + is_dir = subpath.startswith('/') or (index > 0 and case[0][index-1] == '/') + is_longest = index > 0 and case[0][index-1] == '/' + + if is_suffix and is_dir and is_longest: + longest_common_suffix = subpath + + return longest_common_suffix + + @classmethod + def find_most_similar_path(cls, target_paths: List[str], path: str) -> Optional[str]: + """ + Finds the path in the list that is most similar to the given path. + The similarity is calculated using the get_path_similarity function. + + Args: + target_paths: The list of paths to compare against. + path: The path to compare. + + Returns: + The path in the list that is most similar to the given path, or None + """ + best_similarity = 0.0 + best_path = None + for target in target_paths: + similarity = cls.get_path_similarity(target, path) + if similarity > best_similarity: + best_similarity = similarity + best_path = target + return best_path diff --git a/omega/triage-portal/src/triage/util/source_viewer/viewer.py b/omega/triage-portal/src/triage/util/source_viewer/viewer.py new file mode 100644 index 00000000..2fcc1636 --- /dev/null +++ b/omega/triage-portal/src/triage/util/source_viewer/viewer.py @@ -0,0 +1,96 @@ +import logging +import os +import shutil +import subprocess +import tempfile +from typing import Callable, List + +from django.core.cache import cache + +from core.settings import OSSGADGET_PATH +from triage.util.source_viewer.pathsimilarity import PathSimilarity + +logger = logging.getLogger(__name__) + +""" +Usage: +u = SourceViewer("pkg:npm/left-pad@1.3.0") +file = u.get_file("/index.js") +files = u.find_files(u => u.name == "index.js") +file = u.get_files() +""" + + +class SourceViewer: + def __init__(self, package_url): + self.package_url = str(package_url) + + def load_if_needed(self): + """Call OSS-Download to retrieve the package, extract it, and the load it into memory.""" + if cache.get(f"sv_{self.package_url}_exists") == 1: + return + + logger.debug("Loading source for package %s", self.package_url) + + with tempfile.TemporaryDirectory() as temp_directory: + res = subprocess.run( + ["./oss-download", "-e", "-x", temp_directory, self.package_url], + capture_output=True, + cwd=OSSGADGET_PATH, + ) + if res.returncode != 0 or not os.listdir(temp_directory): + logger.debug("Failed to load source for package %s", self.package_url) + raise Exception("Failed to download package") + + cache_updates = { + f"sv_{self.package_url}_exists": 1, + f"sv_{self.package_url}_files": set(), + } + for root, dirs, files in os.walk(temp_directory): + for file in files: + full_path = os.path.join(root, file) + relative_path = full_path[len(temp_directory) + 1 :].replace("\\", "/") + cache_updates[f"sv_{self.package_url}_files"].add(relative_path) + with open(full_path, "rb") as f: + file_cache_key = f"sv_{self.package_url}_{relative_path}" + cache_updates[file_cache_key] = f.read() + logger.debug( + "Adding %d entries to cache", len(cache_updates[f"sv_{self.package_url}_files"]) + ) + cache.set_many(cache_updates, timeout=60 * 60 * 8) + self._is_loaded = True + + def get_file(self, file_path: str) -> dict: + logger.debug("get_file(%s)", file_path) + if not file_path: + return None + + self.load_if_needed() + + target_path = PathSimilarity.find_most_similar_path(self.get_file_list(), file_path) + if target_path: + return { + "path": target_path, + "content": cache.get(f"sv_{self.package_url}_{target_path}"), + } + else: + return None + + def get_file_list(self): + self.load_if_needed() + return cache.get(f"sv_{self.package_url}_files") + + def get_files(self): + return self.find_files(lambda u, c: True) + + def find_files(self, lambda_filter: Callable[[str], str]) -> List[dict]: + self.load_if_needed() + + file_list = cache.get(f"sv_{self.package_url}_files") + for file_path in file_list: + print(file_path) + if lambda_filter(file_path): + yield { + "path": file_path, + "content": cache.get(f"sv_{self.package_url}_{file_path}"), + } diff --git a/omega/triage-portal/src/triage/views/attachments.py b/omega/triage-portal/src/triage/views/attachments.py new file mode 100644 index 00000000..b43807a1 --- /dev/null +++ b/omega/triage-portal/src/triage/views/attachments.py @@ -0,0 +1,27 @@ +import uuid + +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.http import require_http_methods + +from triage.models import Attachment + + +@login_required +@require_http_methods(["GET"]) +def download_attachment(request: HttpRequest, attachment_uuid: uuid.UUID) -> HttpResponse: + """Downloads an attachment + + Params: + attachment_uuid: UUID of the attachment to download. + """ + attachment = get_object_or_404(Attachment, uuid=attachment_uuid) + return HttpResponse( + attachment.content, + content_type=attachment.content_type, + headers={ + "Content-Disposition": f"attachment; filename={attachment.filename}", + }, + ) diff --git a/omega/triage-portal/src/triage/views/cases.py b/omega/triage-portal/src/triage/views/cases.py new file mode 100644 index 00000000..38a56a1b --- /dev/null +++ b/omega/triage-portal/src/triage/views/cases.py @@ -0,0 +1,120 @@ +import json +import os +import uuid +from base64 import b64encode +from typing import Any, List +from uuid import UUID + +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import get_object_or_404, render +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from packageurl import PackageURL + +from triage.models import Case, Project, ProjectVersion, WorkItemState +from triage.util.azure_blob_storage import ToolshedBlobStorageAccessor +from triage.util.finding_importers.sarif_importer import SARIFImporter +from triage.util.general import parse_date +from triage.util.search_parser import parse_query_to_Q +from triage.util.source_viewer import path_to_graph +from triage.util.source_viewer.viewer import SourceViewer + + +@login_required +@require_http_methods(["GET"]) +def show_cases(request: HttpRequest) -> HttpResponse: + """Shows cases based on a query. + + Params: + q: query to search for, or all findings if not provided + """ + query = request.GET.get("q", "").strip() + cases = Case.objects.all() # Default + if query: + query_object = parse_query_to_Q(Case, query) + if query_object: + cases = cases.filter(query_object) + + context = { + "query": query, + "cases": cases, + "case_states": WorkItemState.choices, + "reporting_partner": Case.CasePartner.choices, + } + + return render(request, "triage/case_list.html", context) + + +def show_case(request: HttpRequest, case_uuid: UUID) -> HttpResponse: + """Shows a case.""" + case = get_object_or_404(Case, uuid=str(case_uuid)) + context = { + "case": case, + "case_states": WorkItemState.choices, + "reporting_partners": Case.CasePartner.choices, + "users": get_user_model().objects.all(), + } + return render(request, "triage/case_show.html", context) + + +@login_required +@require_http_methods(["GET"]) +def new_case(request: HttpRequest) -> HttpResponse: + """Shows the new case form.""" + context = { + "case_states": WorkItemState.choices, + "reporting_partners": Case.CasePartner.choices, + "users": get_user_model().objects.all(), + } + return render(request, "triage/case_show.html", context) + + +@login_required +@require_http_methods(["POST"]) +def save_case(request: HttpRequest) -> HttpResponse: + case_uuid = request.POST.get("case_uuid") + if case_uuid is None or case_uuid == "": + case = Case() + case.created_by = request.user + else: + case = get_object_or_404(Case, uuid=case_uuid) + + case.title = request.POST.get("title") + case.state = request.POST.get("state") + case.description = request.POST.get("description") + + assigned_to = request.POST.get("assigned_to") + if assigned_to: + case.assigned_to = get_user_model().objects.get(username=assigned_to) + else: + case.assigned_to = None + + case.reported_to = request.POST.get("reported_to") + case.reporting_partner = request.POST.get("reporting_partner") + case.reporting_reference = request.POST.get("reporting_reference") + case.reported_dt = parse_date(request.POST.get("reported_dt")) + case.resolved_target_dt = parse_date(request.POST.get("resolved_target_dt")) + case.resolved_actual_dt = parse_date(request.POST.get("resolved_actual_dt")) + case.updated_by = request.user + + if not case.created_by: + case.created_by = request.user + + case.full_clean() + case.save() + + note_text = request.POST.get("note_text") + if note_text and note_text.strip(): + case.notes.create(content=note_text, created_by=request.user, updated_by=request.user) + + return HttpResponseRedirect(f"/cases/{case.uuid}") diff --git a/omega/triage-portal/src/triage/views/filters.py b/omega/triage-portal/src/triage/views/filters.py new file mode 100644 index 00000000..766913ad --- /dev/null +++ b/omega/triage-portal/src/triage/views/filters.py @@ -0,0 +1,106 @@ +from uuid import UUID + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseBadRequest, + HttpResponseNotFound, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_http_methods + +from triage.models import Filter +from triage.util.general import strtobool +from triage.util.search_parser import parse_query_to_Q + + +@require_http_methods(["GET"]) +def show_filters(request: HttpRequest) -> HttpResponse: + """Shows filters based on a query. + + Params: + q: query to search for, or all findings if not provided + """ + query = request.GET.get("q", "").strip() + filters = Filter.objects.all() # Default + if query: + query_object = parse_query_to_Q(Filter, query) + if query_object: + filters = filters.filter(query_object) + context = {"query": query, "filters": filters} + return render(request, "triage/filter_list.html", context) + + +@require_http_methods(["GET"]) +def new_filter(request: HttpRequest) -> HttpResponse: + """Show a form to create a new filter.""" + return render(request, "triage/filter_show.html") + + +@require_http_methods(["GET"]) +def show_filter(request: HttpRequest, filter_uuid: UUID) -> HttpResponse: + """Show a filter.""" + if filter_uuid: + filter = Filter.objects.get(uuid=str(filter_uuid)) + return render(request, "triage/filter_show.html", {"filter": filter}) + else: + return HttpResponseNotFound() + + +@login_required +@require_http_methods(["GET"]) +def execute_filter(request: HttpRequest) -> JsonResponse: + """Execute a filter.""" + filter_uuid = request.GET.get("filter_uuid") + if filter_uuid: + filter = Filter.objects.get(uuid=str(filter_uuid)) + filter.execute() + return JsonResponse({"status": "success"}) + else: + return HttpResponseBadRequest() + + +@login_required +@require_http_methods(["POST"]) +def delete_filter(request: HttpRequest) -> HttpResponse: + """Delete a filter.""" + filter_uuid = request.POST.get("filter_uuid") + if filter_uuid: + filter = get_object_or_404(Filter, uuid=str(filter_uuid)) + filter.delete() + return HttpResponseRedirect("/filter") + else: + return HttpResponseBadRequest() + + +@login_required +@require_http_methods(["POST"]) +def save_filter(request: HttpRequest) -> HttpResponse: + """Edit a filter.""" + filter_uuid = request.POST.get("filter_uuid") + if filter_uuid: + filter = Filter.objects.get(uuid=filter_uuid) + else: + filter = Filter() + filter.created_by = request.user + + filter.title = request.POST.get("title") + filter.condition = request.POST.get("condition") + filter.action = request.POST.get("action") + filter.active = strtobool(request.POST.get("active"), True) + filter.priority = int(request.POST.get("priority")) + filter.updated_by = request.user + + try: + filter.full_clean() + except ValidationError as e: + return render( + request, "triage/filter_edit.html", {"filter": filter, "error_messages": e.messages} + ) + filter.save() + + return HttpResponseRedirect(f"/filter/{filter.uuid}") diff --git a/omega/triage-portal/src/triage/views/findings.py b/omega/triage-portal/src/triage/views/findings.py new file mode 100644 index 00000000..8ef098e3 --- /dev/null +++ b/omega/triage-portal/src/triage/views/findings.py @@ -0,0 +1,320 @@ +import json +import logging +import os +from base64 import b64encode + +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + JsonResponse, +) +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.cache import cache_page +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from packageurl import PackageURL + +from triage.models import ( + Attachment, + Case, + File, + FileContent, + Finding, + ProjectVersion, + Scan, + WorkItemState, +) +from triage.util.azure_blob_storage import ToolshedBlobStorageAccessor +from triage.util.finding_importers.sarif_importer import SARIFImporter +from triage.util.general import clamp +from triage.util.search_parser import parse_query_to_Q +from triage.util.source_viewer import path_to_graph +from triage.util.source_viewer.viewer import SourceViewer + +logger = logging.getLogger(__name__) + + +@login_required +def show_findings(request: HttpRequest) -> HttpResponse: + """Shows findings based on a query. + + Params: + q: query to search for, or all findings if not provided + """ + query = request.GET.get("q", "").strip() + page_size = clamp(request.GET.get("page_size", 20), 10, 500) + page = clamp(request.GET.get("page", 1), 1, 1000) + + findings = Finding.active_findings.all() + + if query: + findings = Finding.objects.exclude(state=WorkItemState.DELETED) + query_object = parse_query_to_Q(Finding, query) + if query_object: + findings = findings.filter(query_object) + + findings = findings.select_related("project_version", "tool", "file") + findings = findings.order_by('-project_version__package_url', 'title', 'created_at') + paginator = Paginator(findings, page_size) + page_object = paginator.get_page(page) + + query_string = request.GET.copy() + if 'page' in query_string: + query_string.pop("page", None) + + context = {"query": query, "findings": page_object, "params": query_string.urlencode() } + + return render(request, "triage/findings_list.html", context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def show_upload(request: HttpRequest) -> HttpResponse: + """Show the upload form for findings (SARIF, etc.)""" + if request.method == "GET": + return render(request, "triage/findings_upload.html") + + if request.method == "POST": + package_url = request.POST.get("package_url") + files = request.FILES.getlist("sarif_file[]") + + if not files: + return HttpResponseBadRequest("No files provided") + + for file in files: + try: + importer = SARIFImporter.import_sarif_file( + package_url, json.load(file), request.user + ) + except: # pylint: disable=bare-except + logger.warning("Failed to import SARIF file", exc_info=True) + + return redirect("/findings/upload?status=success") + + +@login_required +def show_finding_by_uuid(request: HttpRequest, finding_uuid) -> HttpResponse: + finding = get_object_or_404(Finding, uuid=finding_uuid) + from django.contrib.auth.models import User + + assignee_list = User.objects.all() + context = {"finding": finding, "assignee_list": assignee_list} + return render(request, "triage/findings_show.html", context) + + +@login_required +@require_http_methods(["POST"]) +def api_update_finding(request: HttpRequest) -> JsonResponse: + """Updates a Finding.""" + + finding_uuid = request.POST.get("finding_uuid") + finding = get_object_or_404(Finding, uuid=finding_uuid) + # if not finding.can_edit(request.user): + # return HttpResponseForbidden() + + # Modify only these fields, if provided + permitted_fields = [ + "analyst_impact", + "confidence", + "analyst_severity_level", + "assigned_to", + "estimated_impact", + ] + is_modified = False + for field in permitted_fields: + if field in request.POST: + value = request.POST.get(field) + if field == "assigned_to": + if value == "$self": # Special case: set to current user + value = request.user + if value == "$clear": # Special case: clear the field + value = None + else: + value = get_user_model().objects.filter(username=value).first() + if value is None: + continue # No action, invalid user passed in + + if getattr(finding, field) != value: + setattr(finding, field, value) + is_modified = True + + if is_modified: + finding.save() + return JsonResponse({"status": "ok"}) + else: + return JsonResponse({"status": "ok, not modified"}) + + +@login_required +@cache_page(60 * 30) +def api_get_source_code(request: HttpRequest) -> JsonResponse: + """Returns the source code for a finding.""" + file_uuid = request.GET.get("file_uuid") + if file_uuid: + file = File.objects.filter(uuid=file_uuid).select_related("content").first() + return JsonResponse( + { + "file_contents": b64encode(file.content.data).decode("utf-8"), + "file_name": file.path, + "status": "ok", + } + ) + else: + logger.info("Source code not found for %s", file_uuid) + return JsonResponse({"status": "error", "message": "File not found"}, status=404) + + # return JsonResponse({"status": "error"}, status=500) + + +@login_required +@cache_page(60 * 5) +def api_get_files(request: HttpRequest) -> JsonResponse: + """Returns a list of files related to a finding.""" + project_version_uuid = request.GET.get("project_version_uuid") + project_version = get_object_or_404(ProjectVersion, uuid=project_version_uuid) + + source_graph = path_to_graph( + project_version.files.all(), + project_version.package_url, + separator="/", + root=str(project_version.package_url), + ) + + return JsonResponse({"data": source_graph, "status": "ok"}) + + +# @login_required +# def api_get_file_list(request: HttpRequest) -> JsonResponse: +# """Returns a list of files in a project.""" +# finding_uuid = request.GET.get("finding_uuid") +# finding = get_object_or_404(Finding, uuid=finding_uuid) +# source_code = finding.scan.get_file_list() +# source_graph = path_to_graph(source_code, finding.scan.project_version.package_url)# +# +# return JsonResponse({"data": source_graph, "status": "ok"}) + + +@login_required +def api_download_file(request: HttpRequest) -> HttpResponse: + """Returns a list of files in a project.""" + return HttpResponse("Not implemented.") + + +@login_required +def api_get_blob_list(request: HttpRequest) -> JsonResponse: + """Returns a list of files in a project.""" + finding_uuid = request.GET.get("finding_uuid") + finding = get_object_or_404(Finding, uuid=finding_uuid) + source_code = finding.scan.get_blob_list() + source_graph = path_to_graph( + map(lambda s: s.get("relative_path"), source_code), finding.scan.project_version.package_url + ) + + return JsonResponse({"data": source_graph, "status": "ok"}) + + +@csrf_exempt +@require_http_methods(["POST"]) +def api_add(request: HttpRequest) -> JsonResponse: + """Inserts data into the database. + + Required: + - sarif => the SARIF content (file contents) + - package_url => the package URL (must include version) + - scan_artifact => an archive of the content analyzed + """ + sarif = request.FILES.get("sarif") + if sarif is None: + return JsonResponse({"error": "No sarif provided"}) + sarif_content = json.load(sarif) + + scan_artifact = request.FILES.get("scan_artifact") + if scan_artifact is None: + return JsonResponse({"error": "No scan_artifact provided"}) + + try: + package_url = PackageURL.from_string(request.POST.get("package_url")) + if package_url.version is None: + raise ValueError("Missing version") + except ValueError: + return JsonResponse({"error": "Invalid or missing package url"}) + + user = get_user_model().objects.get(pk=1) + SARIFImporter.import_sarif_file(package_url, sarif_content, user) + return JsonResponse({"success": True}) + + +@csrf_exempt +@require_http_methods(["POST"]) +def api_add_artifact(request: HttpRequest) -> JsonResponse: + """Inserts data into the database. + + Required: + - artifact_type => "scan" or "package" + - action => "add" or "replace" (only used for package) + - package_url => the package URL (must include version) + - artifact => file contents to import + """ + action = request.FILES.get("action") + if action not in ["add", "update"]: + return JsonResponse({"error": "Invalid action"}) + + artifact_type = request.FILES.get("artifact_type") + if artifact_type is None: + return JsonResponse({"error": "No artifact type provided"}) + + try: + package_url = PackageURL.from_string(request.POST.get("package_url")) + if package_url.version is None: + raise ValueError("Missing version") + except ValueError: + return JsonResponse({"error": "Invalid or missing package url"}) + + # TODO: Make this better + user = get_user_model().objects.get(pk=1) + + if artifact_type == "sarif": + sarif = request.FILES.get("sarif") + sarif_content = json.load(sarif) + SARIFImporter.import_sarif_file(package_url, sarif_content, user) + + """ + Source code files are usually archives that contain source code or other + artifacts. They'll be automatically extracted during the import process. They + are associated with a ProjectVersion""" + if "source_code" in request.FILES: + source_code = request.FILES.get("source_code") + source_code_content = source_code.read() + FileImporter.import_artifact(package_url, source_code_content, user) + + return JsonResponse({"success": True}) + + +def api_upload_attachment(request: HttpRequest) -> JsonResponse: + """Handles uploads (attachments)""" + target_type = request.POST.get("target_type") + target_uuid = request.POST.get("target_uuid") + if target_uuid is None or target_uuid == "": + return JsonResponse({"error": "No target_uuid provided"}) + + if target_type == "case": + obj = get_object_or_404(Case, uuid=target_uuid) + else: + return JsonResponse({"error": "Invalid target_type"}) + + attachments = request.FILES.getlist("attachment") + results = [] + for attachment in attachments: + new_attachment = obj.attachments.create( + filename=attachment.name, + content_type=attachment.content_type, + content=attachment.read(), + ) + results.append({"filename": new_attachment.filename, "uuid": new_attachment.uuid}) + + return JsonResponse({"success": True, "attachments": results}) diff --git a/omega/triage-portal/src/triage/views/home.py b/omega/triage-portal/src/triage/views/home.py new file mode 100644 index 00000000..ee0e8cd2 --- /dev/null +++ b/omega/triage-portal/src/triage/views/home.py @@ -0,0 +1,35 @@ +from datetime import timedelta + +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.utils import timezone + +from triage.models import Case, Finding, ToolDefect + + +@login_required +def home(request: HttpRequest) -> HttpResponse: + finding_last_updated = Finding.objects.all().order_by("-updated_at").first().created_at + case_last_updated = Case.objects.all().order_by("-updated_at").first().created_at + my_work = { + "num_cases": Case.objects.filter(assigned_to=request.user).count(), + "num_findings": Finding.objects.filter(assigned_to=request.user).count(), + "num_tool_defects": ToolDefect.objects.filter(assigned_to=request.user).count(), + } + metrics = { + "num_findings": Finding.objects.count(), + "num_active_findings": Finding.active_findings.count(), + "num_new_findings": Finding.active_findings.filter( + created_at__gt=timezone.now() - timedelta(days=7) + ).count(), + } + context = { + "finding_last_updated": finding_last_updated, + "case_last_updated": case_last_updated, + "my_work": my_work, + "user": request.user, + "metrics": metrics, + "last_week": timezone.now() - timedelta(days=7), + } + return render(request, "triage/home.html", context) diff --git a/omega/triage-portal/src/triage/views/tool_defect.py b/omega/triage-portal/src/triage/views/tool_defect.py new file mode 100644 index 00000000..bcff014a --- /dev/null +++ b/omega/triage-portal/src/triage/views/tool_defect.py @@ -0,0 +1,115 @@ +import json +import os +import uuid +from base64 import b64encode +from typing import Any, List + +from django.contrib.auth.decorators import login_required +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import get_object_or_404, render +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from packageurl import PackageURL + +from triage.models import ( + Case, + Finding, + Note, + Project, + ProjectVersion, + Tool, + ToolDefect, + WorkItemState, +) +from triage.util.azure_blob_storage import ToolshedBlobStorageAccessor +from triage.util.finding_importers.sarif_importer import SARIFImporter +from triage.util.search_parser import parse_query_to_Q +from triage.util.source_viewer import path_to_graph +from triage.util.source_viewer.viewer import SourceViewer + + +@login_required +def show_tool_defects(request: HttpRequest) -> HttpResponse: + """Shows tool_defectsbased on a query. + + Params: + q: query to search for, or all findings if not provided + """ + query = request.GET.get("q", "").strip() + tool_defects = ToolDefect.active_tool_defects.all() + + if query: + tool_defects = ToolDefect.objects.exclude(state=WorkItemState.DELETED) + query_object = parse_query_to_Q(ToolDefect, query) + if query_object: + tool_defects = tool_defects.filter(query_object) + + context = { + "query": query, + "tool_defects": tool_defects, + "tool_defect_states": WorkItemState.choices, + } + + return render(request, "triage/tool_defect_list.html", context) + + +@login_required +@never_cache +def show_tool_defect(request: HttpRequest, tool_defect_uuid: uuid.UUID) -> HttpResponse: + """Shows a tool defect.""" + tool_defect = get_object_or_404(ToolDefect, uuid=tool_defect_uuid) + context = { + "tool_defect": tool_defect, + "tools": Tool.objects.filter(active=True), + "tool_defect_states": WorkItemState.choices, + } + return render(request, "triage/tool_defect_show.html", context) + + +@login_required +def show_add_tool_defect(request: HttpRequest) -> HttpResponse: + """Shows the add tool defect form.""" + finding_uuid = request.GET.get("finding_uuid") + finding = get_object_or_404(Finding, uuid=finding_uuid) + c = { + "tools": Tool.objects.filter(active=True), + "tool_defect_states": WorkItemState.choices, + "finding": finding, + } + return render(request, "triage/tool_defect_show.html", c) + + +@login_required +@require_http_methods(["POST"]) +def save_tool_defect(request: HttpRequest) -> HttpResponse: + """Saves a tool defect.""" + + action = request.POST.get("action") + if action == "create": + tool_defect = ToolDefect() + else: + tool_defect = get_object_or_404(ToolDefect, uuid=request.POST["uuid"]) + + tool_defect.tool = get_object_or_404(Tool, uuid=request.POST["tool"]) + tool_defect.title = request.POST["title"] + tool_defect.state = request.POST["state"] + tool_defect.description = request.POST["description"] + note_content = request.POST.get("note_content") + tool_defect.save() + + if note_content and note_content.strip(): + note = Note(content=note_content, created_by=request.user, updated_by=request.user) + note.save() + tool_defect.notes.add(note) + tool_defect.save() + + return HttpResponseRedirect(f"/tool_defect/{tool_defect.uuid}") diff --git a/omega/triage-portal/src/triage/views/wiki.py b/omega/triage-portal/src/triage/views/wiki.py new file mode 100644 index 00000000..b880998c --- /dev/null +++ b/omega/triage-portal/src/triage/views/wiki.py @@ -0,0 +1,151 @@ +import json +import os +import uuid +from base64 import b64encode +from typing import Any, List +from uuid import UUID + +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.text import slugify +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from packageurl import PackageURL + +from triage.models import ( + Case, + Project, + ProjectVersion, + WikiArticle, + WikiArticleRevision, + WorkItemState, +) +from triage.util.azure_blob_storage import ToolshedBlobStorageAccessor +from triage.util.finding_importers.sarif_importer import SARIFImporter +from triage.util.general import parse_date +from triage.util.search_parser import parse_query_to_Q +from triage.util.source_viewer import path_to_graph +from triage.util.source_viewer.viewer import SourceViewer + + +@login_required +@require_http_methods(["GET"]) +def home(request: HttpRequest) -> HttpResponse: + return HttpResponseRedirect("/wiki/home") + + +@login_required +@require_http_methods(["GET"]) +@never_cache +def show_wiki_article_list(request: HttpRequest) -> HttpResponse: + """Shows all wiki articles.""" + wiki_articles = WikiArticle.active_wiki_articles.all() + + query = request.GET.get("q", "").strip() + wiki_articles = WikiArticle.objects.all() # Default + if query: + query_object = parse_query_to_Q(WikiArticle, query) + if query_object: + wiki_articles = wiki_articles.filter(query_object) + + context = {"query": query, "wiki_articles": wiki_articles} + return render(request, "triage/wiki_list.html", context) + + +@login_required +@require_http_methods(["GET"]) +def show_wiki_article( + request: HttpRequest, slug: str, template: str = "triage/wiki_show.html" +) -> HttpResponse: + """Shows a wiki article (current revision).""" + article = WikiArticle.objects.filter(slug=slug).first() + if article: + context = { + "wiki_article": article, + "wiki_article_revision": article.current, + "wiki_article_states": WorkItemState.choices, + } + return render(request, template, context) + else: + if slug == "new": + slug = "" + context = { + "wiki_article": { + "slug": slug, + "wiki_article_states": WorkItemState.choices, + } + } + return render(request, "triage/wiki_edit.html", context) + + +def edit_wiki_article(request: HttpRequest, slug: str) -> HttpResponse: + return show_wiki_article(request, slug, "triage/wiki_edit.html") + + +@login_required +@require_http_methods(["GET"]) +def show_wiki_article_revision( + request: HttpRequest, + slug: str, + wiki_article_revision_uuid: UUID, + template: str = "triage/wiki_show.html", +) -> HttpResponse: + """Shows a wiki article (current revision).""" + article_revision = get_object_or_404( + WikiArticleRevision, article__slug=slug, uuid=wiki_article_revision_uuid + ) + context = { + "wiki_article": article_revision.article, + "wiki_article_revision": article_revision, + "wiki_article_states": WorkItemState.choices, + } + return render(request, template, context) + + +def edit_wiki_article_revision( + request: HttpRequest, slug: str, wiki_article_revision_uuid: UUID +) -> HttpResponse: + return show_wiki_article_revision( + request, slug, wiki_article_revision_uuid, "triage/wiki_edit.html" + ) + + +@login_required +@require_http_methods(["POST"]) +def save_wiki_article(request: HttpRequest) -> HttpResponse: + wiki_article_uuid = request.POST.get("wiki_article_uuid") + if not wiki_article_uuid: + wiki_article = WikiArticle() + else: + wiki_article = get_object_or_404(WikiArticle, uuid=wiki_article_uuid) + + slug = request.POST.get("slug") + if not slug: + slug = slugify(request.POST.get("title")) + wiki_article.state = request.POST.get("state") + wiki_article.slug = slug + wiki_article.save() + + wiki_article_revision = WikiArticleRevision( + title=request.POST.get("title"), + content=request.POST.get("content"), + article=wiki_article, + change_comment=request.POST.get("change_comment"), + created_by=request.user, + updated_by=request.user, + ) + wiki_article_revision.full_clean() + wiki_article_revision.save() + + return redirect(wiki_article) diff --git a/omega/triage-portal/src/yarn.lock b/omega/triage-portal/src/yarn.lock new file mode 100644 index 00000000..56392877 --- /dev/null +++ b/omega/triage-portal/src/yarn.lock @@ -0,0 +1,187 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@fortawesome/fontawesome-free@^5.7.2": + version "5.15.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" + integrity sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg== + +"@popperjs/core@^2.10.1": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" + integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== + +ace-builds@^1.4.2: + version "1.4.13" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.4.13.tgz#186f42d3849ebcc6a48b93088a058489897514c1" + integrity sha512-SOLzdaQkY6ecPKYRDDg+MY1WoGgXA34cIvYJNNoBMGGUswHmlauU2Hy0UL96vW0Fs/LgFbMUjD+6vqzWTldIYQ== + +add@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/add/-/add-2.0.6.tgz#248f0a9f6e5a528ef2295dbeec30532130ae2235" + integrity sha1-JI8Kn25aUo7yKV2+7DBTITCuIjU= + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +blueimp-canvas-to-blob@3: + version "3.29.0" + resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz#d965f06cb1a67fdae207a2be56683f55ef531466" + integrity sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg== + +blueimp-file-upload@^10.1.0: + version "10.32.0" + resolved "https://registry.yarnpkg.com/blueimp-file-upload/-/blueimp-file-upload-10.32.0.tgz#897c91f813ccf4b6cd14db2da3da237ce433e04e" + integrity sha512-3WMJw5Cbfz94Adl1OeyH+rRpGwHiNHzja+CR6aRWPoAtwrUwvP5gXKo0XdX+sdPE+iCU63Xmba88hoHQmzY8RQ== + optionalDependencies: + blueimp-canvas-to-blob "3" + blueimp-load-image "5" + blueimp-tmpl "3" + +blueimp-load-image@5: + version "5.16.0" + resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-5.16.0.tgz#16b763f57e6725f8865517bca8eb7c3dc7d41e09" + integrity sha512-3DUSVdOtlfNRk7moRZuTwDmA3NnG8KIJuLcq3c0J7/BIr6X3Vb/EpX3kUH1joxUhmoVF4uCpDfz7wHkz8pQajA== + +blueimp-tmpl@3: + version "3.20.0" + resolved "https://registry.yarnpkg.com/blueimp-tmpl/-/blueimp-tmpl-3.20.0.tgz#bed897db362c70d3740e0ad1020ce84bfa15ceaa" + integrity sha512-g6ln9L+VX8ZA4WA8mgKMethYH+5teroJ2uOkCvcthy9Y9d9LrQ42OAMn+r3ECKu9CB+xe9GOChlIUJBSxwkI6g== + +bootstrap@^5.1.0: + version "5.1.3" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.1.3.tgz#ba081b0c130f810fa70900acbc1c6d3c28fa8f34" + integrity sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q== + +datatables.net-bs5@>=1.10.25, datatables.net-bs5@^1.11.3: + version "1.11.3" + resolved "https://registry.yarnpkg.com/datatables.net-bs5/-/datatables.net-bs5-1.11.3.tgz#939d0e66fbf518718a519534a88fc88cd29405b0" + integrity sha512-u0tosKUR1XNpXzxOOt2NInnNYayt7GQoG+OM1xPRhdkZ7ZBD4oNF8S0aKve8yvSUq/ZwTMh4WJeh80GdmrJAdQ== + dependencies: + datatables.net ">=1.10.25" + jquery ">=1.7" + +datatables.net-fixedheader-bs5@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/datatables.net-fixedheader-bs5/-/datatables.net-fixedheader-bs5-3.2.0.tgz#a5b180a34dded5e526fd7d8ba855b493af76f996" + integrity sha512-OT7RpaDWaOYzDScZwFk8KSsfIJVkGN6BLlpQy/KceDOHSwO7BQShETO1l7svQpoTOX9edMP3BQIxF03swUHMAA== + dependencies: + datatables.net-bs5 ">=1.10.25" + datatables.net-fixedheader ">=3.1.9" + jquery ">=1.7" + +datatables.net-fixedheader@>=3.1.9: + version "3.2.0" + resolved "https://registry.yarnpkg.com/datatables.net-fixedheader/-/datatables.net-fixedheader-3.2.0.tgz#5d4891942ecf2a66ff2dfb1be54802cfa6c5927e" + integrity sha512-p8GfXhOLBv94uFmgUSKthNw1Sd9szBMqEHHe0DwRsbBR/4WworYzaeysy45HPoBh1tL6abWZC7gW6I4nLGtzlQ== + dependencies: + datatables.net ">=1.10.25" + jquery ">=1.7" + +datatables.net-keytable-bs5@^2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/datatables.net-keytable-bs5/-/datatables.net-keytable-bs5-2.6.4.tgz#1adaabb5af4099f2c97dc55aa2f9d68cc31cc2a0" + integrity sha512-SHDgrWTHOtrPeuBHepla+GKx0SwpDc00M9zd0WlKtjpAK89TQBO5h0n4xKgycvjbBEA0XVAzyyW0ufE/OklELg== + dependencies: + datatables.net-bs5 ">=1.10.25" + datatables.net-keytable ">=2.6.2" + jquery ">=1.7" + +datatables.net-keytable@>=2.6.2: + version "2.6.4" + resolved "https://registry.yarnpkg.com/datatables.net-keytable/-/datatables.net-keytable-2.6.4.tgz#0cb355441a87d1c7aa6d230641157d17829892a2" + integrity sha512-84ldg8S3rAzHKJw4inz/a4lWUWaVhrSrH16+zO9sxUdBgT+rTTjx7aN5d3JwrdroEYB3z2uWfd0MOxNlNXeuUQ== + dependencies: + datatables.net ">=1.10.25" + jquery ">=1.7" + +datatables.net-select@^1.3.0: + version "1.3.3" + resolved "https://registry.yarnpkg.com/datatables.net-select/-/datatables.net-select-1.3.3.tgz#9259f70bf257e5d32198f38424d005808b78dc6d" + integrity sha512-M4e9Qx790IPt+tc+CLgk7gPram3i+M2OmhIkhIpp7RcZ2Ay4App4ouQZcEx3j1MTRIWxtOz47xjpWrwVfJ23YQ== + dependencies: + datatables.net "^1.10.15" + jquery ">=1.7" + +datatables.net@>=1.10.25, datatables.net@^1.10.15: + version "1.11.3" + resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.11.3.tgz#80e691036efcd62467558ee64c07dd566cb761b4" + integrity sha512-VMj5qEaTebpNurySkM6jy6sGpl+s6onPK8xJhYr296R/vUBnz1+id16NVqNf9z5aR076OGcpGHCuiTuy4E05oQ== + dependencies: + jquery ">=1.7" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +inconsolata@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/inconsolata/-/inconsolata-0.0.2.tgz#baa1433f43d42aa1edb26bd9ee87738e929250eb" + integrity sha1-uqFDP0PUKqHtsmvZ7odzjpKSUOs= + +jquery-contextmenu@^2.8.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/jquery-contextmenu/-/jquery-contextmenu-2.9.2.tgz#f9dc362e45871dda2e50fa45de2243e917446ced" + integrity sha512-6S6sH/08owDStC/7zNwcN366yR0ydX6PmMB0RnjLRQOp7Nc/rqwEHglshfHrrw2kdTev97GXwRXrayDUmToIOw== + dependencies: + jquery "^3.5.0" + +jquery@>=1.2.6, jquery@>=1.7, jquery@>=1.9.1, jquery@^3.3.1, jquery@^3.5.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" + integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== + +js-yaml@^3.13.0: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jstree-bootstrap-theme@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/jstree-bootstrap-theme/-/jstree-bootstrap-theme-1.0.1.tgz#7d5edc73a846e8da7f94f57a1cc5ddee9d9eab4b" + integrity sha1-fV7cc6hG6Np/lPV6HMXd7p2eq0s= + dependencies: + jquery ">=1.9.1" + +jstree@^3.3.11: + version "3.3.12" + resolved "https://registry.yarnpkg.com/jstree/-/jstree-3.3.12.tgz#cf206bc85dcf4a4664ed6617eaae3bd5983d8601" + integrity sha512-vHNLWkUr02ZYH7RcIckvhtLUtneWCVEtIKpIp2G9WtRh01ITv18EoNtNQcFG3ozM+oK6wp1Z300gSLXNQWCqGA== + dependencies: + jquery ">=1.9.1" + +select2@^4.1.0-rc.0: + version "4.1.0-rc.0" + resolved "https://registry.yarnpkg.com/select2/-/select2-4.1.0-rc.0.tgz#ba3cd3901dda0155e1c0219ab41b74ba51ea22d8" + integrity sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A== + +source-code-pro@^2.30.2: + version "2.38.0" + resolved "https://registry.yarnpkg.com/source-code-pro/-/source-code-pro-2.38.0.tgz#85c57689f7386bb9d0515fb00ba4845bfb7b485b" + integrity sha512-JMXu7l3XrLREG17eEwY66ANG9716WTu6OeNvZfRKQKANEvbSERDZjk5AYTHeV6owQNPQTeiiW3ri2Ou93XFGvg== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +tablesorter@^2.31.1: + version "2.31.3" + resolved "https://registry.yarnpkg.com/tablesorter/-/tablesorter-2.31.3.tgz#94c33234ba0e5d9efc5ba4e48651010a396c8b64" + integrity sha512-ueEzeKiMajDcCWnUoT1dOeNEaS1OmPh9+8J0O2Sjp3TTijMygH74EA9QNJiNkLJqULyNU0RhbKY26UMUq9iurA== + dependencies: + jquery ">=1.2.6" + +yarn@^1.13.0: + version "1.22.17" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.17.tgz#bf910747d22497b573131f7341c0e1d15c74036c" + integrity sha512-H0p241BXaH0UN9IeH//RT82tl5PfNraVpSpEoW+ET7lmopNC61eZ+A+IDvU8FM6Go5vx162SncDL8J1ZjRBriQ== From f52c02b438a66ab1b350d7964550388563c279cd Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sun, 4 Dec 2022 18:32:28 +0000 Subject: [PATCH 02/21] Move dev container to root of repo. Signed-off-by: Michael Scovetta --- .../.devcontainer => .devcontainer/triage-portal}/Dockerfile | 0 .../triage-portal}/devcontainer.json | 0 .../triage-portal}/docker-compose.yml | 0 .../triage-portal}/postcreate-initialize.sh | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {omega/triage-portal/.devcontainer => .devcontainer/triage-portal}/Dockerfile (100%) rename {omega/triage-portal/.devcontainer => .devcontainer/triage-portal}/devcontainer.json (100%) rename {omega/triage-portal/.devcontainer => .devcontainer/triage-portal}/docker-compose.yml (100%) rename {omega/triage-portal/.devcontainer => .devcontainer/triage-portal}/postcreate-initialize.sh (100%) diff --git a/omega/triage-portal/.devcontainer/Dockerfile b/.devcontainer/triage-portal/Dockerfile similarity index 100% rename from omega/triage-portal/.devcontainer/Dockerfile rename to .devcontainer/triage-portal/Dockerfile diff --git a/omega/triage-portal/.devcontainer/devcontainer.json b/.devcontainer/triage-portal/devcontainer.json similarity index 100% rename from omega/triage-portal/.devcontainer/devcontainer.json rename to .devcontainer/triage-portal/devcontainer.json diff --git a/omega/triage-portal/.devcontainer/docker-compose.yml b/.devcontainer/triage-portal/docker-compose.yml similarity index 100% rename from omega/triage-portal/.devcontainer/docker-compose.yml rename to .devcontainer/triage-portal/docker-compose.yml diff --git a/omega/triage-portal/.devcontainer/postcreate-initialize.sh b/.devcontainer/triage-portal/postcreate-initialize.sh similarity index 100% rename from omega/triage-portal/.devcontainer/postcreate-initialize.sh rename to .devcontainer/triage-portal/postcreate-initialize.sh From 7550d2cdb422a2ca32781a55e7d2a2d819054385 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sun, 4 Dec 2022 19:25:32 +0000 Subject: [PATCH 03/21] Fiddle with Codespaces to get it work in dir. Signed-off-by: Michael Scovetta --- .devcontainer/triage-portal/devcontainer.json | 8 ++++---- .devcontainer/triage-portal/docker-compose.yml | 4 ++-- .../triage-portal/postcreate-initialize.sh | 3 ++- .gitignore | 3 ++- omega/triage-portal/.vscode/launch.json | 2 +- omega/triage-portal/.vscode/settings.json | 4 ++-- omega/triage-portal/.vscode/tasks.json | 14 +++++++------- omega/triage-portal/src/requirements.txt | 2 +- 8 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.devcontainer/triage-portal/devcontainer.json b/.devcontainer/triage-portal/devcontainer.json index bd251a71..ac9d32e4 100644 --- a/.devcontainer/triage-portal/devcontainer.json +++ b/.devcontainer/triage-portal/devcontainer.json @@ -5,7 +5,7 @@ "name": "Python 3 & PostgreSQL", "dockerComposeFile": "docker-compose.yml", "service": "app", - "workspaceFolder": "/workspace", + "workspaceFolder": "/workspaces", // Set *default* container specific settings.json values on container create. "settings": { "sqltools.connections": [ @@ -34,7 +34,7 @@ "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", "python.testing.pytestPath": "/usr/local/py-utils/bin/pytest", - "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" + "python.defaultInterpreterPath": "${workspaceFolder}/alpha-omega/omega/triage-portal/.venv/bin/python" }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ @@ -49,10 +49,10 @@ 8000 ], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "bash .devcontainer/postcreate-initialize.sh", + "postCreateCommand": "find . && bash alpha-omega/.devcontainer/triage-portal/postcreate-initialize.sh", "remoteEnv": { "DJANGO_SETTINGS_MODULE": "core.settings", - "PYTHONPATH": "/workspaces/omega-triage-portal/src" + "PYTHONPATH": "/workspaces/alpha-omega/omega/triage-portal/src" }, // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode" diff --git a/.devcontainer/triage-portal/docker-compose.yml b/.devcontainer/triage-portal/docker-compose.yml index 9e0a600f..2fb9b54c 100644 --- a/.devcontainer/triage-portal/docker-compose.yml +++ b/.devcontainer/triage-portal/docker-compose.yml @@ -4,7 +4,7 @@ services: app: build: context: .. - dockerfile: .devcontainer/Dockerfile + dockerfile: triage-portal/Dockerfile args: # Update 'VARIANT' to pick a version of Python: 3, 3.10, 3.9, 3.8, 3.7, 3.6 # Append -bullseye or -buster to pin to an OS version. @@ -14,7 +14,7 @@ services: NODE_VERSION: "lts/*" volumes: - - ..:/workspace:cached + - ..:/workspace:cached init: true # Overrides default command so things don't shut down after the process ends. diff --git a/.devcontainer/triage-portal/postcreate-initialize.sh b/.devcontainer/triage-portal/postcreate-initialize.sh index 984d5837..5b444ecd 100644 --- a/.devcontainer/triage-portal/postcreate-initialize.sh +++ b/.devcontainer/triage-portal/postcreate-initialize.sh @@ -1,6 +1,7 @@ #!/bin/bash -ROOT="/workspaces/omega-triage-portal" +ROOT="/workspaces/alpha-omega/omega/triage-portal" +cd "$ROOT" # Create and activate the virtual environment echo "Creating virtual environment." diff --git a/.gitignore b/.gitignore index bc70c1a0..d1e00ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ *.env *.pyc omega/analyzer/worker/results -venv/* \ No newline at end of file +venv/* +/**/.venv/* \ No newline at end of file diff --git a/omega/triage-portal/.vscode/launch.json b/omega/triage-portal/.vscode/launch.json index f260e9a1..d7c7e547 100644 --- a/omega/triage-portal/.vscode/launch.json +++ b/omega/triage-portal/.vscode/launch.json @@ -8,7 +8,7 @@ "name": "Python: Django", "type": "python", "request": "launch", - "program": "${workspaceFolder}/src/manage.py", + "program": "${workspaceFolder}/omega/triage-portal/src/manage.py", "args": [ "runserver", "0.0.0.0:8000" diff --git a/omega/triage-portal/.vscode/settings.json b/omega/triage-portal/.vscode/settings.json index 23fc804c..e52d8489 100644 --- a/omega/triage-portal/.vscode/settings.json +++ b/omega/triage-portal/.vscode/settings.json @@ -17,9 +17,9 @@ "python.terminal.activateEnvInCurrentTerminal": true, "terminal.integrated.env.linux": { "DJANGO_SETTINGS_MODULE": "core.settings", - "PYTHONPATH": "${workspaceFolder}/src" + "PYTHONPATH": "${workspaceFolder}/omega/triage/portal/src" }, - "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.defaultInterpreterPath": "${workspaceFolder}/omega/triage-portal/.venv/bin/python", "python.linting.enabled": false, "python.linting.pylintPath": "pylint", "python.linting.pylintEnabled": false, diff --git a/omega/triage-portal/.vscode/tasks.json b/omega/triage-portal/.vscode/tasks.json index 2a5953ee..5b72615a 100644 --- a/omega/triage-portal/.vscode/tasks.json +++ b/omega/triage-portal/.vscode/tasks.json @@ -7,14 +7,14 @@ "label": "PyLint: Scan entire project", "type": "shell", "options": { - "cwd": "${workspaceFolder}/.venv/bin/" + "cwd": "${workspaceFolder}/omega/triage-portal/.venv/bin/" }, - "command": "${workspaceFolder}/.venv/bin/pylint", + "command": "${workspaceFolder}/omega/triage-portal/.venv/bin/pylint", "args": [ "--msg-template", "\"{path}:{line}:{column}:{category}:{symbol} - {msg}\"", { - "value": "--init-hook=\"import sys;sys.path.append('${workspaceFolder}/src')\"", + "value": "--init-hook=\"import sys;sys.path.append('${workspaceFolder}/omega/triage-portal/src')\"", "quoting": "strong" }, "--load-plugins", @@ -22,7 +22,7 @@ "--django-settings-module", "core.settings", "--exit-zero", - "${workspaceFolder}/src/triage" + "${workspaceFolder}/omega/triage-portal/src/triage" ], "presentation": { "reveal": "never", @@ -54,7 +54,7 @@ ], "problemMatcher": [], "options": { - "cwd": "${workspaceFolder}/src" + "cwd": "${workspaceFolder}/omega/triage-portal/src" }, }, { @@ -67,7 +67,7 @@ ], "problemMatcher": [], "options": { - "cwd": "${workspaceFolder}/src" + "cwd": "${workspaceFolder}/omega/triage-portal/src" }, }, { @@ -86,7 +86,7 @@ ], "problemMatcher": [], "options": { - "cwd": "${workspaceFolder}/src" + "cwd": "${workspaceFolder}/omega/triage-portal/src" }, }, ] diff --git a/omega/triage-portal/src/requirements.txt b/omega/triage-portal/src/requirements.txt index ce112a4a..365d06b7 100644 --- a/omega/triage-portal/src/requirements.txt +++ b/omega/triage-portal/src/requirements.txt @@ -47,7 +47,7 @@ pylint-plugin-utils==0.6 pyparsing==3.0.6 pytz==2021.3 PyYAML==6.0 -redis==4.0.2 +redis==3.5.3 regex==2021.11.10 requests==2.26.0 requests-oauthlib==1.3.0 From 26f681859aa65d166753e4ebe574437183227e3e Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Mon, 5 Dec 2022 00:18:20 +0000 Subject: [PATCH 04/21] Upgrade dependencies, tweak codespaces config. Signed-off-by: Michael Scovetta --- .devcontainer/triage-portal/devcontainer.json | 11 ++- .vscode/project.code-workspace | 11 +++ omega/triage-portal/.vscode/launch.json | 4 +- omega/triage-portal/src/core/settings.py | 3 +- omega/triage-portal/src/requirements.txt | 96 ++++++++++--------- 5 files changed, 75 insertions(+), 50 deletions(-) create mode 100644 .vscode/project.code-workspace diff --git a/.devcontainer/triage-portal/devcontainer.json b/.devcontainer/triage-portal/devcontainer.json index ac9d32e4..d8879322 100644 --- a/.devcontainer/triage-portal/devcontainer.json +++ b/.devcontainer/triage-portal/devcontainer.json @@ -48,8 +48,17 @@ "forwardPorts": [ 8000 ], + "portsAttributes": { + "8000": { + "label": "Triage Portal (Django)", + "protocol": "http", + "onAutoForward": "notify", + "requireLocalPort": false, + "elevateIfNeeded": false + } + }, // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "find . && bash alpha-omega/.devcontainer/triage-portal/postcreate-initialize.sh", + "postCreateCommand": "bash alpha-omega/.devcontainer/triage-portal/postcreate-initialize.sh", "remoteEnv": { "DJANGO_SETTINGS_MODULE": "core.settings", "PYTHONPATH": "/workspaces/alpha-omega/omega/triage-portal/src" diff --git a/.vscode/project.code-workspace b/.vscode/project.code-workspace new file mode 100644 index 00000000..09906959 --- /dev/null +++ b/.vscode/project.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "name": "triage-portal", + "path": "/workspaces/alpha-omega/omega/triage-portal" + }, + { + "path": "../.devcontainer" + } + ] +} \ No newline at end of file diff --git a/omega/triage-portal/.vscode/launch.json b/omega/triage-portal/.vscode/launch.json index d7c7e547..bfb02916 100644 --- a/omega/triage-portal/.vscode/launch.json +++ b/omega/triage-portal/.vscode/launch.json @@ -8,10 +8,10 @@ "name": "Python: Django", "type": "python", "request": "launch", - "program": "${workspaceFolder}/omega/triage-portal/src/manage.py", + "program": "src/manage.py", "args": [ "runserver", - "0.0.0.0:8000" + "0.0.0.0:8001" ], "django": true } diff --git a/omega/triage-portal/src/core/settings.py b/omega/triage-portal/src/core/settings.py index 58762faa..1ecde41d 100644 --- a/omega/triage-portal/src/core/settings.py +++ b/omega/triage-portal/src/core/settings.py @@ -20,10 +20,9 @@ DEBUG = to_bool(get_env_variable("DEBUG")) INTERNAL_IPS = [ - "127.0.0.1", ] -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['*'] # Application definition diff --git a/omega/triage-portal/src/requirements.txt b/omega/triage-portal/src/requirements.txt index 365d06b7..052eaabe 100644 --- a/omega/triage-portal/src/requirements.txt +++ b/omega/triage-portal/src/requirements.txt @@ -1,66 +1,72 @@ -asgiref==3.4.1 -astroid==2.9.0 -azure-core==1.21.1 -azure-storage-blob==12.9.0 -bandit==1.7.1 -black==21.11b1 -certifi==2021.10.8 -cffi==1.15.0 -charset-normalizer==2.0.9 -click==8.0.3 -cryptography==36.0.0 +asgiref==3.5.2 +astroid==2.12.13 +azure-core==1.26.1 +azure-storage-blob==12.14.1 +bandit==1.7.4 +black==22.10.0 +certifi==2022.9.24 +cffi==1.15.1 +charset-normalizer==2.1.1 +click==8.1.3 +cryptography==38.0.4 Deprecated==1.2.13 -Django==4.0.1 +dill==0.3.6 +Django==4.1.3 django-dotenv==1.4.2 -django-redis==5.1.0 -django-taggit==2.0.0 +django-redis==5.2.0 +django-taggit==3.1.0 +django_debug_toolbar==3.8.1 dodgy==0.2.1 -flake8==4.0.1 +flake8==6.0.0 flake8-polyfill==1.0.2 -gitdb==4.0.9 -GitPython==3.1.24 -idna==3.3 -isodate==0.6.0 +gitdb==4.0.10 +GitPython==3.1.29 +idna==3.4 +isodate==0.6.1 isort==5.10.1 lazy-object-proxy==1.6.0 -mccabe==0.6.1 -msrest==0.6.21 +Markdown==3.4.1 +mccabe==0.7.0 +msrest==0.7.1 mypy-extensions==0.4.3 -oauthlib==3.1.1 -packageurl-python==0.9.6 -pathspec==0.9.0 +oauthlib==3.2.2 +packageurl-python==0.10.4 +packaging==21.3 +pathspec==0.10.2 pbr==5.8.0 -pep8-naming==0.12.1 -platformdirs==2.4.0 -prospector==0.12.2 -psycopg2==2.9.2 -pycodestyle==2.8.0 +pep8-naming==0.13.2 +platformdirs==2.5.4 +poetry-semver==0.1.0 +prospector==1.8.2 +psycopg2==2.9.5 +pycodestyle==2.10.0 pycparser==2.21 pydocstyle==6.1.1 -pyflakes==2.4.0 -pylint==2.12.2 +pyflakes==3.0.1 +pylint==2.15.7 pylint-celery==0.3 pylint-common==0.2.5 -pylint-django==2.4.4 +pylint-django==2.5.3 pylint-flask==0.6 -pylint-plugin-utils==0.6 -pyparsing==3.0.6 -pytz==2021.3 +pylint-plugin-utils==0.7 +pyparsing==3.0.9 +pytz==2022.6 PyYAML==6.0 redis==3.5.3 -regex==2021.11.10 -requests==2.26.0 -requests-oauthlib==1.3.0 -requirements-detector==0.7 +regex==2022.10.31 +requests==2.28.1 +requests-oauthlib==1.3.1 +requirements-detector==1.0.3 setoptconf==0.3.0 setoptconf-tmp==0.3.1 six==1.16.0 smmap==5.0.0 snowballstemmer==2.2.0 -sqlparse==0.4.2 -stevedore==3.5.0 +sqlparse==0.4.3 +stevedore==4.1.1 toml==0.10.2 -tomli==1.2.2 -typing_extensions==4.0.1 -urllib3==1.26.7 -wrapt==1.13.3 +tomli==2.0.1 +tomlkit==0.11.6 +typing_extensions==4.4.0 +urllib3==1.26.13 +wrapt==1.14.1 From 824985ef09efa7c90550e1ab06e8d0f1accb4591 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Mon, 5 Dec 2022 00:21:28 +0000 Subject: [PATCH 05/21] Fix home page view bug when database is empty. Signed-off-by: Michael Scovetta --- omega/triage-portal/src/triage/views/home.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/omega/triage-portal/src/triage/views/home.py b/omega/triage-portal/src/triage/views/home.py index ee0e8cd2..458ca525 100644 --- a/omega/triage-portal/src/triage/views/home.py +++ b/omega/triage-portal/src/triage/views/home.py @@ -10,8 +10,12 @@ @login_required def home(request: HttpRequest) -> HttpResponse: - finding_last_updated = Finding.objects.all().order_by("-updated_at").first().created_at - case_last_updated = Case.objects.all().order_by("-updated_at").first().created_at + most_recent_finding = Finding.objects.all().order_by("-updated_at") + finding_last_updated = most_recent_finding.first().created_at if most_recent_finding else None + + most_recent_case = Case.objects.all().order_by("-updated_at") + case_last_updated = most_recent_case.first().created_at if most_recent_case else None + my_work = { "num_cases": Case.objects.filter(assigned_to=request.user).count(), "num_findings": Finding.objects.filter(assigned_to=request.user).count(), From 855741c907c6a71f32c306f88cab66554385802b Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Mon, 5 Dec 2022 00:33:48 +0000 Subject: [PATCH 06/21] Improve triage portal readme. Signed-off-by: Michael Scovetta --- omega/triage-portal/README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/omega/triage-portal/README.md b/omega/triage-portal/README.md index 9f140fa1..41a991b0 100644 --- a/omega/triage-portal/README.md +++ b/omega/triage-portal/README.md @@ -1,18 +1,30 @@ # Omega Triage Portal The Omega Triage Portal is a web-application that can help manage automated vulnerability reports. -It was designed for scale, (millions of projects, many millions of findings), but may also be -useful at lower scale. +It was designed for scale, (hundreds of thousands of projects, many millions of findings), +but may also be useful at lower scale. + +**The Portal is in early development, and is not ready for general use.** ## Getting Started -To get started using VS Code, start a [GitHub Codespace](https://github.com/features/codespaces) or -[Remote Container](https://code.visualstudio.com/docs/remote/containers). Once started, run -the `Python: Django` launch task and navigate to http://localhost:8000. +This extension can be used from GitHub Codespaces: + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?machine=basicLinux32gb&repo=426394209&ref=scovetta%2Fadd-triage-portal&location=WestUs2&devcontainer_path=.devcontainer%2Ftriage-portal%2Fdevcontainer.json) + +Once loaded, open the `.vscode/project.code-workspace` file and then click the `Open Workspace` +button. A new widow will open. This is needed because VS Code launch settings are nested +within the omega/triage-portal folder. + +You can then run the Django launch task to start the application. Navigate to + and enter the default credentials (admin/admin), then +navigate back to . ## Contributing -See [How to Contribute](#) +TBD ## Security +See SECURITY.md](https://github.com/ossf/alpha-omega/blob/main/SECURITY.md). + From 89b305ac95dc19e06cf463187ad92c3800a252f5 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Mon, 5 Dec 2022 00:39:59 +0000 Subject: [PATCH 07/21] Tweaked requirements to get around incompatible semvers. Signed-off-by: Michael Scovetta --- omega/triage-portal/src/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/omega/triage-portal/src/requirements.txt b/omega/triage-portal/src/requirements.txt index 052eaabe..dc5594af 100644 --- a/omega/triage-portal/src/requirements.txt +++ b/omega/triage-portal/src/requirements.txt @@ -17,7 +17,7 @@ django-redis==5.2.0 django-taggit==3.1.0 django_debug_toolbar==3.8.1 dodgy==0.2.1 -flake8==6.0.0 +flake8==5.0.4 flake8-polyfill==1.0.2 gitdb==4.0.10 GitPython==3.1.29 @@ -34,15 +34,15 @@ packageurl-python==0.10.4 packaging==21.3 pathspec==0.10.2 pbr==5.8.0 -pep8-naming==0.13.2 +pep8-naming==0.10.0 platformdirs==2.5.4 poetry-semver==0.1.0 prospector==1.8.2 psycopg2==2.9.5 -pycodestyle==2.10.0 +pycodestyle==2.9.1 pycparser==2.21 pydocstyle==6.1.1 -pyflakes==3.0.1 +pyflakes==2.5.0 pylint==2.15.7 pylint-celery==0.3 pylint-common==0.2.5 From c3d0c36caeb58b19559a6c02adeb03a621d356fa Mon Sep 17 00:00:00 2001 From: Cyber JiuJiteira <107970777+Cyber-JiuJiteria@users.noreply.github.com> Date: Wed, 7 Dec 2022 20:37:32 -0500 Subject: [PATCH 08/21] Updated T-P Readme for missed markdown bracket Signed-off-by: Cyber JiuJiteira <107970777+Cyber-JiuJiteria@users.noreply.github.com> --- omega/triage-portal/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/omega/triage-portal/README.md b/omega/triage-portal/README.md index 41a991b0..c3efcb85 100644 --- a/omega/triage-portal/README.md +++ b/omega/triage-portal/README.md @@ -26,5 +26,5 @@ TBD ## Security -See SECURITY.md](https://github.com/ossf/alpha-omega/blob/main/SECURITY.md). +See [SECURITY.md](https://github.com/ossf/alpha-omega/blob/main/SECURITY.md). From 9e005abe2696f3ceab6488cc0a93eb7a905fe31e Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Thu, 15 Dec 2022 18:01:56 +0000 Subject: [PATCH 09/21] Update UUID fields to be unique explicitly. Signed-off-by: Michael Scovetta --- ...ttachment_uuid_alter_case_uuid_and_more.py | 98 +++++++++++++++++++ omega/triage-portal/src/triage/models/File.py | 2 +- .../src/triage/models/attachment.py | 2 +- omega/triage-portal/src/triage/models/case.py | 2 +- .../triage-portal/src/triage/models/filter.py | 2 +- .../src/triage/models/finding.py | 2 +- .../src/triage/models/project.py | 4 +- omega/triage-portal/src/triage/models/scan.py | 2 +- omega/triage-portal/src/triage/models/tool.py | 2 +- .../src/triage/models/tool_defect.py | 2 +- omega/triage-portal/src/triage/models/wiki.py | 4 +- 11 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 omega/triage-portal/src/triage/migrations/0026_alter_attachment_uuid_alter_case_uuid_and_more.py diff --git a/omega/triage-portal/src/triage/migrations/0026_alter_attachment_uuid_alter_case_uuid_and_more.py b/omega/triage-portal/src/triage/migrations/0026_alter_attachment_uuid_alter_case_uuid_and_more.py new file mode 100644 index 00000000..b2ab4728 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0026_alter_attachment_uuid_alter_case_uuid_and_more.py @@ -0,0 +1,98 @@ +# Generated by Django 4.1.3 on 2022-12-15 18:00 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("triage", "0025_alter_wikiarticle_options_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="attachment", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="case", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="file", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="filter", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="finding", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="project", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="projectversion", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="scan", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="tool", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="tooldefect", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="wikiarticle", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + migrations.AlterField( + model_name="wikiarticlerevision", + name="uuid", + field=models.UUIDField( + db_index=True, default=uuid.uuid4, editable=False, unique=True + ), + ), + ] diff --git a/omega/triage-portal/src/triage/models/File.py b/omega/triage-portal/src/triage/models/File.py index 482563d8..b1894107 100644 --- a/omega/triage-portal/src/triage/models/File.py +++ b/omega/triage-portal/src/triage/models/File.py @@ -16,7 +16,7 @@ class File(models.Model): Represents a file that is associated with an analyzed project. """ - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) name = models.CharField(max_length=512, db_index=True) path = models.CharField(max_length=4096, db_index=True) content = models.ForeignKey("FileContent", on_delete=models.CASCADE, editable=False) diff --git a/omega/triage-portal/src/triage/models/attachment.py b/omega/triage-portal/src/triage/models/attachment.py index 961e821a..3a48e556 100644 --- a/omega/triage-portal/src/triage/models/attachment.py +++ b/omega/triage-portal/src/triage/models/attachment.py @@ -7,7 +7,7 @@ class Attachment(models.Model): - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) filename = models.CharField(max_length=1024) content = models.BinaryField() content_type = models.CharField(max_length=255) diff --git a/omega/triage-portal/src/triage/models/case.py b/omega/triage-portal/src/triage/models/case.py index 2049dd22..38ffc786 100644 --- a/omega/triage-portal/src/triage/models/case.py +++ b/omega/triage-portal/src/triage/models/case.py @@ -22,7 +22,7 @@ class CasePartner(models.TextChoices): MSRC = "MS", _("Microsoft Security Response Center") NOT_SPECIFIED = "NS", _("Not Specified") - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) findings = models.ManyToManyField("Finding", related_name="cases") state = models.CharField(max_length=2, choices=WorkItemState.choices, default=WorkItemState.NEW) title = models.CharField(max_length=1024) diff --git a/omega/triage-portal/src/triage/models/filter.py b/omega/triage-portal/src/triage/models/filter.py index 037ff125..ea47ef81 100644 --- a/omega/triage-portal/src/triage/models/filter.py +++ b/omega/triage-portal/src/triage/models/filter.py @@ -33,7 +33,7 @@ class FilterEvent(models.TextChoices): class RuleType(models.TextChoices): PYTHON_FUNCTION = "PY", _("Python Function") - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) title = models.CharField(max_length=255, null=True, blank=True) condition = models.TextField(null=True, blank=True) action = models.TextField(null=True, blank=True) diff --git a/omega/triage-portal/src/triage/models/finding.py b/omega/triage-portal/src/triage/models/finding.py index 19f6f916..ad79c8fd 100644 --- a/omega/triage-portal/src/triage/models/finding.py +++ b/omega/triage-portal/src/triage/models/finding.py @@ -92,7 +92,7 @@ def parse(cls, severity: str, strict: bool = False) -> "Finding.SeverityLevel": return cls.NONE return cls.NOT_SPECIFIED - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) project_version = models.ForeignKey( "ProjectVersion", on_delete=models.CASCADE, null=True, blank=True ) diff --git a/omega/triage-portal/src/triage/models/project.py b/omega/triage-portal/src/triage/models/project.py index e4280595..d51846c7 100644 --- a/omega/triage-portal/src/triage/models/project.py +++ b/omega/triage-portal/src/triage/models/project.py @@ -20,7 +20,7 @@ class Project(BaseTimestampedModel, BaseUserTrackedModel): """An abstract project undergoing analysis.""" - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) name = models.CharField(max_length=1024, db_index=True) package_url = models.CharField(max_length=1024, null=True, blank=True, db_index=True) metadata = models.JSONField(null=True) @@ -35,7 +35,7 @@ def get_absolute_url(self): class ProjectVersion(BaseTimestampedModel, BaseUserTrackedModel): """A version of a project.""" - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) project = models.ForeignKey("Project", on_delete=models.CASCADE) package_url = models.CharField(max_length=1024, null=True, blank=True, db_index=True) files = models.ManyToManyField("File", blank=True, editable=True) diff --git a/omega/triage-portal/src/triage/models/scan.py b/omega/triage-portal/src/triage/models/scan.py index 20f9b0d2..643a38f6 100644 --- a/omega/triage-portal/src/triage/models/scan.py +++ b/omega/triage-portal/src/triage/models/scan.py @@ -20,7 +20,7 @@ class Scan(BaseTimestampedModel, BaseUserTrackedModel): """A scan of a project version.""" - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) project_version = models.ForeignKey("ProjectVersion", on_delete=models.CASCADE) tool = models.ForeignKey("Tool", on_delete=models.CASCADE) diff --git a/omega/triage-portal/src/triage/models/tool.py b/omega/triage-portal/src/triage/models/tool.py index 681c0699..1098485e 100644 --- a/omega/triage-portal/src/triage/models/tool.py +++ b/omega/triage-portal/src/triage/models/tool.py @@ -18,7 +18,7 @@ class ToolType(models.TextChoices): STATIC_ANALYSIS = "SA", _("Static Analysis") OTHER = "OT", _("Other") - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) name = models.CharField(max_length=128) friendly_name = models.CharField(max_length=128, blank=True, null=True) version = models.CharField(max_length=64, null=True, blank=True) diff --git a/omega/triage-portal/src/triage/models/tool_defect.py b/omega/triage-portal/src/triage/models/tool_defect.py index e7d9c368..2ea1c30a 100644 --- a/omega/triage-portal/src/triage/models/tool_defect.py +++ b/omega/triage-portal/src/triage/models/tool_defect.py @@ -35,7 +35,7 @@ class ToolDefect(BaseTimestampedModel, BaseUserTrackedModel): """ tool = models.ForeignKey(Tool, on_delete=models.CASCADE) - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) title = models.CharField(max_length=1024) description = models.TextField(null=True, blank=True) findings = models.ManyToManyField("Finding") diff --git a/omega/triage-portal/src/triage/models/wiki.py b/omega/triage-portal/src/triage/models/wiki.py index 775ac6b5..52ec2fa8 100644 --- a/omega/triage-portal/src/triage/models/wiki.py +++ b/omega/triage-portal/src/triage/models/wiki.py @@ -9,7 +9,7 @@ class WikiArticleRevision(BaseTimestampedModel, BaseUserTrackedModel): - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) article = models.ForeignKey("WikiArticle", on_delete=models.CASCADE, related_name="revisions") title = models.CharField(max_length=1024) content = models.TextField(null=True, blank=True) @@ -45,7 +45,7 @@ def get_queryset(self): class WikiArticle(models.Model): - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) slug = models.SlugField(unique=True) state = models.CharField( max_length=2, choices=WorkItemState.choices, default=WorkItemState.ACTIVE From 56619419545e0b9ae645bbb0edb4a18b08d540de Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Thu, 15 Dec 2022 21:22:14 +0000 Subject: [PATCH 10/21] Add support for prioritity "not-equals". Signed-off-by: Michael Scovetta --- omega/triage-portal/src/triage/util/search_parser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/omega/triage-portal/src/triage/util/search_parser.py b/omega/triage-portal/src/triage/util/search_parser.py index 11fcde56..ef1c8a09 100644 --- a/omega/triage-portal/src/triage/util/search_parser.py +++ b/omega/triage-portal/src/triage/util/search_parser.py @@ -26,7 +26,7 @@ def parse_query_to_Q(model: Model, query: str) -> Q: priority_clause = pp.Group( pp.Keyword("priority").suppress() + pp.Literal(":").suppress() - + pp.one_of(["<", ">", "<=", ">=", "=="]).setResultsName("op") + + pp.one_of(["<", ">", "<=", ">=", "==", "!="]).setResultsName("op") + pp.Word(pp.nums).setResultsName("value") ).setResultsName("priority") @@ -216,6 +216,10 @@ def parse_query_to_Q(model: Model, query: str) -> Q: q = q & Q(priority__gte=results.priority.value) elif results.priority.op == "==": q = q & Q(priority__exact=results.priority.value) + elif results.priority.op == "!=": + q = q & ~Q(priority__exact=results.priority.value) + else: + logger.warn("Unknown priority op: %s", results.priority.op) if results.purl: if "project_version" in available_attributes: From 4b42ce176e4a513c52091ab80333f2474f793535 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Thu, 15 Dec 2022 21:27:54 +0000 Subject: [PATCH 11/21] Added support for != to created/updated date. Signed-off-by: Michael Scovetta --- .../triage-portal/src/triage/util/search_parser.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/omega/triage-portal/src/triage/util/search_parser.py b/omega/triage-portal/src/triage/util/search_parser.py index ef1c8a09..f074619f 100644 --- a/omega/triage-portal/src/triage/util/search_parser.py +++ b/omega/triage-portal/src/triage/util/search_parser.py @@ -59,14 +59,14 @@ def parse_query_to_Q(model: Model, query: str) -> Q: updated_dt_clause = pp.Group( pp.Keyword("updated").suppress() + pp.Literal(":").suppress() - + pp.one_of(["<", ">", "<=", ">=", "=="]).setResultsName("op") + + pp.one_of(["<", ">", "<=", ">=", "==", "!="]).setResultsName("op") + pp.pyparsing_common.iso8601_date("datetime") ).setResultsName("updated_dt") created_dt_clause = pp.Group( pp.Keyword("created").suppress() + pp.Literal(":").suppress() - + pp.one_of(["<", ">", "<=", ">=", "=="]).setResultsName("op") + + pp.one_of(["<", ">", "<=", ">=", "==", "!="]).setResultsName("op") + ( pp.pyparsing_common.iso8601_date("datetime") ^ ( @@ -178,6 +178,10 @@ def parse_query_to_Q(model: Model, query: str) -> Q: q = q & Q(updated_at__gte=target) elif results.updated_dt.op == "==": q = q & Q(updated_at__exact=target) + elif results.updated_dt.op == "!=": + q = q & ~Q(created_at__exact=target) + else: + logger.warning("Unknown updated_dt op: %s", results.updated_dt.op) if results.created_dt: if results.created_dt.datetime: @@ -204,6 +208,10 @@ def parse_query_to_Q(model: Model, query: str) -> Q: q = q & Q(created_at__gte=target) elif results.created_dt.op == "==": q = q & Q(created_at__exact=target) + elif results.created_dt.op == "!=": + q = q & ~Q(created_at__exact=target) + else: + logger.warning("Unknown created_dt op: %s", results.created_dt.op) if results.priority: if results.priority.op == "<": @@ -219,7 +227,7 @@ def parse_query_to_Q(model: Model, query: str) -> Q: elif results.priority.op == "!=": q = q & ~Q(priority__exact=results.priority.value) else: - logger.warn("Unknown priority op: %s", results.priority.op) + logger.warning("Unknown priority op: %s", results.priority.op) if results.purl: if "project_version" in available_attributes: From 2129da2afa58b495196a15572ba5b5c438a68d71 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Thu, 15 Dec 2022 21:35:24 +0000 Subject: [PATCH 12/21] Remove some unused code. Signed-off-by: Michael Scovetta --- omega/triage-portal/src/triage/static/triage/omega.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/omega/triage-portal/src/triage/static/triage/omega.js b/omega/triage-portal/src/triage/static/triage/omega.js index b380ef6c..3c915c71 100644 --- a/omega/triage-portal/src/triage/static/triage/omega.js +++ b/omega/triage-portal/src/triage/static/triage/omega.js @@ -130,10 +130,6 @@ const load_source_code = function(options) { ace.edit('editor').getSession().clearAnnotations(); } - // @TODO Are these necessary? - //$('#package-source-external-link').data('package-url', $row.data('package-url')); - //$('#issue-metadata-link').data('issue-id', $row.data('issue-id')); - $(window).trigger('resize'); $('#editor').css('height', $(window).height() - $('#editor').offset().top - 10); From 3c60a47c3d972f0720c5542492609cad25530688 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sat, 17 Dec 2022 17:44:40 +0000 Subject: [PATCH 13/21] Fix linting config Signed-off-by: Michael Scovetta --- omega/triage-portal/.vscode/settings.json | 4 +- omega/triage-portal/src/pyproject.toml | 531 ++++++++++++++++++++++ 2 files changed, 533 insertions(+), 2 deletions(-) diff --git a/omega/triage-portal/.vscode/settings.json b/omega/triage-portal/.vscode/settings.json index e52d8489..a08338d8 100644 --- a/omega/triage-portal/.vscode/settings.json +++ b/omega/triage-portal/.vscode/settings.json @@ -17,14 +17,14 @@ "python.terminal.activateEnvInCurrentTerminal": true, "terminal.integrated.env.linux": { "DJANGO_SETTINGS_MODULE": "core.settings", - "PYTHONPATH": "${workspaceFolder}/omega/triage/portal/src" + "PYTHONPATH": "${workspaceFolder}/omega/triage-portal/portal/src" }, "python.defaultInterpreterPath": "${workspaceFolder}/omega/triage-portal/.venv/bin/python", "python.linting.enabled": false, "python.linting.pylintPath": "pylint", "python.linting.pylintEnabled": false, "python.linting.pylintArgs": [ - "--init-hook=import sys;sys.path.append('${workspaceFolder}/src')", + "--init-hook=import sys;sys.path.append('${workspaceFolder}/omage/triage-portal/src')", "--load-plugins", "pylint_django", "--django-settings-module", diff --git a/omega/triage-portal/src/pyproject.toml b/omega/triage-portal/src/pyproject.toml index 87e846d2..39b9a02d 100644 --- a/omega/triage-portal/src/pyproject.toml +++ b/omega/triage-portal/src/pyproject.toml @@ -1,3 +1,534 @@ [tool.isort] profile = "black" known_first_party = ["triage", "core"] + +[tool.pylint.main] +# Analyse import fallback blocks. This can be used to support both Python 2 and 3 +# compatible code, which means that the block might have code that exists only in +# one or another interpreter, leading to false positives when analysed. +# analyse-fallback-blocks = + +# Always return a 0 (non-error) status code, even if lint errors are found. This +# is primarily useful in continuous integration scripts. +# exit-zero = + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +# extension-pkg-allow-list = + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +# extension-pkg-whitelist = + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +# fail-on = + +# Specify a score threshold under which the program will exit with error. +fail-under = 10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +# from-stdin = + +# Files or directories to be skipped. They should be base names, not paths. +ignore = ["CVS"] + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\' represents the directory delimiter on Windows systems, it +# can't be used as an escape character. +# ignore-paths = + +# Files or directories matching the regular expression patterns are skipped. The +# regex matches against base names, not paths. The default value ignores Emacs +# file locks +ignore-patterns = ["^\\.#"] + +# List of module names for which member attributes should not be checked (useful +# for modules/projects where namespaces are manipulated during runtime and thus +# existing member attributes cannot be deduced by static analysis). It supports +# qualified module names, as well as Unix pattern matching. +# ignored-modules = + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +init-hook = 'import sys; sys.path.append("/workspaces/alpha-omega/omega/triage-portal/src"); sys.path.append("/workspaces/alpha-omega/omega/triage-portal/.venv/lib/python3.11/site-packages")' + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs = 1 + +# Control the amount of potential inferred values when inferring a single object. +# This can help the performance when dealing with large functions or complex, +# nested conditions. +limit-inference-results = 100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +# load-plugins = ["pylint_django"] + +# Pickle collected data for later comparisons. +persistent = true + +# Minimum Python version to use for version dependent checks. Will default to the +# version used to run pylint. +py-version = "3.11" + +# Discover python modules and packages in the file system subtree. +# recursive = + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode = true + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +# unsafe-load-any-extension = + +[tool.pylint.basic] +# Naming style matching correct argument names. +argument-naming-style = "snake_case" + +# Regular expression matching correct argument names. Overrides argument-naming- +# style. If left empty, argument names will be checked with the set naming style. +# argument-rgx = + +# Naming style matching correct attribute names. +attr-naming-style = "snake_case" + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +# attr-rgx = + +# Bad variable names which should always be refused, separated by a comma. +bad-names = ["foo", "bar", "baz", "toto", "tutu", "tata"] + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +# bad-names-rgxs = + +# Naming style matching correct class attribute names. +class-attribute-naming-style = "any" + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +# class-attribute-rgx = + +# Naming style matching correct class constant names. +class-const-naming-style = "UPPER_CASE" + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +# class-const-rgx = + +# Naming style matching correct class names. +class-naming-style = "PascalCase" + +# Regular expression matching correct class names. Overrides class-naming-style. +# If left empty, class names will be checked with the set naming style. +# class-rgx = + +# Naming style matching correct constant names. +const-naming-style = "UPPER_CASE" + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming style. +# const-rgx = + +# Minimum line length for functions/classes that require docstrings, shorter ones +# are exempt. +docstring-min-length = -1 + +# Naming style matching correct function names. +function-naming-style = "snake_case" + +# Regular expression matching correct function names. Overrides function-naming- +# style. If left empty, function names will be checked with the set naming style. +# function-rgx = + +# Good variable names which should always be accepted, separated by a comma. +good-names = ["i", "j", "k", "ex", "Run", "_"] + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +# good-names-rgxs = + +# Include a hint for the correct naming format with invalid-name. +# include-naming-hint = + +# Naming style matching correct inline iteration names. +inlinevar-naming-style = "any" + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +# inlinevar-rgx = + +# Naming style matching correct method names. +method-naming-style = "snake_case" + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +# method-rgx = + +# Naming style matching correct module names. +module-naming-style = "snake_case" + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +# module-rgx = + +# Colon-delimited sets of names that determine each other's naming style when the +# name regexes allow several styles. +# name-group = + +# Regular expression which should only match function or class names that do not +# require a docstring. +no-docstring-rgx = "^_" + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. These +# decorators are taken in consideration only for invalid-name. +property-classes = ["abc.abstractproperty"] + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +# typevar-rgx = + +# Naming style matching correct variable names. +variable-naming-style = "snake_case" + +# Regular expression matching correct variable names. Overrides variable-naming- +# style. If left empty, variable names will be checked with the set naming style. +# variable-rgx = + +[tool.pylint.classes] +# Warn about protected attribute access inside special methods +# check-protected-access-in-special-methods = + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods = ["__init__", "__new__", "setUp", "__post_init__"] + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected = ["_asdict", "_fields", "_replace", "_source", "_make"] + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg = ["cls"] + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg = ["cls"] + +[tool.pylint.design] +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +# exclude-too-few-public-methods = + +# List of qualified class names to ignore when counting class parents (see R0901) +# ignored-parents = + +# Maximum number of arguments for function / method. +max-args = 5 + +# Maximum number of attributes for a class (see R0902). +max-attributes = 7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr = 5 + +# Maximum number of branch for function / method body. +max-branches = 12 + +# Maximum number of locals for function / method body. +max-locals = 15 + +# Maximum number of parents for a class (see R0901). +max-parents = 7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods = 20 + +# Maximum number of return / yield for function / method body. +max-returns = 6 + +# Maximum number of statements in function / method body. +max-statements = 50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods = 2 + +[tool.pylint.exceptions] +# Exceptions that will emit a warning when caught. +overgeneral-exceptions = ["BaseException", "Exception"] + +[tool.pylint.format] +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +# expected-line-ending-format = + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines = "^\\s*(# )??$" + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren = 4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string = " " + +# Maximum number of characters on a single line. +max-line-length = 100 + +# Maximum number of lines in a module. +max-module-lines = 1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +# single-line-class-stmt = + +# Allow the body of an if to be on the same line as the test if there is no else. +# single-line-if-stmt = + +[tool.pylint.imports] +# List of modules that can be imported at any level, not just the top level one. +# allow-any-import-level = + +# Allow wildcard imports from modules that define __all__. +# allow-wildcard-with-all = + +# Deprecated modules which should not be used, separated by a comma. +# deprecated-modules = + +# Output a graph (.gv or any supported image format) of external dependencies to +# the given file (report RP0402 must not be disabled). +# ext-import-graph = + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be disabled). +# import-graph = + +# Output a graph (.gv or any supported image format) of internal dependencies to +# the given file (report RP0402 must not be disabled). +# int-import-graph = + +# Force import order to recognize a module as part of the standard compatibility +# libraries. +# known-standard-library = + +# Force import order to recognize a module as part of a third party library. +known-third-party = ["enchant"] + +# Couples of modules and preferred modules, separated by a comma. +# preferred-modules = + +[tool.pylint.logging] +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style = "old" + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules = ["logging"] + +[tool.pylint."messages control"] +# Only show warnings with the listed confidence levels. Leave empty to show all. +# Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence = ["HIGH", "CONTROL_FLOW", "INFERENCE", "INFERENCE_FAILURE", "UNDEFINED"] + +# Disable the message, report, category or checker with the given id(s). You can +# either give multiple identifiers separated by comma (,) or put this option +# multiple times (only on the command line, not in the configuration file where +# it should appear only once). You can also use "--disable=all" to disable +# everything first and then re-enable specific checks. For example, if you want +# to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead"] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where it +# should appear only once). See also the "--disable" option for examples. +enable = ["c-extension-no-member"] + +[tool.pylint.method_args] +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods = ["requests.api.delete", "requests.api.get", "requests.api.head", "requests.api.options", "requests.api.patch", "requests.api.post", "requests.api.put", "requests.api.request"] + +[tool.pylint.miscellaneous] +# List of note tags to take in consideration, separated by a comma. +notes = ["FIXME", "XXX", "TODO"] + +# Regular expression of note tags to take in consideration. +# notes-rgx = + +[tool.pylint.refactoring] +# Maximum number of nested blocks for function / method body +max-nested-blocks = 5 + +# Complete name of functions that never returns. When checking for inconsistent- +# return-statements if a never returning function is called then it will be +# considered as an explicit return statement and no message will be printed. +never-returning-functions = ["sys.exit", "argparse.parse_error"] + +[tool.pylint.reports] +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each category, +# as well as 'statement' which is the total number of statements analyzed. This +# score is used by the global evaluation report (RP0004). +evaluation = "max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))" + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +# msg-template = + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +# output-format = + +# Tells whether to display a full report or only the messages. +# reports = + +# Activate the evaluation score. +score = true + +[tool.pylint.similarities] +# Comments are removed from the similarity computation +ignore-comments = true + +# Docstrings are removed from the similarity computation +ignore-docstrings = true + +# Imports are removed from the similarity computation +ignore-imports = true + +# Signatures are removed from the similarity computation +ignore-signatures = true + +# Minimum lines number of a similarity. +min-similarity-lines = 4 + +[tool.pylint.spelling] +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions = 4 + +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the 'python-enchant' package. +# spelling-dict = + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives = "fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:" + +# List of comma separated words that should not be checked. +# spelling-ignore-words = + +# A path to a file that contains the private dictionary; one word per line. +# spelling-private-dict-file = + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +# spelling-store-unknown-words = + +[tool.pylint.string] +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +# check-quote-consistency = + +# This flag controls whether the implicit-str-concat should generate a warning on +# implicit string concatenation in sequences defined over several lines. +# check-str-concat-over-line-jumps = + +[tool.pylint.typecheck] +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators = ["contextlib.contextmanager"] + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +# generated-members = + +# Tells whether missing members accessed in mixin class should be ignored. A +# class is considered mixin if its name matches the mixin-class-rgx option. +# Tells whether to warn about missing members when the owner of the attribute is +# inferred to be None. +ignore-none = true + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference can +# return multiple potential results while evaluating a Python object, but some +# branches might not be evaluated, which results in partial inference. In that +# case, it might be useful to still emit no-member and other checks for the rest +# of the inferred objects. +ignore-on-opaque-inference = true + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins = ["no-member", "not-async-context-manager", "not-context-manager", "attribute-defined-outside-init"] + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes = ["optparse.Values", "thread._local", "_thread._local", "argparse.Namespace"] + +# Show a hint with possible names when a member name was not found. The aspect of +# finding the hint is based on edit distance. +missing-member-hint = true + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance = 1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices = 1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx = ".*[Mm]ixin" + +# List of decorators that change the signature of a decorated function. +# signature-mutators = + +[tool.pylint.variables] +# List of additional names supposed to be defined in builtins. Remember that you +# should avoid defining new builtins when possible. +# additional-builtins = + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables = true + +# List of names allowed to shadow builtins +# allowed-redefined-builtins = + +# List of strings which can identify a callback function by name. A callback name +# must start or end with one of those strings. +callbacks = ["cb_", "_cb"] + +# A regular expression matching the name of dummy variables (i.e. expected to not +# be used). +dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_" + +# Argument names that match this expression will be ignored. +ignored-argument-names = "_.*|^ignored_|^unused_" + +# Tells whether we should check for unused import in __init__ files. +# init-import = + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules = ["six.moves", "past.builtins", "future.builtins", "builtins", "io"] + + From c10fe8585d9debb7ab67a66073dcf94440340345 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sun, 18 Dec 2022 20:33:24 +0000 Subject: [PATCH 14/21] Starting refactor of file storage. Signed-off-by: Michael Scovetta --- .vscode/launch.json | 20 +++ omega/triage-portal/src/core/settings.py | 12 +- omega/triage-portal/src/pyproject.toml | 6 +- .../triage/migrations/0027_file_file_type.py | 23 +++ ...file_content_file_content_type_and_more.py | 37 +++++ .../0029_rename_storage_key_file_file_key.py | 18 +++ omega/triage-portal/src/triage/models/File.py | 35 ++++- .../src/triage/models/finding.py | 2 +- .../templates/triage/findings_list.html | 6 +- .../templates/triage/findings_upload.html | 6 +- omega/triage-portal/src/triage/urls.py | 1 + .../triage/util/content_managers/__init__.py | 0 .../util/content_managers/base_manager.py | 2 + .../util/content_managers/file_manager.py | Bin 0 -> 3500 bytes .../finding_importers/archive_importer.py | 145 ++++++++++++++++++ .../util/finding_importers/sarif_importer.py | 41 +++-- .../triage-portal/src/triage/util/general.py | 10 +- .../src/triage/views/findings.py | 99 +++++++++--- 18 files changed, 401 insertions(+), 62 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 omega/triage-portal/src/triage/migrations/0027_file_file_type.py create mode 100644 omega/triage-portal/src/triage/migrations/0028_remove_file_content_file_content_type_and_more.py create mode 100644 omega/triage-portal/src/triage/migrations/0029_rename_storage_key_file_file_key.py create mode 100644 omega/triage-portal/src/triage/util/content_managers/__init__.py create mode 100644 omega/triage-portal/src/triage/util/content_managers/base_manager.py create mode 100644 omega/triage-portal/src/triage/util/content_managers/file_manager.py create mode 100644 omega/triage-portal/src/triage/util/finding_importers/archive_importer.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..de9ab8b3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Triage Portal - Start Server (Debug)", + "type": "python", + "request": "launch", + "program": "src/manage.py", + "cwd": "${workspaceFolder}/omega/triage-portal", + "args": [ + "runserver", + "0.0.0.0:8001" + ], + "django": true + } + ] +} \ No newline at end of file diff --git a/omega/triage-portal/src/core/settings.py b/omega/triage-portal/src/core/settings.py index 1ecde41d..f16113df 100644 --- a/omega/triage-portal/src/core/settings.py +++ b/omega/triage-portal/src/core/settings.py @@ -212,4 +212,14 @@ OSSGADGET_PATH = get_env_variable("OSSGADGET_PATH") -AUTH_USER_MODEL = "auth.User" +AUTH_USER_MODEL = "auth.User" # pylint: disable=hard-coded-auth-user + +# File Storage Providers for files, attachments, etc. +FILE_STORAGE_PROVIDERS = { + "default": { + "provider": "triage.util.content_managers.file_manager.FileManager", + "args": { + "root_path": "/tmp/omega-fs" + } + } +} \ No newline at end of file diff --git a/omega/triage-portal/src/pyproject.toml b/omega/triage-portal/src/pyproject.toml index 39b9a02d..53ce723d 100644 --- a/omega/triage-portal/src/pyproject.toml +++ b/omega/triage-portal/src/pyproject.toml @@ -71,7 +71,9 @@ limit-inference-results = 100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. -# load-plugins = ["pylint_django"] +load-plugins = [ + "pylint_django" +] # Pickle collected data for later comparisons. persistent = true @@ -156,7 +158,7 @@ function-naming-style = "snake_case" # function-rgx = # Good variable names which should always be accepted, separated by a comma. -good-names = ["i", "j", "k", "ex", "Run", "_"] +good-names = ["f", "i", "j", "k", "ex", "Run", "_"] # Good variable names regexes, separated by a comma. If names match any regex, # they will always be accepted diff --git a/omega/triage-portal/src/triage/migrations/0027_file_file_type.py b/omega/triage-portal/src/triage/migrations/0027_file_file_type.py new file mode 100644 index 00000000..eacaee88 --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0027_file_file_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.3 on 2022-12-18 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("triage", "0026_alter_attachment_uuid_alter_case_uuid_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="file", + name="file_type", + field=models.CharField( + choices=[("S", "Source Code"), ("R", "Scan Result"), ("U", "Unknown")], + db_index=True, + default="U", + max_length=64, + ), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0028_remove_file_content_file_content_type_and_more.py b/omega/triage-portal/src/triage/migrations/0028_remove_file_content_file_content_type_and_more.py new file mode 100644 index 00000000..1d098abc --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0028_remove_file_content_file_content_type_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.1.3 on 2022-12-18 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("triage", "0027_file_file_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="file", + name="content", + ), + migrations.AddField( + model_name="file", + name="content_type", + field=models.CharField(blank=True, db_index=True, max_length=64, null=True), + ), + migrations.AddField( + model_name="file", + name="storage_key", + field=models.CharField(blank=True, db_index=True, max_length=64, null=True), + ), + migrations.AlterField( + model_name="file", + name="file_type", + field=models.CharField( + choices=[("S", "Source Code"), ("R", "Scan Result"), ("U", "Unknown")], + db_index=True, + default="U", + max_length=16, + ), + ), + ] diff --git a/omega/triage-portal/src/triage/migrations/0029_rename_storage_key_file_file_key.py b/omega/triage-portal/src/triage/migrations/0029_rename_storage_key_file_file_key.py new file mode 100644 index 00000000..e41fde0a --- /dev/null +++ b/omega/triage-portal/src/triage/migrations/0029_rename_storage_key_file_file_key.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-12-18 07:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("triage", "0028_remove_file_content_file_content_type_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="file", + old_name="storage_key", + new_name="file_key", + ), + ] diff --git a/omega/triage-portal/src/triage/models/File.py b/omega/triage-portal/src/triage/models/File.py index b1894107..306a992d 100644 --- a/omega/triage-portal/src/triage/models/File.py +++ b/omega/triage-portal/src/triage/models/File.py @@ -1,3 +1,4 @@ +import hashlib import base64 import logging import uuid @@ -11,18 +12,38 @@ logger = logging.getLogger(__name__) +FILE_TYPES = ( + ("source", _("Source")), + ("binary", _("Binary")), + ("other", _("Other")), + ("unknown", _("Unknown")), + ("", _("Unknown")), +) + + class File(models.Model): """ Represents a file that is associated with an analyzed project. """ - uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True, unique=True) + class FileType(models.TextChoices): + SOURCE_CODE = "S", _("Source Code") + SCAN_RESULT = "R", _("Scan Result") + UNKNOWN = "U", _("Unknown") + + uuid = models.UUIDField( + default=uuid.uuid4, editable=False, db_index=True, unique=True + ) name = models.CharField(max_length=512, db_index=True) path = models.CharField(max_length=4096, db_index=True) - content = models.ForeignKey("FileContent", on_delete=models.CASCADE, editable=False) + file_type = models.CharField( + max_length=16, db_index=True, choices=FileType.choices, default=FileType.UNKNOWN + ) + content_type = models.CharField(max_length=64, db_index=True, null=True, blank=True) + file_key = models.CharField(max_length=64, db_index=True, null=True, blank=True) def __str__(self): - return self.path + return str(self.path) class FileContent(models.Model): @@ -34,3 +55,11 @@ class FileContent(models.Model): def __str__(self): return base64.b64encode(self.hash).decode("ascii") + + @classmethod + def generate_hash(cls, data: bytes, encode=False) -> str | bytes: + """Generates a hash for the given data.""" + digest = hashlib.sha256(data, usedforsecurity=True).digest() + if encode: + return base64.b64encode(digest).decode("ascii") + return digest diff --git a/omega/triage-portal/src/triage/models/finding.py b/omega/triage-portal/src/triage/models/finding.py index ad79c8fd..edd68cd2 100644 --- a/omega/triage-portal/src/triage/models/finding.py +++ b/omega/triage-portal/src/triage/models/finding.py @@ -144,7 +144,7 @@ def parse(cls, severity: str, strict: bool = False) -> "Finding.SeverityLevel": objects = models.Manager() def __str__(self): - return f"{self.normalized_title} in {self.file.name}:{self.file_line}" + return f"{self.normalized_title} in {self.file}:{self.file_line}" def get_absolute_url(self): return f"/finding/{self.uuid}" diff --git a/omega/triage-portal/src/triage/templates/triage/findings_list.html b/omega/triage-portal/src/triage/templates/triage/findings_list.html index ea0c2cd9..d0f9d86b 100644 --- a/omega/triage-portal/src/triage/templates/triage/findings_list.html +++ b/omega/triage-portal/src/triage/templates/triage/findings_list.html @@ -30,9 +30,11 @@
  • Save Current Query As...
  • -
  • Query Syntax Info
  • +
  • Query Syntax Info
  • +
    +
    @@ -83,7 +85,7 @@
  • {{ i }}
  • {% endif %} {% endfor %} - + {% if findings.has_next %}
  • diff --git a/omega/triage-portal/src/triage/templates/triage/findings_upload.html b/omega/triage-portal/src/triage/templates/triage/findings_upload.html index ef0cead5..cdf0cec4 100644 --- a/omega/triage-portal/src/triage/templates/triage/findings_upload.html +++ b/omega/triage-portal/src/triage/templates/triage/findings_upload.html @@ -11,10 +11,12 @@ {% endif %} -

    Upload SARIF

    +
    +
    -
    +
    +

    Upload SARIF

    {% csrf_token %}
    diff --git a/omega/triage-portal/src/triage/urls.py b/omega/triage-portal/src/triage/urls.py index ac3a1b12..e230940a 100644 --- a/omega/triage-portal/src/triage/urls.py +++ b/omega/triage-portal/src/triage/urls.py @@ -17,6 +17,7 @@ path("tool_defect/save", tool_defect.save_tool_defect), path("tool_defect/", tool_defect.show_tool_defects), # Findings + path("api/findings/add_archive", findings.api_add_scan_archive), path("api/findings/add", findings.api_add), path("api/findings/get_files", findings.api_get_files), path("api/findings/get_source_code", findings.api_get_source_code), diff --git a/omega/triage-portal/src/triage/util/content_managers/__init__.py b/omega/triage-portal/src/triage/util/content_managers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/omega/triage-portal/src/triage/util/content_managers/base_manager.py b/omega/triage-portal/src/triage/util/content_managers/base_manager.py new file mode 100644 index 00000000..64823ac9 --- /dev/null +++ b/omega/triage-portal/src/triage/util/content_managers/base_manager.py @@ -0,0 +1,2 @@ +class BaseManager: + pass \ No newline at end of file diff --git a/omega/triage-portal/src/triage/util/content_managers/file_manager.py b/omega/triage-portal/src/triage/util/content_managers/file_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..f3716016ce93ea50c164c4a93cfe3f2aa77fb4ac GIT binary patch literal 3500 zcmb7H+iu%N5Y4l`VnCq~naV`tB7HER0%>dq2JEJAoV*wYu@qO*#uQiBUD{UC{Cj8i zO38B3>WSvg&gINGGxS2qHJI&2bW0HHjZ_A1`c2D6{*P8Jmv|QblsY_p)20c}E2dYi zs7Ip(c`IwCd9`L8TXGeg{(nM*^%0u5m< zJHtBzlyd{J1GD1pL~pg>>k==F?9DOU;L%*JH;QX5(3G`Hsf1Y}fD(Gmryv#lDLZt4 z51kwuTNmLnp3Y!G2u=XT1V29kZ4~@%ue*2+UqDrfPME67HE$PFc>VhRk*THjwfK52 z&_uj?CBAVD3_AWegaBmymCsEogD#EX12?u(C8Zgb3maxukaMgG@!lx;AR59&p|z6z zaz%hG#ImsUkT3F3*;_LzA$J*4gYro{n@nLcDbgjeKzK~L>v}`(=!ccFzBQ3TGdRo( z8zl4AS@KRke1tt$yK%@)F`+q}bq_+xZjDShQ%b5lmPa<>mt6^LJPO5O&?{z$1`n+5 zUCUocGi3P8>2AsqEhGmaxtF`EmfD|9;roxm{T(miOAB!fGKTkxGs9g=2@gI;;xe_3 zCLScIOKM=rpM*9#k1hu^BM8uJ&F*<4)WBv6_EshDXE&c7!vVSWUaXARR?)U z7=DIHi30+KLt~;09&(>XQILu^+g4CHjnJ%a`D}i~pRhn0PDh2L~}sJ>YF z`&(pKx#CZaSaNOh)Pmg>Vm#YH>i&z}i$b-{!0kZot1RvMwzb>$aPwHV&G-6dtqV&T zx3)zGw+?peJq(_^z2+hnB8oAey>13XcFx^~{X$F9?bbzhKCBkLhigCZi;5b*b&+(5 zJ3YD@!xb(c(7oceE4YU6yGE*D91VwSD%6g~v<56Mi&_{?R5TtmEi&K!qV3 zVc+AziFAD@vm5dZ)H literal 0 HcmV?d00001 diff --git a/omega/triage-portal/src/triage/util/finding_importers/archive_importer.py b/omega/triage-portal/src/triage/util/finding_importers/archive_importer.py new file mode 100644 index 00000000..326b910a --- /dev/null +++ b/omega/triage-portal/src/triage/util/finding_importers/archive_importer.py @@ -0,0 +1,145 @@ +import json +import io +import logging +import os +import tarfile +import zipfile + +import magic +from triage.models import File, FileContent, ProjectVersion +from triage.util.finding_importers.sarif_importer import SARIFImporter +from django.contrib.auth.models import AbstractBaseUser, AnonymousUser +from triage.util.content_managers.file_manager import FileManager +from core import settings + +logger = logging.getLogger(__name__) + + +class ArchiveImporter: + """Imports an archive of files into the database. + + The archive can contain source code (within a 'reference-source' directory), + scan results (including SARIF), and other files.""" + + def __init__(self): + self.storage_manager = FileManager() + + def import_archive( + self, + filename: str, + archive: bytes, + project_version: ProjectVersion, + user: AbstractBaseUser | AnonymousUser | None = None, + ): + """ + Adds a file to the database. + """ + logger.debug("Importing archive: %s", filename) + + for file_info in self.extract_archive(filename, archive): + logger.debug("Processing file: %s", file_info.get("name")) + + if "/reference-binaries/" in file_info.get("name"): + logger.debug( + "File was in reference source directory, saving as source code." + ) + + # Extract each file, save it to the database + for source_file_info in self.extract_archive( + file_info.get("name"), file_info.get("content") + ): + logger.debug("Saving source code: %s", source_file_info.get("name")) + self.add_file( + source_file_info.get("content"), + source_file_info.get("name"), + project_version, + File.FileType.SOURCE_CODE, + save=False, + ) + + elif file_info.get("name").endswith("sarif"): + logger.debug("File was a SARIF file, saving as scan.") + self.add_file( + file_info.get("content"), + file_info.get("name"), + project_version, + File.FileType.SCAN_RESULT, + save=False, + ) + + logger.debug("File was a SARIF file, saving as scan.") + sarif_content = json.loads(file_info.get("content")) + SARIFImporter.import_sarif_file(sarif_content, project_version, user) + + else: + logger.debug("File was generic file, saving as a scan result.") + self.add_file( + file_info.get("content"), + file_info.get("name"), + project_version, + File.FileType.SCAN_RESULT, + save=False, + ) + + project_version.save() + + def add_file( + self, + content: bytes, + path: str, + project_version: ProjectVersion, + file_type: str, + save: bool = True, + ): + """Adds a file to storage.""" + mime_type = magic.from_buffer(content, mime=True) + + file_key = self.storage_manager.add_file(content, path) + # Files are unique by content, path, etc. + file = File.objects.get_or_create( + name=os.path.basename(path), + path=path, + content_type=mime_type, + file_key=file_key, + file_type=file_type, + )[0] + + project_version.files.add(file) + if save: + project_version.save() + + def extract_archive(self, file_path: str, file_content: bytes) -> dict: + """ + Extracts contents of an archive file. + """ + if file_path.endswith(".tgz") or file_path.endswith(".tar.gz"): + logger.debug("Detected archive type: tar.gz (%s)", file_path) + with tarfile.open(fileobj=io.BytesIO(file_content), mode="r") as tar: + for member in tar.getmembers(): + if member.isfile(): + content = tar.extractfile(member) + yield { + "name": member.name, + "path": member.name, + "size": member.size, + "content": content.read() if content is not None else None, + } + elif file_path.endswith(".zip"): + logger.debug("Detected archive type: zip (%s)", file_path) + with zipfile.ZipFile(io.BytesIO(file_content), mode="r") as zip_obj: + for member in zip_obj.infolist(): + if not member.is_dir(): # ZipInfo does not have is_file() + yield { + "name": member.filename, + "path": member.filename, + "size": member.file_size, + "content": zip_obj.read(member), + } + else: + logger.debug("File %s is not an archive", file_path) + return { + "name": file_path, + "path": file_path, + "size": len(file_content), + "content": file_content, + } diff --git a/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py b/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py index 305d3bf9..16e811de 100644 --- a/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py +++ b/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py @@ -10,7 +10,7 @@ from typing import Optional, Type from django.contrib.auth import get_user_model -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractBaseUser from packageurl import PackageURL from triage.models import Finding, ProjectVersion, Scan, Tool, WorkItemState @@ -26,7 +26,7 @@ class SARIFImporter: @classmethod def import_sarif_file( - cls, package_url: PackageURL | str, sarif: dict, user: Optional[Type[AbstractUser]] + cls, sarif: dict, project_version: ProjectVersion, user: AbstractBaseUser | None ) -> bool: """ Imports a SARIF file containing tool findings into the database. @@ -40,31 +40,23 @@ def import_sarif_file( Returns: True if the SARIF content was successfully imported, False otherwise. """ - if package_url is None: - raise TypeError("The package_url must not be None") - - if isinstance(package_url, str): - package_url = PackageURL.from_string(package_url) - - if package_url.version is None: - raise TypeError( - f"The package_url ({package_url}) does not contain a version. Unable to import." - ) - if sarif is None: - raise TypeError("The sarif content must not be None.") + raise ValueError("The sarif content must not be None.") if sarif.get("version") != "2.1.0": raise ValueError("Only SARIF version 2.1.0 is supported.") - user = get_user_model().objects.get(id=1) # TODO: Fix this hardcoding - project_version = ProjectVersion.get_or_create_from_package_url(package_url, user) + if project_version is None: + raise ValueError("The project version must not be None.") + + if user is None: + user = get_user_model().objects.get(id=1) # TODO: Fix this hardcoding num_imported = 0 processed = set() # Reduce duplicates # First load all of the rules - for run in sarif.get("runs"): + for run in sarif.get("runs", []): tool_name = get_complex(run, "tool.driver.name") tool_version = get_complex(run, "tool.driver.version") tool = Tool.objects.get_or_create( @@ -96,7 +88,7 @@ def import_sarif_file( artifact_location = get_complex(location, "physicalLocation.artifactLocation") src_root = get_complex(artifact_location, "uriBaseId", "%SRCROOT%") - if src_root.upper() not in ["%SRCROOT%", "SRCROOT"]: + if str(src_root).upper() not in ["%SRCROOT%", "SRCROOT"]: continue uri = get_complex(artifact_location, "uri") @@ -116,10 +108,17 @@ def import_sarif_file( file_path = get_complex(artifact_location, "uri") file_path = cls.normalize_file_path(file_path) - file = project_version.files.filter(path=file_path).first() - if not file: - logger.debug("File %s not found, skipping.", file_path) + possible_files = project_version.files.filter(path__endswith=os.path.basename(file_path)) + if len(possible_files) > 1: + logger.debug("Multiple files found for path %s, skipping.", file_path) + for pf in possible_files: + logger.debug("Possible file: %s", pf.path) continue + file = possible_files.first() + + #if not file: + # logger.debug("File %s not found, skipping.", file_path) + # continue # Create the issue finding = Finding() diff --git a/omega/triage-portal/src/triage/util/general.py b/omega/triage-portal/src/triage/util/general.py index c202a325..d31d8ae8 100644 --- a/omega/triage-portal/src/triage/util/general.py +++ b/omega/triage-portal/src/triage/util/general.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -def get_complex(obj, key, default_value=""): +def get_complex(obj, key, default_value: str | None = ""): """Get a value from the dictionary d by nested.key.value. If keys contain periods, then use key=['a','b','c'] instead.""" if not obj or not isinstance(obj, dict): @@ -20,7 +20,7 @@ def get_complex(obj, key, default_value=""): for inner_key in parts: _data = _data[inner_key] return _data - except Exception: + except Exception: # pylint: disable=broad-except return default_value @@ -45,7 +45,7 @@ def strtobool(value: str, default: bool) -> bool: return default -def parse_date(date_str: str) -> datetime: +def parse_date(date_str: str) -> datetime | None: """Converts a date string to a timezone-aware datetime object.""" if date_str: try: @@ -54,8 +54,8 @@ def parse_date(date_str: str) -> datetime: parsed_dt = datetime(parsed.year, parsed.month, parsed.day) if parsed_dt: return timezone.make_aware(parsed_dt) - except Exception as msg: - logger.warning(f"Failed to parse date: {msg}") + except Exception as msg: # pylint: disable=broad-except + logger.warning("Failed to parse date: %s", msg) return None def clamp(value, min_value, max_value): diff --git a/omega/triage-portal/src/triage/views/findings.py b/omega/triage-portal/src/triage/views/findings.py index 8ef098e3..d09c480f 100644 --- a/omega/triage-portal/src/triage/views/findings.py +++ b/omega/triage-portal/src/triage/views/findings.py @@ -2,7 +2,7 @@ import logging import os from base64 import b64encode - +from triage.util.content_managers.file_manager import FileManager from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator @@ -14,6 +14,7 @@ JsonResponse, ) from django.shortcuts import get_object_or_404, redirect, render +from django.contrib.auth import get_user_model from django.views.decorators.cache import cache_page from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -31,6 +32,7 @@ ) from triage.util.azure_blob_storage import ToolshedBlobStorageAccessor from triage.util.finding_importers.sarif_importer import SARIFImporter +from triage.util.finding_importers.archive_importer import ArchiveImporter from triage.util.general import clamp from triage.util.search_parser import parse_query_to_Q from triage.util.source_viewer import path_to_graph @@ -59,15 +61,19 @@ def show_findings(request: HttpRequest) -> HttpResponse: findings = findings.filter(query_object) findings = findings.select_related("project_version", "tool", "file") - findings = findings.order_by('-project_version__package_url', 'title', 'created_at') + findings = findings.order_by("-project_version__package_url", "title", "created_at") paginator = Paginator(findings, page_size) page_object = paginator.get_page(page) query_string = request.GET.copy() - if 'page' in query_string: + if "page" in query_string: query_string.pop("page", None) - context = {"query": query, "findings": page_object, "params": query_string.urlencode() } + context = { + "query": query, + "findings": page_object, + "params": query_string.urlencode(), + } return render(request, "triage/findings_list.html", context) @@ -100,7 +106,9 @@ def show_upload(request: HttpRequest) -> HttpResponse: @login_required def show_finding_by_uuid(request: HttpRequest, finding_uuid) -> HttpResponse: finding = get_object_or_404(Finding, uuid=finding_uuid) - from django.contrib.auth.models import User + from django.contrib.auth.models import ( + User, + ) # pylint: disable=import-outside-toplevel assignee_list = User.objects.all() context = {"finding": finding, "assignee_list": assignee_list} @@ -156,20 +164,22 @@ def api_get_source_code(request: HttpRequest) -> JsonResponse: """Returns the source code for a finding.""" file_uuid = request.GET.get("file_uuid") if file_uuid: - file = File.objects.filter(uuid=file_uuid).select_related("content").first() - return JsonResponse( - { - "file_contents": b64encode(file.content.data).decode("utf-8"), - "file_name": file.path, - "status": "ok", - } - ) - else: - logger.info("Source code not found for %s", file_uuid) - return JsonResponse({"status": "error", "message": "File not found"}, status=404) - - # return JsonResponse({"status": "error"}, status=500) - + file = File.objects.filter(uuid=file_uuid).first() + if file and file.file_key: + file_manager = FileManager() + content = file_manager.get_file(file.file_key) + if content: + return JsonResponse( + { + "file_contents": b64encode(content).decode("utf-8"), + "file_name": file.path, + "status": "ok", + } + ) + logger.info("Source code not found for %s", file_uuid) + return JsonResponse( + {"status": "error", "message": "File not found"}, status=404 + ) @login_required @cache_page(60 * 5) @@ -212,12 +222,50 @@ def api_get_blob_list(request: HttpRequest) -> JsonResponse: finding = get_object_or_404(Finding, uuid=finding_uuid) source_code = finding.scan.get_blob_list() source_graph = path_to_graph( - map(lambda s: s.get("relative_path"), source_code), finding.scan.project_version.package_url + map(lambda s: s.get("relative_path"), source_code), + finding.scan.project_version.package_url, ) return JsonResponse({"data": source_graph, "status": "ok"}) +@csrf_exempt +@require_http_methods(["POST"]) +def api_add_scan_archive(request: HttpRequest) -> JsonResponse: + """Inserts data into the database. + + Required: + - scan_artifact => an archive of the content analyzed + - package_url => the package URL (must include version) + - scan_type => the type of scan (e.g. "toolshed") + - replace => replace existing scan if it exists (default: false) + """ + scan_artifact = request.FILES.get("scan_artifact") + if scan_artifact is None: + return JsonResponse({"error": "No scan_artifact provided"}) + + try: + package_url = PackageURL.from_string(request.POST.get("package_url")) + if package_url.version is None: + raise ValueError("Missing version") + except ValueError: + return JsonResponse({"error": "Invalid or missing package url"}) + + if request.user.is_anonymous: + user = get_user_model().objects.get(id=1) + else: + user = request.user + + project_version = ProjectVersion.get_or_create_from_package_url(package_url, user) + + archive_importer = ArchiveImporter() + archive_importer.import_archive( + scan_artifact.name, scan_artifact.read(), project_version, user + ) + + return JsonResponse({"success": True}) + + @csrf_exempt @require_http_methods(["POST"]) def api_add(request: HttpRequest) -> JsonResponse: @@ -283,10 +331,9 @@ def api_add_artifact(request: HttpRequest) -> JsonResponse: sarif_content = json.load(sarif) SARIFImporter.import_sarif_file(package_url, sarif_content, user) - """ - Source code files are usually archives that contain source code or other - artifacts. They'll be automatically extracted during the import process. They - are associated with a ProjectVersion""" + # Source code files are usually archives that contain source code or other + # artifacts. They'll be automatically extracted during the import process. They + # are associated with a ProjectVersion. if "source_code" in request.FILES: source_code = request.FILES.get("source_code") source_code_content = source_code.read() @@ -315,6 +362,8 @@ def api_upload_attachment(request: HttpRequest) -> JsonResponse: content_type=attachment.content_type, content=attachment.read(), ) - results.append({"filename": new_attachment.filename, "uuid": new_attachment.uuid}) + results.append( + {"filename": new_attachment.filename, "uuid": new_attachment.uuid} + ) return JsonResponse({"success": True, "attachments": results}) From 79dd75dd2771b3ec923e5b24ad992b5b205dee31 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sun, 18 Dec 2022 20:49:16 +0000 Subject: [PATCH 15/21] Add zstd and magic modules. Signed-off-by: Michael Scovetta --- omega/triage-portal/src/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/omega/triage-portal/src/requirements.txt b/omega/triage-portal/src/requirements.txt index dc5594af..0f26f945 100644 --- a/omega/triage-portal/src/requirements.txt +++ b/omega/triage-portal/src/requirements.txt @@ -50,6 +50,7 @@ pylint-django==2.5.3 pylint-flask==0.6 pylint-plugin-utils==0.7 pyparsing==3.0.9 +python-magic==0.4.27 pytz==2022.6 PyYAML==6.0 redis==3.5.3 @@ -70,3 +71,4 @@ tomlkit==0.11.6 typing_extensions==4.4.0 urllib3==1.26.13 wrapt==1.14.1 +zstd==1.5.2.6 From c5c25c2e33b0331535c604d7c0594a5280785884 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sun, 18 Dec 2022 20:53:27 +0000 Subject: [PATCH 16/21] Remove null bytes from FileManager source code Signed-off-by: Michael Scovetta --- .../util/content_managers/file_manager.py | Bin 3500 -> 3496 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/omega/triage-portal/src/triage/util/content_managers/file_manager.py b/omega/triage-portal/src/triage/util/content_managers/file_manager.py index f3716016ce93ea50c164c4a93cfe3f2aa77fb4ac..a224e72aa68d7ea7fc83be53fc58c0d2e58b5484 100644 GIT binary patch delta 17 ZcmZ1@y+V4!3C7K*7#A~b&S$aZ1OP)t29p2) delta 23 ccmZ1>y+(S&2}ULchRr7!7c+tAY!+Ki09(cekpKVy From e281db563dd1e0f42edd8f63e0a4bb3cc653a00a Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Sun, 18 Dec 2022 21:39:53 +0000 Subject: [PATCH 17/21] Clean up (remove SCIM refs, add comments) Signed-off-by: Michael Scovetta --- omega/triage-portal/src/triage/urls.py | 2 +- .../src/triage/util/scim/scim.py | 81 ------------------- omega/triage-portal/src/triage/views/cases.py | 6 +- .../src/triage/views/findings.py | 69 +++------------- omega/triage-portal/src/triage/views/wiki.py | 10 +++ 5 files changed, 25 insertions(+), 143 deletions(-) delete mode 100644 omega/triage-portal/src/triage/util/scim/scim.py diff --git a/omega/triage-portal/src/triage/urls.py b/omega/triage-portal/src/triage/urls.py index e230940a..f5e37d08 100644 --- a/omega/triage-portal/src/triage/urls.py +++ b/omega/triage-portal/src/triage/urls.py @@ -18,7 +18,6 @@ path("tool_defect/", tool_defect.show_tool_defects), # Findings path("api/findings/add_archive", findings.api_add_scan_archive), - path("api/findings/add", findings.api_add), path("api/findings/get_files", findings.api_get_files), path("api/findings/get_source_code", findings.api_get_source_code), path("api/findings/download_file", findings.api_download_file), @@ -46,5 +45,6 @@ path("wiki//edit", wiki.edit_wiki_article), path("wiki///edit", wiki.edit_wiki_article_revision), path("wiki/", wiki.home), + # Default (Home) path("", home.home), ] diff --git a/omega/triage-portal/src/triage/util/scim/scim.py b/omega/triage-portal/src/triage/util/scim/scim.py deleted file mode 100644 index 0ef47390..00000000 --- a/omega/triage-portal/src/triage/util/scim/scim.py +++ /dev/null @@ -1,81 +0,0 @@ -import uuid -from datetime import datetime, timezone - -from triage.models import Finding, Package, PackageVersion, Scan - - -class ScimManager: - def __init__(self): - self.evidence = {} - - def process_all_packages(self): - for package in Package.objects.filter(active=True): - self.process_package(package) - - def process_package(self, package: Package): - SIX_MONTHS_AGO = timezone.now() - datetime.timedelta(days=180) - latest_scans = ( - Scan.objects.filter(package_version__package=package, analysis_dt__gte=SIX_MONTHS_AGO) - .order_by("package_version", "-analysis_dt") - .distinct("package_version") - ) - evidence = {} - for scan in latest_scans: - self.process_scan(scan, evidence) - - def process_scan(self, scan: Scan, evidence: dict): - tool = scan.tool - tool_evidence = { - "id": f"https://scim.openssf.org/v1/tool/{tool.name}/{tool.version}", - "type": "tool", - "name": str(scan.tool), - "externalReferences": [ - { - "externalReferenceType": "OTHER", - "locator": f"https://omega.openssf.org/tool/{tool.name}/{tool.version}", - } - ], - } - if tool_evidence not in evidence: - evidence.append(tool_evidence) - - if scan.findings.filter(severity=Finding.SeverityLevel.VERY_HIGH).count() == 0: - evidence.append( - { - "id": uuid.uuid4().hex, - "type": "Claim", - "claimant": "https://scim.openssf.org/v1/tool/{tool.name}/{tool.version}", - "subjects": [scan.project_version.package_url], - "predicateType": "https://scim.openssf.org/v1/types/predicate/conformance", - "predicate": { - "requirement": f"https://omega.openssf.org/scim/requirement/{tool.name}/no-very-high-severity-findings", - }, - } - ) - - if ( - scan.findings.filter( - severity__in=[ - Finding.SeverityLevel.VERY_HIGH, - Finding.SeverityLevel.HIGH, - Finding.SeverityLevel.MEDIUM, - ] - ).count() - == 0 - ): - evidence.append( - { - "id": uuid.uuid4().hex, - "type": "Claim", - "claimant": "https://scim.openssf.org/v1/tool/{tool.name}/{tool.version}", - "subjects": [scan.project_version.package_url], - "predicateType": "https://scim.openssf.org/v1/types/predicate/conformance", - "predicate": { - "requirement": f"https://omega.openssf.org/scim/requirement/{tool.name}/no-medium-or-higher-severity-findings", - }, - } - ) - - package_version = scan.package_version - findings = Finding.objects.filter(package_version=package_version) - self.process_findings(findings) diff --git a/omega/triage-portal/src/triage/views/cases.py b/omega/triage-portal/src/triage/views/cases.py index 38a56a1b..10f5f556 100644 --- a/omega/triage-portal/src/triage/views/cases.py +++ b/omega/triage-portal/src/triage/views/cases.py @@ -54,7 +54,8 @@ def show_cases(request: HttpRequest) -> HttpResponse: return render(request, "triage/case_list.html", context) - +@login_required +@require_http_methods(["GET"]) def show_case(request: HttpRequest, case_uuid: UUID) -> HttpResponse: """Shows a case.""" case = get_object_or_404(Case, uuid=str(case_uuid)) @@ -82,8 +83,9 @@ def new_case(request: HttpRequest) -> HttpResponse: @login_required @require_http_methods(["POST"]) def save_case(request: HttpRequest) -> HttpResponse: + """Saves a case.""" case_uuid = request.POST.get("case_uuid") - if case_uuid is None or case_uuid == "": + if not case_uuid: case = Case() case.created_by = request.user else: diff --git a/omega/triage-portal/src/triage/views/findings.py b/omega/triage-portal/src/triage/views/findings.py index d09c480f..691c4a30 100644 --- a/omega/triage-portal/src/triage/views/findings.py +++ b/omega/triage-portal/src/triage/views/findings.py @@ -2,41 +2,25 @@ import logging import os from base64 import b64encode -from triage.util.content_managers.file_manager import FileManager + from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator -from django.http import ( - HttpRequest, - HttpResponse, - HttpResponseBadRequest, - HttpResponseForbidden, - JsonResponse, -) +from django.http import (HttpRequest, HttpResponse, HttpResponseBadRequest, + JsonResponse) from django.shortcuts import get_object_or_404, redirect, render -from django.contrib.auth import get_user_model from django.views.decorators.cache import cache_page from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from packageurl import PackageURL - -from triage.models import ( - Attachment, - Case, - File, - FileContent, - Finding, - ProjectVersion, - Scan, - WorkItemState, -) -from triage.util.azure_blob_storage import ToolshedBlobStorageAccessor -from triage.util.finding_importers.sarif_importer import SARIFImporter +from triage.models import (Case, File, Finding, + ProjectVersion, WorkItemState) +from triage.util.content_managers.file_manager import FileManager from triage.util.finding_importers.archive_importer import ArchiveImporter +from triage.util.finding_importers.sarif_importer import SARIFImporter from triage.util.general import clamp from triage.util.search_parser import parse_query_to_Q from triage.util.source_viewer import path_to_graph -from triage.util.source_viewer.viewer import SourceViewer logger = logging.getLogger(__name__) @@ -106,9 +90,8 @@ def show_upload(request: HttpRequest) -> HttpResponse: @login_required def show_finding_by_uuid(request: HttpRequest, finding_uuid) -> HttpResponse: finding = get_object_or_404(Finding, uuid=finding_uuid) - from django.contrib.auth.models import ( - User, - ) # pylint: disable=import-outside-toplevel + from django.contrib.auth.models import \ + User # pylint: disable=import-outside-toplevel assignee_list = User.objects.all() context = {"finding": finding, "assignee_list": assignee_list} @@ -168,7 +151,7 @@ def api_get_source_code(request: HttpRequest) -> JsonResponse: if file and file.file_key: file_manager = FileManager() content = file_manager.get_file(file.file_key) - if content: + if content is not None: return JsonResponse( { "file_contents": b64encode(content).decode("utf-8"), @@ -265,38 +248,6 @@ def api_add_scan_archive(request: HttpRequest) -> JsonResponse: return JsonResponse({"success": True}) - -@csrf_exempt -@require_http_methods(["POST"]) -def api_add(request: HttpRequest) -> JsonResponse: - """Inserts data into the database. - - Required: - - sarif => the SARIF content (file contents) - - package_url => the package URL (must include version) - - scan_artifact => an archive of the content analyzed - """ - sarif = request.FILES.get("sarif") - if sarif is None: - return JsonResponse({"error": "No sarif provided"}) - sarif_content = json.load(sarif) - - scan_artifact = request.FILES.get("scan_artifact") - if scan_artifact is None: - return JsonResponse({"error": "No scan_artifact provided"}) - - try: - package_url = PackageURL.from_string(request.POST.get("package_url")) - if package_url.version is None: - raise ValueError("Missing version") - except ValueError: - return JsonResponse({"error": "Invalid or missing package url"}) - - user = get_user_model().objects.get(pk=1) - SARIFImporter.import_sarif_file(package_url, sarif_content, user) - return JsonResponse({"success": True}) - - @csrf_exempt @require_http_methods(["POST"]) def api_add_artifact(request: HttpRequest) -> JsonResponse: diff --git a/omega/triage-portal/src/triage/views/wiki.py b/omega/triage-portal/src/triage/views/wiki.py index b880998c..6f52b5a2 100644 --- a/omega/triage-portal/src/triage/views/wiki.py +++ b/omega/triage-portal/src/triage/views/wiki.py @@ -124,6 +124,16 @@ def edit_wiki_article_revision( @login_required @require_http_methods(["POST"]) def save_wiki_article(request: HttpRequest) -> HttpResponse: + """Saves a wiki article. + + Required fields: + wiki_article_uuid: UUID of the wiki article to update. If not provided, a new + wiki article will be created. + title: Title of the wiki article. + content: Content of the wiki article. + state: State of the wiki article. + change_comment: Comment for the change. + """ wiki_article_uuid = request.POST.get("wiki_article_uuid") if not wiki_article_uuid: wiki_article = WikiArticle() From fae8d9c7df39ac5ba192b030b3c104c4645e5bf8 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Mon, 19 Dec 2022 00:09:01 +0000 Subject: [PATCH 18/21] Improvements to SARIF import (finding right file) Signed-off-by: Michael Scovetta --- .../util/finding_importers/sarif_importer.py | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py b/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py index 16e811de..bd2e3368 100644 --- a/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py +++ b/omega/triage-portal/src/triage/util/finding_importers/sarif_importer.py @@ -13,7 +13,7 @@ from django.contrib.auth.models import AbstractBaseUser from packageurl import PackageURL -from triage.models import Finding, ProjectVersion, Scan, Tool, WorkItemState +from triage.models import Finding, ProjectVersion, Scan, Tool, WorkItemState, File from triage.util.general import get_complex logger = logging.getLogger(__name__) @@ -85,7 +85,9 @@ def import_sarif_file( message = get_complex(result, "message.text") level = get_complex(result, "level") for location in get_complex(result, "locations"): - artifact_location = get_complex(location, "physicalLocation.artifactLocation") + artifact_location = get_complex( + location, "physicalLocation.artifactLocation" + ) src_root = get_complex(artifact_location, "uriBaseId", "%SRCROOT%") if str(src_root).upper() not in ["%SRCROOT%", "SRCROOT"]: @@ -97,7 +99,9 @@ def import_sarif_file( key = { "title": message, "path": uri, - "line_number": get_complex(location, "physicalLocation.region.startLine"), + "line_number": get_complex( + location, "physicalLocation.region.startLine" + ), } key = hashlib.sha256(json.dumps(key).encode("utf-8")).digest() @@ -108,17 +112,10 @@ def import_sarif_file( file_path = get_complex(artifact_location, "uri") file_path = cls.normalize_file_path(file_path) - possible_files = project_version.files.filter(path__endswith=os.path.basename(file_path)) - if len(possible_files) > 1: - logger.debug("Multiple files found for path %s, skipping.", file_path) - for pf in possible_files: - logger.debug("Possible file: %s", pf.path) + file = cls.get_most_likely_source(project_version, file_path) + if not file: + logger.debug("File not found, skipping.") continue - file = possible_files.first() - - #if not file: - # logger.debug("File %s not found, skipping.", file_path) - # continue # Create the issue finding = Finding() @@ -133,7 +130,9 @@ def import_sarif_file( location, "physicalLocation.region.startLine", None ) finding.severity_level = Finding.SeverityLevel.parse(level) - finding.analyst_severity_level = Finding.SeverityLevel.NOT_SPECIFIED + finding.analyst_severity_level = ( + Finding.SeverityLevel.NOT_SPECIFIED + ) finding.confidence = Finding.ConfidenceLevel.NOT_SPECIFIED finding.created_by = user @@ -160,7 +159,7 @@ def import_sarif_file( return False @classmethod - def normalize_file_path(self, path): + def normalize_file_path(cls, path): """Normalizes a file path to be relative to the root.""" logger.debug("normalize_file_path(%s)", path) try: @@ -173,7 +172,7 @@ def normalize_file_path(self, path): return path @classmethod - def normalize_title(self, title): + def normalize_title(cls, title): norm = { r"^Bracket object notation with user input is present.*": "Bracket object notation", r"^Object injection via bracket notation.*": "Object injection", @@ -183,3 +182,42 @@ def normalize_title(self, title): if re.match(regex, title, re.IGNORECASE): return replacement return title + + @classmethod + def get_most_likely_source( + cls, project_version: ProjectVersion, file_path: str + ) -> File | None: + """Returns the most likely source file for a given issue.""" + + possible_files = project_version.files.filter( + path__endswith=os.path.basename(file_path) + ) + if not possible_files: + logger.debug("No files found for path %s, skipping.", file_path) + return None + + if len(possible_files) == 1: + logger.debug( + "Only one possible file found for path %s, using that one.", file_path + ) + return possible_files.first() + + # Let's make up a shortest-suffix algorithm (why not?!) + file_path = file_path.strip(os.path.sep) + parts = file_path.split(os.path.sep) + best_option = None + + # Iterate through increasingly large suffixes of the path, and see which files + # end with it. Only count the first one found at each level, since we have no + # other way to distinguish between them. + for i in range(len(parts) - 1, -1, -1): + target = os.path.sep + os.path.sep.join(parts[i:]) + logger.debug("New target: [%s]", target) + + for possible_file in possible_files: + if possible_file.path.endswith(target): + logger.debug("Best option is now [%s]", possible_file.path) + best_option = possible_file + break + + return best_option From 3d2b003ce3a06cfda556166510387573f06ef968 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Mon, 19 Dec 2022 05:53:26 +0000 Subject: [PATCH 19/21] Minor UI improvements, move file uploads to $HOME. Signed-off-by: Michael Scovetta --- omega/triage-portal/src/core/settings.py | 2 +- .../templates/triage/findings_list.html | 2 +- .../templates/triage/findings_show.html | 51 ++++++++----------- 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/omega/triage-portal/src/core/settings.py b/omega/triage-portal/src/core/settings.py index f16113df..b895a5de 100644 --- a/omega/triage-portal/src/core/settings.py +++ b/omega/triage-portal/src/core/settings.py @@ -219,7 +219,7 @@ "default": { "provider": "triage.util.content_managers.file_manager.FileManager", "args": { - "root_path": "/tmp/omega-fs" + "root_path": "/home/vscode/omega-fs" } } } \ No newline at end of file diff --git a/omega/triage-portal/src/triage/templates/triage/findings_list.html b/omega/triage-portal/src/triage/templates/triage/findings_list.html index d0f9d86b..36f602cf 100644 --- a/omega/triage-portal/src/triage/templates/triage/findings_list.html +++ b/omega/triage-portal/src/triage/templates/triage/findings_list.html @@ -69,7 +69,7 @@
      +
    • +

      Details

      +
      +
      Tool:
      +
      {{ finding.tool.friendly_name }} {{ finding.tool.version }}
      +
      +
    • +
  • -
  • - + - -

    Triage Status

    -
    - - - - - - - - -
    -
  • -
  • Severity

  • - + {% for label in label_list %} {% endfor %} @@ -231,9 +224,9 @@
  • Notes

    -
  • +
  • - +
  • @@ -309,9 +302,9 @@ } }); }); - $('#estimated_impact').on('keypress', (e) => { - if (e.keyCode === 13) { - e.preventDefault(); + $('#estimated_impact').on('keypress', (e) => { + if (e.keyCode === 13) { + e.preventDefault(); $('#estimated_impact').trigger('change'); } }); @@ -344,7 +337,7 @@ } }); - + /* Resize the file and editor */ $(window).on('resize', (e) => { $('#editor').css('height', $(window).height() - $('#editor').offset().top - 10); From 39b17ec8c4342242e025c32e752c97d095a6a7f6 Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Wed, 21 Dec 2022 03:44:01 +0000 Subject: [PATCH 20/21] Minor fixes to tool defects. Signed-off-by: Michael Scovetta --- omega/triage-portal/src/triage/models/finding.py | 2 +- omega/triage-portal/src/triage/views/tool_defect.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/omega/triage-portal/src/triage/models/finding.py b/omega/triage-portal/src/triage/models/finding.py index edd68cd2..b7b2a97d 100644 --- a/omega/triage-portal/src/triage/models/finding.py +++ b/omega/triage-portal/src/triage/models/finding.py @@ -147,7 +147,7 @@ def __str__(self): return f"{self.normalized_title} in {self.file}:{self.file_line}" def get_absolute_url(self): - return f"/finding/{self.uuid}" + return f"/findings/{self.uuid}" @property def get_filename_display(self): diff --git a/omega/triage-portal/src/triage/views/tool_defect.py b/omega/triage-portal/src/triage/views/tool_defect.py index bcff014a..b3267eeb 100644 --- a/omega/triage-portal/src/triage/views/tool_defect.py +++ b/omega/triage-portal/src/triage/views/tool_defect.py @@ -106,6 +106,12 @@ def save_tool_defect(request: HttpRequest) -> HttpResponse: note_content = request.POST.get("note_content") tool_defect.save() + finding_uuid = request.POST.get("finding_uuid"); + if finding_uuid: + finding = get_object_or_404(Finding, uuid=finding_uuid) + tool_defect.findings.add(finding) + tool_defect.save() + if note_content and note_content.strip(): note = Note(content=note_content, created_by=request.user, updated_by=request.user) note.save() From b8289519fad992f3d8c1407f540633dee9a521fd Mon Sep 17 00:00:00 2001 From: Michael Scovetta Date: Tue, 3 Jan 2023 22:13:57 -0800 Subject: [PATCH 21/21] Clean up TODO comment I honestly can't remember what the TODO comment meant, but going through the file, things look fine. Signed-off-by: Michael Scovetta --- omega/triage-portal/src/triage/util/source_viewer/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/omega/triage-portal/src/triage/util/source_viewer/__init__.py b/omega/triage-portal/src/triage/util/source_viewer/__init__.py index f9683e9d..b81a6ff6 100644 --- a/omega/triage-portal/src/triage/util/source_viewer/__init__.py +++ b/omega/triage-portal/src/triage/util/source_viewer/__init__.py @@ -38,7 +38,7 @@ def path_to_graph(files: QuerySet, package_url, separator="/", root=None): if root: result.append( { - "id": root, # TODO minimize this via a lookup table + "id": root, "full_path": "#", "text": root, "parent": "#", @@ -75,7 +75,7 @@ def path_to_graph(files: QuerySet, package_url, separator="/", root=None): if node_name and node_id not in seen_nids: result.append( { - "id": node_id, # TODO minimize this via a lookup table + "id": node_id, "full_path": node_id, "text": node_name, "parent": parent_id,