├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── .isort.cfg ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── config.py ├── config ├── .gitignore └── 00-default.conf ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 2a69a8406f71_add_user_idp_id_attribute.py │ ├── cf0c99c08578_.py │ └── d0b4cb352ca1_drop_package_md5sum_column.py ├── pacman ├── .gitignore └── arch │ └── x86_64 │ └── pacman.conf ├── requirements.txt ├── test-requirements.txt ├── test ├── __init__.py ├── conftest.py ├── data │ └── openid-client.json ├── test_admin.py ├── test_advisory.py ├── test_cve.py ├── test_group.py ├── test_index.py ├── test_login.py ├── test_package.py ├── test_profile.py ├── test_sso.py ├── test_stats.py ├── test_todo.py └── util.py ├── tracker ├── __init__.py ├── advisory.py ├── cli │ ├── __init__.py │ ├── db.py │ ├── run.py │ ├── setup.py │ ├── shell.py │ ├── update.py │ └── util.py ├── form │ ├── __init__.py │ ├── admin.py │ ├── advisory.py │ ├── base.py │ ├── confirm.py │ ├── cve.py │ ├── group.py │ ├── login.py │ ├── user.py │ └── validators.py ├── maintenance.py ├── model │ ├── __init__.py │ ├── advisory.py │ ├── cve.py │ ├── cvegroup.py │ ├── cvegroupentry.py │ ├── cvegrouppackage.py │ ├── enum.py │ ├── package.py │ └── user.py ├── pacman.py ├── static │ ├── archlogo.8a05bc7f6cd1.svg │ ├── favicon.ico │ ├── feed.svg │ ├── normalize.css │ ├── opensans.woff │ ├── opensans.woff2 │ └── style.css ├── symbol.py ├── templates │ ├── _formhelpers.html │ ├── admin │ │ ├── form │ │ │ ├── delete_user.html │ │ │ └── user.html │ │ └── user.html │ ├── advisories.html │ ├── advisory.html │ ├── advisory.txt │ ├── base.html │ ├── bug.txt │ ├── cve.html │ ├── error.html │ ├── feed.html │ ├── form │ │ ├── advisory.html │ │ ├── cve.html │ │ ├── delete_advisory.html │ │ ├── delete_cve.html │ │ ├── delete_group.html │ │ ├── group.html │ │ ├── profile.html │ │ └── publish.html │ ├── group.html │ ├── index.html │ ├── log │ │ ├── advisory_log.html │ │ ├── advisory_log_table.html │ │ ├── cve_log.html │ │ ├── cve_log_table.html │ │ ├── group_log.html │ │ ├── group_log_table.html │ │ ├── log.html │ │ └── user_log.html │ ├── login.html │ ├── navbar.html │ ├── package.html │ ├── stats.html │ └── todo.html ├── user.py ├── util.py └── view │ ├── __init__.py │ ├── add.py │ ├── admin.py │ ├── advisory.py │ ├── blueprint.py │ ├── copy.py │ ├── delete.py │ ├── edit.py │ ├── error.py │ ├── index.py │ ├── login.py │ ├── show.py │ ├── stats.py │ ├── todo.py │ └── user.py └── trackerctl /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = tab 9 | indent_size = 4 10 | 11 | [*.py] 12 | indent_style = space 13 | indent_size = 4 14 | max_line_length = 119 15 | 16 | [*.yml] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.md] 21 | indent_style = space 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [pull_request, push] 2 | jobs: 3 | test: 4 | runs-on: ubuntu-latest 5 | container: archlinux/archlinux:latest 6 | steps: 7 | - name: Install dependencies 8 | run: > 9 | pacman -Syu --noconfirm git make python python-authlib python-isort python-pytest python-pytest-cov 10 | python-sqlalchemy python-sqlalchemy-continuum python-flask python-flask-sqlalchemy python-flask-wtf 11 | python-flask-login python-flask-migrate python-flask-talisman python-email-validator python-feedgen 12 | python-pytz python-requests python-scrypt python-markupsafe pyalpm sqlite 13 | - uses: actions/checkout@v3 14 | - name: Run tests 15 | run: | 16 | git config --global --add safe.directory /__w/arch-security-tracker/arch-security-tracker 17 | python3 -m py_compile $(git ls-files '*.py') 18 | make test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | tracker.db 3 | tracker.db* 4 | .cache 5 | .virtualenv 6 | .coverage 7 | .pytest_cache/ 8 | test/coverage 9 | Makefile.local 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule ".external/normalize.css"] 2 | path = .external/normalize.css 3 | url = https://github.com/necolas/normalize.css 4 | [submodule ".external/archlinux-common-style"] 5 | path = .external/archlinux-common-style 6 | url = https://gitlab.archlinux.org/archlinux/archlinux-common-style.git 7 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | force_single_line = 1 3 | skip = migrations/env.py 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Tests 2 | 3 | - If possible, prefer writing a test case for whatever you add, change or fix. 4 | - Always run the whole test suite before submitting a new pull request. 5 | 6 | 7 | ## Coding Style 8 | 9 | Keep indention and style consistency in mind and double-check your commit diffs before pushing. 10 | 11 | ### Definition 12 | * **Always:** 13 | * **End of line:** LF 14 | * **Insert final newline:** yes 15 | * **Python:** 16 | * **Indention:** 4 spaces 17 | * **Style:** full PEP except max line length on judgement (E501) 18 | * **HTML/CSS:** 19 | * **Indention:** 1 tab 20 | * **YAML:** 21 | * **Indention:** 2 spaces 22 | * **Markdown:** 23 | * **Indention:** 2 spaces 24 | 25 | 26 | ### Vim settings 27 | * **Python:** 28 | ``` 29 | foldmethod=indent tabstop=4 expandtab 30 | let g:syntastic_python_flake8_args='--ignore=E501' 31 | ``` 32 | * **HTML/CSS:** 33 | ``` 34 | foldmethod=indent noexpandtab 35 | ``` 36 | * **YAML:** 37 | ``` 38 | foldmethod=indent tabstop=2 expandtab 39 | ``` 40 | * **Markdown:** 41 | ``` 42 | foldmethod=indent tabstop=2 expandtab 43 | ``` 44 | 45 | ## Git pull-request 46 | 47 | ### Contribute 48 | 49 | 1. Always amend or interactive rebase and force push changes (don't add adjustment commits on top). 50 | 2. Try to limit the number of commits and prefer to use a single commit for small changes. 51 | 3. Never do merge commits, rebase the master into your branch to update it. 52 | 4. Never use the `Update branch` feature on GitHub (create a merge commit) 53 | 5. Always add a single component before the commit message where the change belongs to (look at the history for inspiration) 54 | 6. If there is a matching open issue, reference it like `Fixes #1` in the extended commit message 55 | 56 | ### Review 57 | 58 | 1. Don't be too conservative if you see any potential problem (that's not blaming) 59 | 2. Find a logical and objective consensus 60 | 3. Never skip a review just because there are already approvals (more is better) 61 | 62 | ### Apply 63 | 64 | 1. Always wait for at least **2** approvals (possibly more, especially for huger changes) 65 | 2. Never apply when there is any disapproval left, always try to find a consensus 66 | 3. Use the "rebase and merge" feature or consider using "squash and merge" if too much noisy commits were added 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Levente Polyak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | -include Makefile.local 2 | 3 | PYTEST?=py.test 4 | PYTEST_OPTIONS+=-s 5 | PYTEST_INPUT?=test 6 | PYTEST_COVERAGE_OPTIONS+=--cov-report=term-missing --cov-report=html:test/coverage --cov=tracker 7 | PYTEST_PDB?=0 8 | PYTEST_PDB_OPTIONS?=--pdb --pdbcls=IPython.terminal.debugger:TerminalPdb 9 | 10 | ISORT?=isort 11 | ISORT_OPTIONS+=--skip .virtualenv --skip .venv 12 | ISORT_CHECK_OPTIONS+=--check-only --diff 13 | 14 | .PHONY: update test 15 | 16 | all: update 17 | 18 | ifeq (${PYTEST_PDB},1) 19 | PYTEST_OPTIONS+= ${PYTEST_PDB_OPTIONS} 20 | else 21 | test-pdb: PYTEST_OPTIONS+= ${PYTEST_PDB_OPTIONS} 22 | endif 23 | test-pdb: test 24 | 25 | setup: submodule 26 | ./trackerctl setup bootstrap 27 | 28 | submodule: 29 | git submodule update --recursive --init --rebase 30 | 31 | update: setup 32 | ./trackerctl update env 33 | 34 | user: setup 35 | ./trackerctl setup user 36 | 37 | run: setup 38 | ./trackerctl run 39 | 40 | shell: setup 41 | ./trackerctl shell 42 | 43 | check: setup 44 | ./trackerctl db check 45 | 46 | db-upgrade: setup 47 | ./trackerctl db upgrade 48 | 49 | test: test-py test-isort 50 | 51 | test-py coverage: setup 52 | PYTHONPATH=".:${PYTHONPATH}" ${PYTEST} ${PYTEST_INPUT} ${PYTEST_OPTIONS} ${PYTEST_COVERAGE_OPTIONS} 53 | 54 | test-isort: 55 | @if [[ -n "$$(which colordiff 2>/dev/null)" ]]; then \ 56 | DIFF="$$(${ISORT} ${ISORT_OPTIONS} ${ISORT_CHECK_OPTIONS} .)"; EXIT=$$?; \ 57 | cat <<< $$DIFF|colordiff; if [[ 0 -ne "$$EXIT" ]]; then exit $$EXIT; fi; \ 58 | else \ 59 | ${ISORT} ${ISORT_OPTIONS} ${ISORT_CHECK_OPTIONS} .; \ 60 | fi 61 | @echo "Checking isort...ok" 62 | 63 | open-coverage: coverage 64 | ${BROWSER} test/coverage/index.html 65 | 66 | isort: 67 | ${ISORT} ${ISORT_OPTIONS} . 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arch Linux Security Tracker [![Build Status](https://travis-ci.com/archlinux/arch-security-tracker.svg?branch=master)](https://travis-ci.com/archlinux/arch-security-tracker) 2 | 3 | The **Arch Linux Security Tracker** is a lightweight flask based panel 4 | for tracking vulnerabilities in Arch Linux packages, displaying 5 | vulnerability details and generating security advisories. 6 | 7 | ## Features 8 | 9 | * Issue tracking 10 | * Issue grouping 11 | * libalpm support 12 | * Todo lists 13 | * Advisory scheduling 14 | * Advisory generation 15 | * SSO or local users 16 | 17 | ## Dependencies 18 | 19 | ### Application 20 | 21 | * python >= 3.4 22 | * python-sqlalchemy 23 | * python-sqlalchemy-continuum 24 | * python-flask 25 | * python-flask-sqlalchemy 26 | * python-flask-talisman 27 | * python-flask-wtf 28 | * python-flask-login 29 | * python-flask-migrate 30 | * python-authlib 31 | * python-email-validator 32 | * python-requests 33 | * python-scrypt 34 | * python-feedgen 35 | * python-pytz 36 | * python-markupsafe 37 | * pyalpm 38 | * sqlite 39 | 40 | ### Tests 41 | 42 | * python-isort 43 | * python-pytest 44 | * python-pytest-cov 45 | 46 | ### Virtualenv 47 | 48 | Python dependencies can be installed in a virtual environment (`venv`), by running: 49 | 50 | ``` 51 | python -m venv .virtualenv 52 | . .virtualenv/bin/activate 53 | pip install -r requirements.txt 54 | ``` 55 | 56 | For running tests: 57 | ``` 58 | pip install -r test-requirements.txt 59 | ``` 60 | 61 | ## Setup 62 | 63 | ``` 64 | make 65 | ``` 66 | 67 | run debug mode: 68 | 69 | ``` 70 | make run 71 | ``` 72 | 73 | adding a new user: 74 | 75 | ``` 76 | make user 77 | ``` 78 | 79 | run tests: 80 | 81 | ``` 82 | make test 83 | ``` 84 | 85 | For production run it through ```uwsgi``` 86 | 87 | ## Command line interface 88 | 89 | The ```trackerctl``` script provides access to the command line interface 90 | that controls and operates different parts of the tracker. All commands 91 | and subcommands provide a ```--help``` option that describes the operation 92 | and all its available options. 93 | 94 | ## Configuration 95 | 96 | The configurations are all placed into the ```config``` directory and 97 | applied as a sorted cascade. 98 | 99 | The default values in the ```00-default.conf``` file should not be 100 | altered for customization. If some tweaking is required, simply create 101 | a new configuration file with a ```.local.conf``` suffix and some non 102 | zero prefix like ```20-user.local.conf```. Files using this suffix are 103 | on the ```.gitignore``` and not handled as untracked or dirty. 104 | 105 | ## SSO setup 106 | 107 | A simple test environment for SSO can be configured using Keycloak: 108 | 109 | 1. Run a local Keycloak installation via docker as [described 110 | upstream](https://www.keycloak.org/getting-started/getting-started-docker). 111 | 112 | 2. Create an ```arch-security-tracker``` client in Keycloak like in 113 | [test/data/openid-client.json](test/data/openid-client.json). 114 | Make sure the client contains a mapper for the group memberships called 115 | ```groups``` which is included as a claim. 116 | 117 | 3. Create a local tracker config file with enabled SSO and configure OIDC 118 | secrets, groups and metadata url accordingly. 119 | 120 | ## Contribution 121 | 122 | Help is appreciated, for some guidelines and recommendations check our 123 | [Contribution](CONTRIBUTING.md) file. 124 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from glob import glob 3 | from os import environ 4 | from os.path import abspath 5 | from os.path import dirname 6 | 7 | basedir = abspath(dirname(__file__)) 8 | 9 | config = ConfigParser() 10 | config_files = sorted(glob('{}/config/*.conf'.format(basedir))) 11 | 12 | # ignore local configs during test run or when explicitly deactivated 13 | if environ.get('TRACKER_CONFIG_LOCAL', 'true').lower() not in ['1', 'yes', 'true', 'on']: 14 | config_files = list(filter(lambda f: not f.endswith(".local.conf"), config_files)) 15 | 16 | for config_file in config_files: 17 | config.read(config_file) 18 | 19 | atom_feeds = [] 20 | 21 | 22 | def get_debug_flag(): 23 | return config_flask.getboolean('debug') 24 | 25 | 26 | def set_debug_flag(debug): 27 | global FLASK_DEBUG 28 | FLASK_DEBUG = debug 29 | environ.setdefault('FLASK_DEBUG', '1' if FLASK_DEBUG else '0') 30 | config_flask['debug'] = 'on' if debug else 'off' 31 | 32 | 33 | config_tracker = config['tracker'] 34 | TRACKER_ADVISORY_URL = config_tracker['advisory_url'] 35 | TRACKER_BUGTRACKER_URL = config_tracker['bugtracker_url'] 36 | TRACKER_MAILMAN_URL = config_tracker['mailman_url'] 37 | TRACKER_GROUP_URL = config_tracker['group_url'] 38 | TRACKER_ISSUE_URL = config_tracker['issue_url'] 39 | TRACKER_PASSWORD_LENGTH_MIN = config_tracker.getint('password_length_min') 40 | TRACKER_PASSWORD_LENGTH_MAX = config_tracker.getint('password_length_max') 41 | TRACKER_SUMMARY_LENGTH_MAX = config_tracker.getint('summary_length_max') 42 | TRACKER_LOG_ENTRIES_PER_PAGE = config_tracker.getint('log_entries_per_page') 43 | TRACKER_FEED_ADVISORY_ENTRIES = config_tracker.getint('feed_advisory_entries') 44 | 45 | config_sqlite = config['sqlite'] 46 | SQLITE_JOURNAL_MODE = config_sqlite['journal_mode'] 47 | SQLITE_TEMP_STORE = config_sqlite['temp_store'] 48 | SQLITE_SYNCHRONOUS = config_sqlite['synchronous'] 49 | SQLITE_MMAP_SIZE = config_sqlite.getint('mmap_size') 50 | SQLITE_CACHE_SIZE = config_sqlite.getint('cache_size') 51 | 52 | config_sqlalchemy = config['sqlalchemy'] 53 | SQLALCHEMY_DATABASE_URI = config_sqlalchemy['database_uri'].replace('{{BASEDIR}}', basedir) 54 | SQLALCHEMY_MIGRATE_REPO = config_sqlalchemy['migrate_repo'].replace('{{BASEDIR}}', basedir) 55 | SQLALCHEMY_ECHO = config_sqlalchemy.getboolean('echo') 56 | SQLALCHEMY_TRACK_MODIFICATIONS = config_sqlalchemy.getboolean('track_modifications') 57 | 58 | config_flask = config['flask'] 59 | CSRF_ENABLED = config_flask.getboolean('csrf') 60 | SECRET_KEY = config_flask['secret_key'] 61 | FLASK_HOST = config_flask['host'] 62 | FLASK_PORT = config_flask.getint('port') 63 | FLASK_SESSION_PROTECTION = None if 'none' == config_flask['session_protection'] else config_flask['session_protection'] 64 | set_debug_flag(config_flask.getboolean('debug')) 65 | FLASK_STRICT_TRANSPORT_SECURITY = config_flask.getboolean('strict_transport_security') 66 | SESSION_COOKIE_SAMESITE = config_flask['session_cookie_samesite'] 67 | 68 | config_pacman = config['pacman'] 69 | PACMAN_HANDLE_CACHE_TIME = config_pacman.getint('handle_cache_time') 70 | 71 | config_sso = config['sso'] 72 | SSO_ENABLED = config_sso.getboolean('enabled') 73 | SSO_CLIENT_SECRET = config_sso.get('client_secret') 74 | SSO_CLIENT_ID = config_sso.get('client_id') 75 | SSO_ADMINISTRATOR_GROUP = config_sso.get('administrator_group') 76 | SSO_SECURITY_TEAM_GROUP = config_sso.get('security_team_group') 77 | SSO_REPORTER_GROUP = config_sso.get('reporter_group') 78 | SSO_METADATA_URL = config_sso.get('metadata_url') 79 | -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | *.local.conf 2 | -------------------------------------------------------------------------------- /config/00-default.conf: -------------------------------------------------------------------------------- 1 | [tracker] 2 | advisory_url = https://security.archlinux.org/AVG-{1} 3 | group_url = https://security.archlinux.org/AVG-{0} 4 | issue_url = https://security.archlinux.org/{0} 5 | bugtracker_url = https://bugs.archlinux.org/task/{0} 6 | mailman_url = https://lists.archlinux.org/archives/list/arch-security@lists.archlinux.org/ 7 | password_length_min = 16 8 | password_length_max = 64 9 | summary_length_max = 200 10 | log_entries_per_page = 10 11 | feed_advisory_entries = 15 12 | 13 | [pacman] 14 | handle_cache_time = 120 15 | 16 | [flask] 17 | host = 0.0.0.0 18 | port = 5000 19 | debug = off 20 | secret_key = changeme_iddqd 21 | csrf = on 22 | session_protection = strong 23 | strict_transport_security = off 24 | session_cookie_samesite = Lax 25 | 26 | [sqlalchemy] 27 | echo = no 28 | track_modifications = no 29 | database_uri = sqlite:///{{BASEDIR}}/tracker.db 30 | migrate_repo = {{BASEDIR}}/migrations 31 | 32 | [sqlite] 33 | journal_mode = WAL 34 | temp_store = MEMORY 35 | synchronous = NORMAL 36 | mmap_size = 268435456 37 | cache_size = -40960 38 | 39 | [sso] 40 | enabled = no 41 | metadata_url = http://localhost:8080/realms/master/.well-known/openid-configuration 42 | client_id = arch-security-tracker 43 | client_secret = 79750d90-42b5-4789-add7-30b01d7b05ab 44 | administrator_group = Administrator 45 | security_team_group = Security Team 46 | reporter_group = Reporter 47 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | # add your model's MetaData object here 6 | # for 'autogenerate' support 7 | # from myapp import mymodel 8 | # target_metadata = mymodel.Base.metadata 9 | from flask import current_app 10 | from sqlalchemy import engine_from_config 11 | from sqlalchemy import event 12 | from sqlalchemy import pool 13 | from sqlalchemy.engine import Engine 14 | 15 | # this is the Alembic Config object, which provides 16 | # access to the values within the .ini file in use. 17 | config = context.config 18 | 19 | # Interpret the config file for Python logging. 20 | # This line sets up loggers basically. 21 | fileConfig(config.config_file_name) 22 | logger = logging.getLogger('alembic.env') 23 | 24 | 25 | @event.listens_for(Engine, 'connect') 26 | def set_sqlite_pragma(dbapi_connection, connection_record): 27 | isolation_level = dbapi_connection.isolation_level 28 | dbapi_connection.isolation_level = None 29 | cursor = dbapi_connection.cursor() 30 | cursor.execute("PRAGMA foreign_keys=OFF") 31 | cursor.close() 32 | dbapi_connection.isolation_level = isolation_level 33 | 34 | 35 | config.set_main_option('sqlalchemy.url', 36 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 37 | target_metadata = current_app.extensions['migrate'].db.metadata 38 | 39 | # other values from the config, defined by the needs of env.py, 40 | # can be acquired: 41 | # my_important_option = config.get_main_option("my_important_option") 42 | # ... etc. 43 | 44 | 45 | def run_migrations_offline(): 46 | """Run migrations in 'offline' mode. 47 | 48 | This configures the context with just a URL 49 | and not an Engine, though an Engine is acceptable 50 | here as well. By skipping the Engine creation 51 | we don't even need a DBAPI to be available. 52 | 53 | Calls to context.execute() here emit the given string to the 54 | script output. 55 | 56 | """ 57 | url = config.get_main_option("sqlalchemy.url") 58 | context.configure(url=url) 59 | 60 | with context.begin_transaction(): 61 | context.run_migrations() 62 | 63 | 64 | def run_migrations_online(): 65 | """Run migrations in 'online' mode. 66 | 67 | In this scenario we need to create an Engine 68 | and associate a connection with the context. 69 | 70 | """ 71 | 72 | # this callback is used to prevent an auto-migration from being generated 73 | # when there are no changes to the schema 74 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 75 | def process_revision_directives(context, revision, directives): 76 | if getattr(config.cmd_opts, 'autogenerate', False): 77 | script = directives[0] 78 | if script.upgrade_ops.is_empty(): 79 | directives[:] = [] 80 | logger.info('No changes in schema detected.') 81 | 82 | engine = engine_from_config(config.get_section(config.config_ini_section), 83 | prefix='sqlalchemy.', 84 | poolclass=pool.NullPool) 85 | 86 | connection = engine.connect() 87 | context.configure(connection=connection, 88 | target_metadata=target_metadata, 89 | process_revision_directives=process_revision_directives, 90 | **current_app.extensions['migrate'].configure_args) 91 | 92 | try: 93 | with context.begin_transaction(): 94 | context.run_migrations() 95 | finally: 96 | connection.close() 97 | 98 | 99 | if context.is_offline_mode(): 100 | run_migrations_offline() 101 | else: 102 | run_migrations_online() 103 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/2a69a8406f71_add_user_idp_id_attribute.py: -------------------------------------------------------------------------------- 1 | """add user IDP id attribute 2 | 3 | Revision ID: 2a69a8406f71 4 | Revises: cf0c99c08578 5 | Create Date: 2021-05-04 20:39:27.197143 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '2a69a8406f71' 13 | down_revision = 'cf0c99c08578' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | with op.batch_alter_table('user', schema=None) as batch_op: 20 | batch_op.add_column(sa.Column('idp_id', sa.String(length=255), nullable=True, index=True, unique=True)) 21 | 22 | def downgrade(): 23 | with op.batch_alter_table('user', schema=None) as batch_op: 24 | batch_op.drop_column('idp_id') 25 | -------------------------------------------------------------------------------- /migrations/versions/d0b4cb352ca1_drop_package_md5sum_column.py: -------------------------------------------------------------------------------- 1 | """drop package.md5sum column 2 | 3 | Revision ID: d0b4cb352ca1 4 | Revises: 2a69a8406f71 5 | Create Date: 2024-03-25 10:09:20.603755 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = 'd0b4cb352ca1' 13 | down_revision = '2a69a8406f71' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | with op.batch_alter_table('package', schema=None) as batch_op: 21 | batch_op.drop_column('md5sum') 22 | 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | with op.batch_alter_table('package', schema=None) as batch_op: 29 | batch_op.add_column(sa.Column('md5sum', sa.VARCHAR(length=32), nullable=False)) 30 | 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /pacman/.gitignore: -------------------------------------------------------------------------------- 1 | cache/ 2 | arch/*/db/ 3 | log/ 4 | -------------------------------------------------------------------------------- /pacman/arch/x86_64/pacman.conf: -------------------------------------------------------------------------------- 1 | # 2 | # /etc/pacman.conf 3 | # 4 | # See the pacman.conf(5) manpage for option and repository directives 5 | 6 | # 7 | # GENERAL OPTIONS 8 | # 9 | [options] 10 | # The following paths are commented out with their default values listed. 11 | # If you wish to use different paths, uncomment and update the paths. 12 | RootDir = ./pacman 13 | DBPath = ./pacman/arch/x86_64/db 14 | CacheDir = ./pacman/cache 15 | LogFile = ./pacman/log/pacman.log 16 | #GPGDir = /etc/pacman.d/gnupg/ 17 | HoldPkg = pacman glibc 18 | #XferCommand = /usr/bin/curl -C - -f %u > %o 19 | #XferCommand = /usr/bin/wget --passive-ftp -c -O %o %u 20 | #CleanMethod = KeepInstalled 21 | #UseDelta = 0.7 22 | Architecture = x86_64 23 | 24 | # Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup 25 | #IgnorePkg = 26 | #IgnoreGroup = 27 | 28 | #NoUpgrade = 29 | #NoExtract = 30 | 31 | # Misc options 32 | #UseSyslog 33 | Color 34 | #TotalDownload 35 | CheckSpace 36 | #VerbosePkgLists 37 | ILoveCandy 38 | 39 | # By default, pacman accepts packages signed by keys that its local keyring 40 | # trusts (see pacman-key and its man page), as well as unsigned packages. 41 | SigLevel = Required DatabaseOptional 42 | LocalFileSigLevel = Optional 43 | #RemoteFileSigLevel = Required 44 | 45 | # NOTE: You must run `pacman-key --init` before first using pacman; the local 46 | # keyring can then be populated with the keys of all official Arch Linux 47 | # packagers with `pacman-key --populate archlinux`. 48 | 49 | # 50 | # REPOSITORIES 51 | # - can be defined here or included from another file 52 | # - pacman will search repositories in the order defined here 53 | # - local/custom mirrors can be added here or in separate files 54 | # - repositories listed first will take precedence when packages 55 | # have identical names, regardless of version number 56 | # - URLs will have $repo replaced by the name of the current repo 57 | # - URLs will have $arch replaced by the name of the architecture 58 | # 59 | # Repository entries are of the format: 60 | # [repo-name] 61 | # Server = ServerName 62 | # Include = IncludePath 63 | # 64 | # The header [repo-name] is crucial - it must be present and 65 | # uncommented to enable the repo. 66 | # 67 | 68 | # The testing repositories are disabled by default. To enable, uncomment the 69 | # repo name header and Include lines. You can add preferred servers immediately 70 | # after the header, and they will be used before the default mirrors. 71 | 72 | [core-testing] 73 | Include = /etc/pacman.d/mirrorlist 74 | 75 | [core] 76 | Include = /etc/pacman.d/mirrorlist 77 | 78 | [extra-testing] 79 | Include = /etc/pacman.d/mirrorlist 80 | 81 | [extra] 82 | Include = /etc/pacman.d/mirrorlist 83 | 84 | # If you want to run 32 bit applications on your x86_64 system, 85 | # enable the multilib repositories as required here. 86 | 87 | [multilib-testing] 88 | Include = /etc/pacman.d/mirrorlist 89 | 90 | [multilib] 91 | Include = /etc/pacman.d/mirrorlist 92 | 93 | # An example of a custom package repository. See the pacman manpage for 94 | # tips on creating your own repositories. 95 | #[custom] 96 | #SigLevel = Optional TrustAll 97 | #Server = file:///home/custompkgs 98 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | Flask-Login 3 | Flask-SQLAlchemy 4 | Flask-Migrate 5 | Flask-WTF 6 | flask-talisman 7 | email_validator 8 | requests 9 | scrypt 10 | pyalpm 11 | SQLAlchemy-Continuum 12 | SQLAlchemy<2.0 13 | feedgen 14 | pytz 15 | authlib 16 | MarkupSafe 17 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | isort 2 | pytest 3 | pytest-cov 4 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | # ignore local configs during test run 4 | environ['TRACKER_CONFIG_LOCAL'] = 'false' 5 | -------------------------------------------------------------------------------- /test/data/openid-client.json: -------------------------------------------------------------------------------- 1 | { 2 | "clientId": "arch-security-tracker", 3 | "rootUrl": "http://localhost:5000", 4 | "adminUrl": "http://localhost:5000", 5 | "surrogateAuthRequired": false, 6 | "enabled": true, 7 | "alwaysDisplayInConsole": false, 8 | "clientAuthenticatorType": "client-secret", 9 | "redirectUris": [ 10 | "http://localhost:5000/*" 11 | ], 12 | "webOrigins": [ 13 | "http://localhost:5000" 14 | ], 15 | "notBefore": 0, 16 | "bearerOnly": false, 17 | "consentRequired": false, 18 | "standardFlowEnabled": true, 19 | "implicitFlowEnabled": false, 20 | "directAccessGrantsEnabled": false, 21 | "serviceAccountsEnabled": false, 22 | "publicClient": false, 23 | "frontchannelLogout": false, 24 | "protocol": "openid-connect", 25 | "attributes": { 26 | "saml.assertion.signature": "false", 27 | "saml.force.post.binding": "false", 28 | "saml.multivalued.roles": "false", 29 | "saml.encrypt": "false", 30 | "login_theme": "base", 31 | "saml.server.signature": "false", 32 | "saml.server.signature.keyinfo.ext": "false", 33 | "exclude.session.state.from.auth.response": "false", 34 | "saml_force_name_id_format": "false", 35 | "saml.client.signature": "false", 36 | "tls.client.certificate.bound.access.tokens": "false", 37 | "saml.authnstatement": "false", 38 | "display.on.consent.screen": "false", 39 | "saml.onetimeuse.condition": "false" 40 | }, 41 | "authenticationFlowBindingOverrides": {}, 42 | "fullScopeAllowed": true, 43 | "nodeReRegistrationTimeout": -1, 44 | "protocolMappers": [ 45 | { 46 | "name": "groups", 47 | "protocol": "openid-connect", 48 | "protocolMapper": "oidc-group-membership-mapper", 49 | "consentRequired": false, 50 | "config": { 51 | "full.path": "true", 52 | "id.token.claim": "true", 53 | "access.token.claim": "true", 54 | "claim.name": "groups", 55 | "userinfo.token.claim": "true" 56 | } 57 | } 58 | ], 59 | "defaultClientScopes": [ 60 | "role_list", 61 | "email", 62 | "profile" 63 | ], 64 | "optionalClientScopes": [ 65 | "web-origins", 66 | "address", 67 | "phone", 68 | "offline_access", 69 | "roles", 70 | "microprofile-jwt" 71 | ], 72 | "access": { 73 | "view": true, 74 | "configure": true, 75 | "manage": true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/test_index.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | 3 | from .conftest import DEFAULT_GROUP_ID 4 | from .conftest import DEFAULT_GROUP_NAME 5 | from .conftest import create_group 6 | from .conftest import create_package 7 | 8 | 9 | @create_package(name='foo', version='1.2.3-4') 10 | @create_group(id=DEFAULT_GROUP_ID, packages=['foo'], affected='1.2.3-3', fixed='1.2.3-4') 11 | def test_index(db, client): 12 | resp = client.get(url_for('tracker.index'), follow_redirects=True) 13 | assert 200 == resp.status_code 14 | assert 'text/html; charset=utf-8' == resp.content_type 15 | assert DEFAULT_GROUP_NAME not in resp.data.decode() 16 | 17 | 18 | @create_package(name='foo', version='1.2.3-4') 19 | @create_group(id=DEFAULT_GROUP_ID, packages=['foo'], affected='1.2.3-3') 20 | def test_index_vulnerable(db, client): 21 | resp = client.get(url_for('tracker.index_vulnerable'), follow_redirects=True) 22 | assert 200 == resp.status_code 23 | assert DEFAULT_GROUP_NAME in resp.data.decode() 24 | 25 | 26 | @create_package(name='foo', version='1.2.3-4') 27 | @create_group(id=DEFAULT_GROUP_ID, packages=['foo'], affected='1.2.3-3') 28 | def test_index_all(db, client): 29 | resp = client.get(url_for('tracker.index_all'), follow_redirects=True) 30 | assert 200 == resp.status_code 31 | assert DEFAULT_GROUP_NAME in resp.data.decode() 32 | 33 | 34 | @create_package(name='foo', version='1.2.3-4') 35 | @create_group(id=DEFAULT_GROUP_ID, packages=['foo'], affected='1.2.3-3') 36 | def test_index_json(db, client): 37 | resp = client.get(url_for('tracker.index_json', only_vulernable=False), follow_redirects=True) 38 | assert 200 == resp.status_code 39 | data = resp.get_json() 40 | assert 'application/json; charset=utf-8' == resp.content_type 41 | assert len(data) == 1 42 | assert data[0]['name'] == DEFAULT_GROUP_NAME 43 | 44 | 45 | @create_package(name='foo', version='1.2.3-4') 46 | @create_group(id=DEFAULT_GROUP_ID, packages=['foo'], affected='1.2.3-3') 47 | def test_index_vulnerable_json(db, client): 48 | resp = client.get(url_for('tracker.index_vulnerable_json'), follow_redirects=True) 49 | assert 200 == resp.status_code 50 | data = resp.get_json() 51 | assert len(data) == 1 52 | assert data[0]['name'] == DEFAULT_GROUP_NAME 53 | -------------------------------------------------------------------------------- /test/test_login.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from flask_login import current_user 3 | from werkzeug.exceptions import Unauthorized 4 | 5 | from config import TRACKER_PASSWORD_LENGTH_MIN 6 | from tracker.form.login import ERROR_ACCOUNT_DISABLED 7 | from tracker.form.login import ERROR_INVALID_USERNAME_PASSWORD 8 | 9 | from .conftest import DEFAULT_USERNAME 10 | from .conftest import assert_logged_in 11 | from .conftest import assert_not_logged_in 12 | from .conftest import create_user 13 | from .conftest import logged_in 14 | 15 | 16 | def test_login_view(db, client): 17 | resp = client.get(url_for('tracker.login')) 18 | assert 200 == resp.status_code 19 | assert 'text/html; charset=utf-8' == resp.content_type 20 | 21 | 22 | @create_user 23 | def test_login_success(db, client): 24 | resp = client.post(url_for('tracker.login'), follow_redirects=True, 25 | data=dict(username=DEFAULT_USERNAME, password=DEFAULT_USERNAME)) 26 | assert_logged_in(resp) 27 | assert DEFAULT_USERNAME == current_user.name 28 | 29 | 30 | @create_user 31 | def test_login_invalid_credentials(db, client): 32 | resp = client.post(url_for('tracker.login'), data={'username': DEFAULT_USERNAME, 33 | 'password': 'N' * TRACKER_PASSWORD_LENGTH_MIN}) 34 | assert_not_logged_in(resp, status_code=Unauthorized.code) 35 | assert 'text/html; charset=utf-8' == resp.content_type 36 | assert ERROR_INVALID_USERNAME_PASSWORD in resp.data.decode() 37 | 38 | 39 | def test_login_invalid_form(db, client): 40 | resp = client.post(url_for('tracker.login'), data={'username': DEFAULT_USERNAME}) 41 | assert_not_logged_in(resp, status_code=Unauthorized.code) 42 | assert 'This field is required.' in resp.data.decode() 43 | 44 | 45 | @create_user(active=False) 46 | def test_login_disabled(db, client): 47 | resp = client.post(url_for('tracker.login'), data={'username': DEFAULT_USERNAME, 'password': DEFAULT_USERNAME}) 48 | assert_not_logged_in(resp, status_code=Unauthorized.code) 49 | assert ERROR_ACCOUNT_DISABLED in resp.data.decode() 50 | 51 | 52 | @logged_in 53 | def test_login_logged_in_redirect(db, client): 54 | resp = client.post(url_for('tracker.login'), follow_redirects=False) 55 | assert 302 == resp.status_code 56 | assert resp.location.endswith('/') 57 | 58 | 59 | @logged_in 60 | def test_logout(db, client): 61 | resp = client.post(url_for('tracker.logout'), follow_redirects=True) 62 | assert_not_logged_in(resp) 63 | 64 | 65 | def test_logout_not_logged_in(db, client): 66 | resp = client.post(url_for('tracker.logout'), follow_redirects=False) 67 | assert 302 == resp.status_code 68 | assert resp.location.endswith('/') 69 | -------------------------------------------------------------------------------- /test/test_package.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from werkzeug.exceptions import NotFound 3 | 4 | from tracker.model.cve import issue_types 5 | from tracker.model.enum import Publication 6 | 7 | from .conftest import DEFAULT_ADVISORY_ID 8 | from .conftest import DEFAULT_GROUP_ID 9 | from .conftest import create_advisory 10 | from .conftest import create_group 11 | from .conftest import create_package 12 | from .util import AssertionHTMLParser 13 | 14 | 15 | @create_package(name='foo', version='1.2.3-4') 16 | @create_group(id=DEFAULT_GROUP_ID, packages=['foo'], affected='1.2.3-3', fixed='1.2.3-4') 17 | @create_advisory(id=DEFAULT_ADVISORY_ID, group_package_id=DEFAULT_GROUP_ID, advisory_type=issue_types[1], reference='https://security.archlinux.org', publication=Publication.published) 18 | def test_show_package_json(db, client): 19 | resp = client.get(url_for('tracker.show_package_json', pkgname='foo', suffix='/json'), follow_redirects=True) 20 | assert 200 == resp.status_code 21 | assert 'application/json; charset=utf-8' == resp.content_type 22 | data = resp.get_json() 23 | assert len(data['groups']) == 1 24 | assert len(data['versions']) == 1 25 | assert len(data['advisories']) == 1 26 | assert len(data['issues']) == 1 27 | assert data['name'] == 'foo' 28 | 29 | 30 | def test_show_package_json_not_found(db, client): 31 | resp = client.get(url_for('tracker.show_package_json', pkgname='foo', suffix='/json'), follow_redirects=True) 32 | assert NotFound.code == resp.status_code 33 | assert 'application/json; charset=utf-8' == resp.content_type 34 | 35 | 36 | def test_show_package_not_found(db, client): 37 | resp = client.get(url_for('tracker.show_package', pkgname='foo'), follow_redirects=True) 38 | assert NotFound.code == resp.status_code 39 | assert 'text/html; charset=utf-8' == resp.content_type 40 | 41 | 42 | @create_package(name='foo', version='1.2.3-4') 43 | @create_group(id=DEFAULT_GROUP_ID, packages=['foo'], affected='1.2.3-3', fixed='1.2.3-4') 44 | @create_advisory(id=DEFAULT_ADVISORY_ID, group_package_id=DEFAULT_GROUP_ID, advisory_type=issue_types[1], reference='https://security.archlinux.org', publication=Publication.published) 45 | def test_show_package(db, client): 46 | resp = client.get(url_for('tracker.show_package', pkgname='foo'), follow_redirects=True) 47 | html = AssertionHTMLParser() 48 | html.feed(resp.data.decode()) 49 | assert 200 == resp.status_code 50 | assert 'text/html; charset=utf-8' == resp.content_type 51 | assert 'foo' in resp.data.decode() 52 | -------------------------------------------------------------------------------- /test/test_profile.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | from flask_login import current_user 3 | 4 | from config import TRACKER_PASSWORD_LENGTH_MAX 5 | from config import TRACKER_PASSWORD_LENGTH_MIN 6 | from tracker.form.user import ERROR_PASSWORD_CONTAINS_USERNAME 7 | from tracker.form.user import ERROR_PASSWORD_INCORRECT 8 | from tracker.form.user import ERROR_PASSWORD_REPEAT_MISMATCHES 9 | from tracker.user import random_string 10 | 11 | from .conftest import DEFAULT_USERNAME 12 | from .conftest import assert_logged_in 13 | from .conftest import assert_not_logged_in 14 | from .conftest import logged_in 15 | 16 | 17 | @logged_in 18 | def test_change_password(db, client): 19 | new_password = DEFAULT_USERNAME[::-1] 20 | resp = client.post(url_for('tracker.edit_own_user_profile'), follow_redirects=True, 21 | data=dict(password=new_password, password_repeat=new_password, 22 | password_current=DEFAULT_USERNAME)) 23 | assert resp.status_code == 200 24 | 25 | # logout and test if new password was applied 26 | resp = client.post(url_for('tracker.logout'), follow_redirects=True) 27 | assert_not_logged_in(resp) 28 | resp = client.post(url_for('tracker.login'), follow_redirects=True, 29 | data=dict(username=DEFAULT_USERNAME, password=new_password)) 30 | assert_logged_in(resp) 31 | assert DEFAULT_USERNAME == current_user.name 32 | 33 | 34 | @logged_in 35 | def test_invalid_password_length(db, client): 36 | resp = client.post(url_for('tracker.edit_own_user_profile'), follow_redirects=True, 37 | data=dict(password='1234', new_password='1234', password_current=DEFAULT_USERNAME)) 38 | assert 'Field must be between {} and {} characters long.' \ 39 | .format(TRACKER_PASSWORD_LENGTH_MIN, TRACKER_PASSWORD_LENGTH_MAX) in resp.data.decode() 40 | assert resp.status_code == 200 41 | 42 | 43 | @logged_in 44 | def test_password_must_not_contain_username(db, client): 45 | new_password = '{}123'.format(DEFAULT_USERNAME) 46 | resp = client.post(url_for('tracker.edit_own_user_profile'), follow_redirects=True, 47 | data=dict(password=new_password, password_repeat=new_password, 48 | password_current=DEFAULT_USERNAME)) 49 | assert resp.status_code == 200 50 | assert ERROR_PASSWORD_CONTAINS_USERNAME in resp.data.decode() 51 | 52 | 53 | @logged_in 54 | def test_password_repeat_mismatches(db, client): 55 | new_password = random_string() 56 | resp = client.post(url_for('tracker.edit_own_user_profile'), follow_redirects=True, 57 | data=dict(password=new_password, password_repeat=new_password[::-1], 58 | password_current=DEFAULT_USERNAME)) 59 | assert resp.status_code == 200 60 | assert ERROR_PASSWORD_REPEAT_MISMATCHES in resp.data.decode() 61 | 62 | 63 | @logged_in 64 | def test_current_password_incorrect(db, client): 65 | new_password = random_string() 66 | resp = client.post(url_for('tracker.edit_own_user_profile'), follow_redirects=True, 67 | data=dict(password=new_password, password_repeat=new_password, 68 | password_current=new_password)) 69 | assert resp.status_code == 200 70 | assert ERROR_PASSWORD_INCORRECT in resp.data.decode() 71 | -------------------------------------------------------------------------------- /test/util.py: -------------------------------------------------------------------------------- 1 | from html.parser import HTMLParser 2 | 3 | 4 | class AssertionHTMLElement(object): 5 | def __init__(self, tag, attrs): 6 | self.tag = tag 7 | self.attrs = attrs 8 | self.data = None 9 | 10 | def __repr__(self): 11 | return f'tag: {self.tag} attrs: {self.attrs} data: {self.data}' 12 | 13 | class AssertionHTMLParser(HTMLParser): 14 | SELF_CLOSING_TAGS = { 15 | "area", "base", "br", "col", "embed", 16 | "hr", "img", "input", "link", "meta", 17 | "param", "source", "track", "wbr"} 18 | 19 | def __init__(self): 20 | HTMLParser.__init__(self) 21 | 22 | def reset(self): 23 | self.elements = [] 24 | self.processing = [] 25 | HTMLParser.reset(self) 26 | 27 | def handle_starttag(self, tag, attrs): 28 | element = AssertionHTMLElement(tag=tag, attrs=attrs) 29 | if tag not in self.SELF_CLOSING_TAGS: 30 | self.elements.append(element) 31 | self.processing.append(element) 32 | 33 | def handle_endtag(self, tag): 34 | if tag in self.SELF_CLOSING_TAGS: 35 | #
will emit both starttag and endtag 36 | return 37 | elem = self.processing.pop() 38 | if elem.tag != tag: 39 | raise ValueError("tag {} ended by {}".format(elem.tag, tag)) 40 | 41 | def handle_data(self, data): 42 | if not self.processing: 43 | return 44 | self.processing[-1].data = data.strip() 45 | 46 | def get_element_by_id(self, id): 47 | return next(iter(self.get_elements_by_attribute('id', id)), None) 48 | 49 | def get_elements_by_attribute(self, key, value): 50 | return list(filter( 51 | lambda e: any(filter(lambda attr: attr == (key, value), e.attrs)), 52 | self.elements)) 53 | 54 | def get_elements_by_tag(self, tag): 55 | return list(filter(lambda e: e.tag == tag, self.elements)) 56 | -------------------------------------------------------------------------------- /tracker/__init__.py: -------------------------------------------------------------------------------- 1 | from types import MethodType 2 | 3 | from authlib.integrations.flask_client import OAuth 4 | from flask import Blueprint 5 | from flask import Flask 6 | from flask import url_for 7 | from flask_login import LoginManager 8 | from flask_migrate import Migrate 9 | from flask_sqlalchemy import SQLAlchemy 10 | from flask_talisman import Talisman 11 | from sqlalchemy import event 12 | from sqlalchemy import orm 13 | from sqlalchemy.engine import Engine 14 | from sqlalchemy.sql.expression import ClauseElement 15 | from sqlalchemy_continuum import make_versioned 16 | from sqlalchemy_continuum.plugins import FlaskPlugin 17 | from sqlalchemy_continuum.plugins import PropertyModTrackerPlugin 18 | from werkzeug.routing import BaseConverter 19 | 20 | from config import FLASK_SESSION_PROTECTION 21 | from config import FLASK_STRICT_TRANSPORT_SECURITY 22 | from config import SQLALCHEMY_MIGRATE_REPO 23 | from config import SQLITE_CACHE_SIZE 24 | from config import SQLITE_JOURNAL_MODE 25 | from config import SQLITE_MMAP_SIZE 26 | from config import SQLITE_SYNCHRONOUS 27 | from config import SQLITE_TEMP_STORE 28 | from config import SSO_CLIENT_ID 29 | from config import SSO_CLIENT_SECRET 30 | from config import SSO_ENABLED 31 | from config import SSO_METADATA_URL 32 | from config import atom_feeds 33 | 34 | 35 | @event.listens_for(Engine, 'connect') 36 | def set_sqlite_pragma(dbapi_connection, connection_record): 37 | isolation_level = dbapi_connection.isolation_level 38 | dbapi_connection.isolation_level = None 39 | cursor = dbapi_connection.cursor() 40 | cursor.execute('PRAGMA temp_store = {}'.format(SQLITE_TEMP_STORE)) 41 | cursor.execute('PRAGMA journal_mode = {}'.format(SQLITE_JOURNAL_MODE)) 42 | cursor.execute('PRAGMA synchronous = {}'.format(SQLITE_SYNCHRONOUS)) 43 | cursor.execute('PRAGMA mmap_size = {}'.format(SQLITE_MMAP_SIZE)) 44 | cursor.execute('PRAGMA cache_size = {}'.format(SQLITE_CACHE_SIZE)) 45 | cursor.execute('PRAGMA foreign_keys = ON') 46 | cursor.close() 47 | dbapi_connection.isolation_level = isolation_level 48 | 49 | 50 | class RegexConverter(BaseConverter): 51 | def __init__(self, url_map, *items): 52 | super(RegexConverter, self).__init__(url_map) 53 | self.regex = items[0] 54 | 55 | 56 | def db_get(self, model, defaults=None, **kwargs): 57 | return self.session.query(model).filter_by(**kwargs).first() 58 | 59 | 60 | def db_create(self, model, defaults=None, **kwargs): 61 | params = dict((k, v) for k, v in kwargs.items() if not isinstance(v, ClauseElement)) 62 | params.update(defaults or {}) 63 | instance = model(**params) 64 | self.session.add(instance) 65 | self.session.flush() 66 | return instance 67 | 68 | 69 | def db_get_or_create(self, model, defaults=None, **kwargs): 70 | instance = self.get(model, defaults, **kwargs) 71 | if instance: 72 | return instance 73 | return self.create(model, defaults, **kwargs) 74 | 75 | 76 | def handle_unauthorized_access_with_sso(): 77 | redirect_url = url_for('tracker.login', _external=True) 78 | return oauth.idp.authorize_redirect(redirect_url) 79 | 80 | 81 | csp = { 82 | 'default-src': '\'self\'', 83 | 'style-src': '\'self\'', 84 | 'font-src': '\'self\'', 85 | 'form-action': '\'self\'' 86 | } 87 | 88 | db = SQLAlchemy() 89 | db.get = MethodType(db_get, db) 90 | db.create = MethodType(db_create, db) 91 | db.get_or_create = MethodType(db_get_or_create, db) 92 | 93 | make_versioned(plugins=[FlaskPlugin(), PropertyModTrackerPlugin()]) 94 | migrate = Migrate(db=db, directory=SQLALCHEMY_MIGRATE_REPO) 95 | talisman = Talisman() 96 | login_manager = LoginManager() 97 | oauth = OAuth() 98 | tracker = Blueprint('tracker', __name__) 99 | 100 | 101 | def create_app(script_info=None): 102 | app = Flask(__name__) 103 | app.config.from_object('config') 104 | 105 | db.init_app(app) 106 | migrate.init_app(app) 107 | orm.configure_mappers() 108 | 109 | talisman.init_app(app, 110 | force_https=False, 111 | session_cookie_secure=False, 112 | content_security_policy=csp, 113 | strict_transport_security=FLASK_STRICT_TRANSPORT_SECURITY, 114 | referrer_policy='no-referrer') 115 | 116 | login_manager.init_app(app) 117 | login_manager.session_protection = FLASK_SESSION_PROTECTION 118 | login_manager.login_view = 'tracker.login' 119 | 120 | app.url_map.converters['regex'] = RegexConverter 121 | app.jinja_env.globals['ATOM_FEEDS'] = atom_feeds 122 | app.jinja_env.globals['SSO_ENABLED'] = SSO_ENABLED 123 | 124 | if SSO_ENABLED: 125 | app.config["IDP_CLIENT_ID"] = SSO_CLIENT_ID 126 | app.config["IDP_CLIENT_SECRET"] = SSO_CLIENT_SECRET 127 | 128 | oauth.init_app(app) 129 | oauth.register( 130 | name='idp', 131 | server_metadata_url=SSO_METADATA_URL, 132 | client_kwargs={ 133 | 'scope': 'openid email' 134 | } 135 | ) 136 | login_manager.unauthorized_handler(handle_unauthorized_access_with_sso) 137 | 138 | from tracker.view.error import error_handlers 139 | for error_handler in error_handlers: 140 | app.register_error_handler(error_handler['code_or_exception'], error_handler['func']) 141 | 142 | from tracker.view.blueprint import blueprint 143 | app.register_blueprint(tracker) 144 | app.register_blueprint(blueprint) 145 | 146 | @app.shell_context_processor 147 | def make_shell_context(): 148 | from tracker.model import CVE 149 | from tracker.model import Advisory 150 | from tracker.model import CVEGroup 151 | from tracker.model import CVEGroupEntry 152 | from tracker.model import CVEGroupPackage 153 | from tracker.model import Package 154 | from tracker.model import User 155 | return dict(db=db, migrate=migrate, talisman=talisman, login_manager=login_manager, tracker=tracker, 156 | Advisory=Advisory, CVE=CVE, CVEGroup=CVEGroup, CVEGroupEntry=CVEGroupEntry, 157 | CVEGroupPackage=CVEGroupPackage, User=User, Package=Package, oauth=oauth) 158 | 159 | return app 160 | -------------------------------------------------------------------------------- /tracker/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .db import * 2 | from .run import * 3 | from .setup import * 4 | from .shell import * 5 | from .update import * 6 | -------------------------------------------------------------------------------- /tracker/cli/db.py: -------------------------------------------------------------------------------- 1 | from os.path import exists 2 | from os.path import join 3 | 4 | from click import echo 5 | from click import option 6 | from click import pass_context 7 | from flask.cli import with_appcontext 8 | from flask_migrate import stamp 9 | from flask_migrate.cli import db as db_cli 10 | 11 | from config import basedir 12 | from tracker import db 13 | 14 | 15 | def abort_if_false(ctx, param, value): 16 | if not value: 17 | ctx.abort() 18 | 19 | 20 | @db_cli.command() 21 | @with_appcontext 22 | def vacuum(): 23 | """Perform vacuum on the database.""" 24 | 25 | echo('Performing database vacuum...', nl=False) 26 | db.session.execute('VACUUM') 27 | echo('done') 28 | 29 | 30 | @db_cli.command() 31 | @option('--yes', is_flag=True, callback=abort_if_false, 32 | expose_value=False, prompt='Are you sure you want to drop the database?') 33 | @with_appcontext 34 | @pass_context 35 | def drop(ctx): 36 | """Drop the database.""" 37 | 38 | echo('Dropping database...', nl=False) 39 | db.drop_all() 40 | echo('done') 41 | ctx.invoke(vacuum) 42 | 43 | 44 | @db_cli.command() 45 | @option('--purge', is_flag=True, help='Purge all data and tables.') 46 | @with_appcontext 47 | @pass_context 48 | def initdb(ctx, purge): 49 | """Initialize the database and all tables.""" 50 | 51 | if purge: 52 | ctx.invoke(drop) 53 | 54 | echo('Initializing database...', nl=False) 55 | db_exists = exists(join(basedir, 'tracker.db')) 56 | db.create_all() 57 | if not db_exists: 58 | stamp() 59 | echo('done') 60 | 61 | 62 | @db_cli.command() 63 | @option('--integrity/--no-integrity', default=True, help='Check database integrity.') 64 | @option('--foreign-key/--no-foreign-key', default=True, help='Check foreign keys.') 65 | @with_appcontext 66 | def check(integrity, foreign_key): 67 | """Database integrity checks. 68 | 69 | Performs database integrity and foreign key checks and displays the 70 | results if any errors are found.""" 71 | 72 | integrity_errors = False 73 | foreign_key_errors = False 74 | 75 | if integrity: 76 | echo('Checking database integrity...', nl=False) 77 | integrity_result = db.session.execute('PRAGMA integrity_check') 78 | integrity_errors = list(filter(lambda result: result[0] != 'ok', integrity_result.fetchall())) 79 | if not integrity_errors: 80 | echo('ok') 81 | else: 82 | echo('failed') 83 | for error in integrity_errors: 84 | echo('{}'.format(error), err=True) 85 | 86 | if foreign_key: 87 | echo('Checking database foreign keys...', nl=False) 88 | foreign_key_errors = db.session.execute('PRAGMA foreign_key_check').fetchall() 89 | if not foreign_key_errors: 90 | echo('ok') 91 | else: 92 | echo('failed') 93 | header_table = 'table' 94 | header_row = 'row id' 95 | header_parent = 'parent' 96 | header_fkey = 'fkey idx' 97 | max_table = max(list(map(lambda error: len(error[0]), foreign_key_errors)) + [len(header_table)]) 98 | max_row = max(list(map(lambda error: len(str(error[1])), foreign_key_errors)) + [len(header_row)]) 99 | max_parent = max(list(map(lambda error: len(error[2]), foreign_key_errors)) + [len(header_parent)]) 100 | max_fkey = max(list(map(lambda error: len(str(error[3])), foreign_key_errors)) + [len(header_fkey)]) 101 | header = ' {} | {} | {} | {} '.format(header_table.ljust(max_table), 102 | header_row.ljust(max_row), 103 | header_parent.ljust(max_parent), 104 | header_fkey.ljust(max_fkey)) 105 | echo('=' * len(header), err=True) 106 | echo(header, err=True) 107 | echo('=' * len(header), err=True) 108 | for error in foreign_key_errors: 109 | table = error[0] 110 | row = str(error[1]) 111 | parent = error[2] 112 | fkey = str(error[3]) 113 | echo(' {} | {} | {} | {} '.format(table.ljust(max_table), 114 | row.rjust(max_row), 115 | parent.ljust(max_parent), 116 | fkey.rjust(max_fkey)), err=True) 117 | 118 | if integrity_errors or foreign_key_errors: 119 | exit(1) 120 | -------------------------------------------------------------------------------- /tracker/cli/run.py: -------------------------------------------------------------------------------- 1 | from click import option 2 | from flask.cli import pass_script_info 3 | 4 | from config import FLASK_DEBUG 5 | from config import FLASK_HOST 6 | from config import FLASK_PORT 7 | from config import set_debug_flag 8 | 9 | from .util import cli 10 | 11 | 12 | @cli.command('run', short_help='Runs a development server.') 13 | @option('--host', '-h', default=FLASK_HOST, 14 | help='The interface to bind to.') 15 | @option('--port', '-p', default=FLASK_PORT, 16 | help='The port to bind to.') 17 | @option('--debug/--no-debug', default=FLASK_DEBUG, 18 | help='Enable or disable the debug mode. By default the debug ' 19 | 'mode enables the reloader and debugger.') 20 | @option('--reload/--no-reload', default=None, 21 | help='Enable or disable the reloader. By default the reloader ' 22 | 'is active if debug is enabled.') 23 | @option('--debugger/--no-debugger', default=None, 24 | help='Enable or disable the debugger. By default the debugger ' 25 | 'is active if debug is enabled.') 26 | @option('--with-threads/--without-threads', default=False, 27 | help='Enable or disable multithreading.') 28 | @pass_script_info 29 | def run(info, host, port, debug, reload, debugger, with_threads): 30 | """Runs a local development server for the Flask application. 31 | 32 | This local server is recommended for development purposes only but it 33 | can also be used for simple intranet deployments. By default it will 34 | not support any sort of concurrency at all to simplify debugging. This 35 | can be changed with the --with-threads option which will enable basic 36 | multithreading. 37 | 38 | The reloader and debugger are by default enabled if the debug flag of 39 | Flask is enabled and disabled otherwise. 40 | """ 41 | import os 42 | 43 | from werkzeug.serving import run_simple 44 | 45 | if debug != FLASK_DEBUG: 46 | set_debug_flag(debug) 47 | if reload is None: 48 | reload = bool(debug) 49 | if debugger is None: 50 | debugger = bool(debug) 51 | 52 | app = info.load_app() 53 | 54 | # Extra startup messages. This depends a bit on Werkzeug internals to 55 | # not double execute when the reloader kicks in. 56 | if os.environ.get('WERKZEUG_RUN_MAIN') != 'true': 57 | # If we have an import path we can print it out now which can help 58 | # people understand what's being served. If we do not have an 59 | # import path because the app was loaded through a callback then 60 | # we won't print anything. 61 | if info.app_import_path is not None: 62 | print(' * Serving Flask app "{}"'.format(info.app_import_path)) 63 | if debug is not None: 64 | print(' * Forcing debug mode {}'.format(debug and 'on' or 'off')) 65 | 66 | run_simple(host, port, app, use_reloader=reload, use_debugger=debugger, threaded=with_threads) 67 | -------------------------------------------------------------------------------- /tracker/cli/setup.py: -------------------------------------------------------------------------------- 1 | from os import rename 2 | from os.path import exists 3 | from os.path import join 4 | from pathlib import Path 5 | from re import IGNORECASE 6 | from re import match 7 | from sys import exit 8 | 9 | from click import BadParameter 10 | from click import Choice 11 | from click import echo 12 | from click import option 13 | from click import pass_context 14 | from click import password_option 15 | 16 | from config import TRACKER_PASSWORD_LENGTH_MAX 17 | from config import TRACKER_PASSWORD_LENGTH_MIN 18 | from config import basedir 19 | from tracker.model.enum import UserRole 20 | from tracker.model.user import User 21 | from tracker.model.user import username_regex 22 | 23 | from .db import initdb 24 | from .util import cli 25 | 26 | 27 | @cli.group() 28 | def setup(): 29 | """Setup and bootstrap the application.""" 30 | pass 31 | 32 | 33 | @setup.command() 34 | @option('--purge', is_flag=True, help='Purge all data and tables.') 35 | @pass_context 36 | def database(ctx, purge=False): 37 | """Initialize the database tables.""" 38 | 39 | # Auto rename old database for compatibility 40 | db_old = join(basedir, 'app.db') 41 | db_new = join(basedir, 'tracker.db') 42 | if exists(db_old) and not exists(db_new): 43 | echo('Renaming old database file...', nl=False) 44 | rename(db_old, db_new) 45 | echo('done') 46 | 47 | ctx.invoke(initdb, purge=purge) 48 | 49 | 50 | @setup.command() 51 | @option('--purge', is_flag=True, help='Purge all data and tables.') 52 | @pass_context 53 | def bootstrap(ctx, purge=False): 54 | """Bootstrap the environment. 55 | 56 | Create all folders, database tables and other things that are required to 57 | run the application. 58 | 59 | An initial administrator user must be created separately.""" 60 | 61 | def mkdir(path): 62 | Path(path).mkdir(parents=True, exist_ok=True) 63 | 64 | echo('Creating folders...', nl=False) 65 | mkdir(join(basedir, 'pacman/cache')) 66 | mkdir(join(basedir, 'pacman/log')) 67 | mkdir(join(basedir, 'pacman/arch/x86_64/db')) 68 | echo('done') 69 | 70 | ctx.invoke(database, purge=purge) 71 | 72 | 73 | def validate_username(ctx, param, username): 74 | if len(username) > User.NAME_LENGTH: 75 | raise BadParameter('must not exceed {} characters'.format(User.NAME_LENGTH)) 76 | if not username or not match(username_regex, username): 77 | raise BadParameter('must match {}'.format(username_regex)) 78 | return username 79 | 80 | 81 | def validate_email(ctx, param, email): 82 | email_regex = r'^.+@([^.@][^@]+)$' 83 | if not email or not match(email_regex, email, IGNORECASE): 84 | raise BadParameter('must match {}'.format(email_regex)) 85 | return email 86 | 87 | 88 | def validate_password(ctx, param, password): 89 | from tracker.user import random_string 90 | if not password or 'generated' == password: 91 | password = random_string() 92 | print('Generated password: {}'.format(password)) 93 | if len(password) > TRACKER_PASSWORD_LENGTH_MAX or len(password) < TRACKER_PASSWORD_LENGTH_MIN: 94 | raise BadParameter('Error: password must be between {} and {} characters.' 95 | .format(TRACKER_PASSWORD_LENGTH_MIN, TRACKER_PASSWORD_LENGTH_MAX)) 96 | return password 97 | 98 | 99 | @setup.command() 100 | @option('--username', prompt=True, callback=validate_username, help='Username used to log in.') 101 | @option('--email', prompt='E-mail', callback=validate_email, help='E-mail address of the user.') 102 | @password_option(default='generated', callback=validate_password, help='Password for the user.') 103 | @option('--role', type=Choice([role.name for role in UserRole]), default=UserRole.reporter.name, 104 | prompt=True, callback=lambda ctx, param, role: role, 105 | help='Permission group of the user.') 106 | @option('--active/--inactive', default=True, prompt=True, help='Enable or disable the user.') 107 | def user(username, email, password, role, active): 108 | """Create a new application user.""" 109 | 110 | from tracker import db 111 | from tracker.user import hash_password 112 | from tracker.user import random_string 113 | 114 | user_by_name = db.get(User, name=username) 115 | if user_by_name: 116 | echo('Error: username already exists', err=True) 117 | exit(1) 118 | 119 | user_by_email = db.get(User, email=email) 120 | if user_by_email: 121 | echo('Error: e-mail already exists', err=True) 122 | exit(1) 123 | 124 | user = User() 125 | user.name = username 126 | user.email = email 127 | user.salt = random_string() 128 | user.password = hash_password(password, user.salt) 129 | user.role = UserRole.fromstring(role) 130 | user.active = active 131 | 132 | db.session.add(user) 133 | db.session.commit() 134 | -------------------------------------------------------------------------------- /tracker/cli/shell.py: -------------------------------------------------------------------------------- 1 | from click import UNPROCESSED 2 | from click import argument 3 | from flask.cli import with_appcontext 4 | 5 | from .util import cli 6 | 7 | 8 | @cli.command(context_settings=dict(ignore_unknown_options=True)) 9 | @argument('ipython_args', nargs=-1, type=UNPROCESSED) 10 | @with_appcontext 11 | def shell(ipython_args): 12 | """Runs a shell in the app context. 13 | Runs an interactive Python shell in the context of a given 14 | Flask application. The application will populate the default 15 | namespace of this shell according to it's configuration. 16 | This is useful for executing small snippets of management code 17 | without having to manually configuring the application. 18 | """ 19 | 20 | from sys import platform 21 | from sys import version 22 | 23 | from flask.globals import _app_ctx_stack 24 | app = _app_ctx_stack.top.app 25 | ctx = app.make_shell_context() 26 | 27 | try: 28 | from IPython import __version__ as ipython_version 29 | from IPython import start_ipython 30 | from IPython.terminal.ipapp import load_default_config 31 | from traitlets.config.loader import Config 32 | 33 | if 'IPYTHON_CONFIG' in app.config: 34 | config = Config(app.config['IPYTHON_CONFIG']) 35 | else: 36 | config = load_default_config() 37 | 38 | config.TerminalInteractiveShell.banner1 = '''Python {} on {} 39 | IPython: {} 40 | App: {}{} 41 | Instance: {}'''.format(version, 42 | platform, 43 | ipython_version, 44 | app.import_name, 45 | app.debug and ' [debug]' or '', 46 | app.instance_path) 47 | start_ipython( 48 | argv=ipython_args, 49 | user_ns=ctx, 50 | config=config, 51 | ) 52 | except ImportError: 53 | # fallback to standard python interactive console 54 | from code import interact 55 | 56 | banner = '''Python {} on {} 57 | App: {}{} 58 | Instance: {}'''.format(version, 59 | platform, 60 | app.import_name, 61 | app.debug and ' [debug]' or '', 62 | app.instance_path) 63 | interact(local=ctx, banner=banner) 64 | -------------------------------------------------------------------------------- /tracker/cli/update.py: -------------------------------------------------------------------------------- 1 | from click import echo 2 | from click import option 3 | from click import pass_context 4 | 5 | from .util import cli 6 | 7 | 8 | @cli.group() 9 | def update(): 10 | """Update the application. 11 | 12 | In doubt, call env as a general purpose command.""" 13 | pass 14 | 15 | 16 | @update.command() 17 | @option('--force', is_flag=True, default=False, help='Force database update.') 18 | def pacman(force): 19 | """Update pacman database.""" 20 | 21 | from tracker.pacman import update as update_pacman_db 22 | 23 | echo('Updating pacman database...', nl=False) 24 | update_pacman_db(force=force) 25 | echo('done') 26 | 27 | 28 | @update.command() 29 | def cache(): 30 | """Update package cache.""" 31 | 32 | from tracker.maintenance import update_package_cache 33 | 34 | echo('Updating package cache...') 35 | update_package_cache() 36 | 37 | 38 | @update.command() 39 | @pass_context 40 | def env(ctx): 41 | """Update pacman, groups and caches.""" 42 | 43 | ctx.invoke(pacman) 44 | ctx.invoke(cache) 45 | ctx.invoke(group) 46 | 47 | 48 | @update.command() 49 | @option('--recalc', is_flag=True, help='Recalculate everything.') 50 | @option('--recalc-status', is_flag=True, help='Recalculate group status.') 51 | @option('--recalc-severity', is_flag=True, help='Recalculate group severity.') 52 | def group(recalc=False, recalc_status=False, recalc_severity=False): 53 | """Update group status.""" 54 | 55 | from tracker.maintenance import recalc_group_severity 56 | from tracker.maintenance import recalc_group_status 57 | from tracker.maintenance import update_group_status 58 | 59 | echo('Updating group status...', nl=False) 60 | updated = update_group_status() 61 | echo('done') 62 | for update in updated: 63 | group = update['group'] 64 | old_status = update['old_status'] 65 | echo(' -> Updated {}: {} -> {}'.format(group.name, old_status, group.status)) 66 | 67 | if recalc or recalc_status: 68 | echo('Recalcing group status...', nl=False) 69 | updated = recalc_group_status() 70 | echo('done') 71 | for update in updated: 72 | group = update['group'] 73 | old_status = update['old_status'] 74 | echo(' -> Updated {}: {} -> {}'.format(group.name, old_status, group.status)) 75 | 76 | if recalc or recalc_severity: 77 | echo('Recalcing group severity...', nl=False) 78 | updated = recalc_group_severity() 79 | echo('done') 80 | for update in updated: 81 | group = update['group'] 82 | old_severity = update['old_severity'] 83 | echo(' -> Updated {}: {} -> {}'.format(group.name, old_severity, group.severity)) 84 | -------------------------------------------------------------------------------- /tracker/cli/util.py: -------------------------------------------------------------------------------- 1 | from flask.cli import FlaskGroup 2 | 3 | from tracker import create_app 4 | 5 | cli = FlaskGroup(add_default_commands=True, create_app=create_app) 6 | -------------------------------------------------------------------------------- /tracker/form/__init__.py: -------------------------------------------------------------------------------- 1 | from .cve import CVEForm 2 | from .group import GroupForm 3 | from .login import LoginForm 4 | -------------------------------------------------------------------------------- /tracker/form/admin.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import or_ 2 | from wtforms import BooleanField 3 | from wtforms import EmailField 4 | from wtforms import PasswordField 5 | from wtforms import SelectField 6 | from wtforms import StringField 7 | from wtforms import SubmitField 8 | from wtforms.validators import DataRequired 9 | from wtforms.validators import Email 10 | from wtforms.validators import Length 11 | from wtforms.validators import Optional 12 | from wtforms.validators import Regexp 13 | 14 | from config import TRACKER_PASSWORD_LENGTH_MAX 15 | from config import TRACKER_PASSWORD_LENGTH_MIN 16 | from tracker.model.enum import UserRole 17 | from tracker.model.user import User 18 | from tracker.model.user import username_regex 19 | 20 | from .base import BaseForm 21 | 22 | ERROR_USERNAME_EXISTS = 'Username already exists.' 23 | ERROR_EMAIL_EXISTS = 'E-Mail already exists.' 24 | 25 | 26 | class UserForm(BaseForm): 27 | username = StringField(u'Username', validators=[DataRequired(), Length(max=User.NAME_LENGTH), Regexp(username_regex)]) 28 | email = EmailField(u'E-Mail', validators=[DataRequired(), Length(max=User.EMAIL_LENGTH), Email()]) 29 | password = PasswordField(u'Password', validators=[Optional(), Length(min=TRACKER_PASSWORD_LENGTH_MIN, max=TRACKER_PASSWORD_LENGTH_MAX)]) 30 | role = SelectField(u'Role', choices=[(e.name, e.label) for e in [*UserRole]], default=UserRole.reporter.name, validators=[DataRequired()]) 31 | active = BooleanField(u'Active', default=True) 32 | random_password = BooleanField(u'Randomize password', default=False) 33 | submit = SubmitField(u'submit') 34 | 35 | def __init__(self, edit=False): 36 | super().__init__() 37 | self.edit = edit 38 | 39 | def validate(self, **kwargs): 40 | rv = BaseForm.validate(self, kwargs) 41 | if not rv: 42 | return False 43 | 44 | if self.password.data and self.username.data in self.password.data: 45 | self.password.errors.append('Password must not contain the username.') 46 | return False 47 | 48 | if self.edit: 49 | return True 50 | 51 | user = User.query.filter(or_(User.name == self.username.data, 52 | User.email == self.email.data)).first() 53 | if not user: 54 | return True 55 | if user.name == self.username.data: 56 | self.username.errors.append(ERROR_USERNAME_EXISTS) 57 | if user.email == self.email.data: 58 | self.email.errors.append(ERROR_EMAIL_EXISTS) 59 | return False 60 | -------------------------------------------------------------------------------- /tracker/form/advisory.py: -------------------------------------------------------------------------------- 1 | from wtforms import BooleanField 2 | from wtforms import HiddenField 3 | from wtforms import SelectField 4 | from wtforms import SubmitField 5 | from wtforms import TextAreaField 6 | from wtforms import URLField 7 | from wtforms.validators import URL 8 | from wtforms.validators import DataRequired 9 | from wtforms.validators import Length 10 | from wtforms.validators import Optional 11 | 12 | from tracker.form.validators import ValidAdvisoryReference 13 | from tracker.model.advisory import Advisory 14 | from tracker.model.advisory import advisory_types 15 | 16 | from .base import BaseForm 17 | 18 | 19 | class AdvisoryForm(BaseForm): 20 | advisory_type = SelectField(u'Type', choices=[(item, item.capitalize()) for item in advisory_types], validators=[DataRequired()]) 21 | submit = SubmitField(u'schedule') 22 | 23 | 24 | class AdvisoryPublishForm(BaseForm): 25 | advisory_content = None 26 | reference = URLField(u'Reference', validators=[DataRequired(), URL(), Length(max=Advisory.REFERENCE_LENGTH), ValidAdvisoryReference()]) 27 | submit = SubmitField(u'publish') 28 | 29 | def __init__(self, advisory_id): 30 | super().__init__() 31 | self.advisory_id = advisory_id 32 | 33 | 34 | class AdvisoryEditForm(BaseForm): 35 | advisory_content = None 36 | reference = URLField(u'Reference', validators=[Optional(), URL(), Length(max=Advisory.REFERENCE_LENGTH), ValidAdvisoryReference()]) 37 | workaround = TextAreaField(u'Workaround', validators=[Optional(), Length(max=Advisory.WORKAROUND_LENGTH)]) 38 | impact = TextAreaField(u'Impact', validators=[Optional(), Length(max=Advisory.IMPACT_LENGTH)]) 39 | changed = HiddenField(u'Changed', validators=[Optional()]) 40 | changed_latest = HiddenField(u'Latest Changed', validators=[Optional()]) 41 | force_submit = BooleanField(u'Force update', default=False, validators=[Optional()]) 42 | edit = SubmitField(u'edit') 43 | 44 | def __init__(self, advisory_id): 45 | super().__init__() 46 | self.advisory_id = advisory_id 47 | -------------------------------------------------------------------------------- /tracker/form/base.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | 3 | 4 | class BaseForm(FlaskForm): 5 | class Meta: 6 | def bind_field(self, form, unbound_field, options): 7 | filters = unbound_field.kwargs.get('filters', []) 8 | filters.append(strip_filter) 9 | return unbound_field.bind(form=form, filters=filters, **options) 10 | 11 | 12 | def strip_filter(value): 13 | if not value or not hasattr(value, 'strip'): 14 | return value 15 | return value.strip() 16 | -------------------------------------------------------------------------------- /tracker/form/confirm.py: -------------------------------------------------------------------------------- 1 | from wtforms import SubmitField 2 | 3 | from .base import BaseForm 4 | 5 | 6 | class ConfirmForm(BaseForm): 7 | confirm = SubmitField(u'confirm') 8 | abort = SubmitField(u'abort') 9 | -------------------------------------------------------------------------------- /tracker/form/cve.py: -------------------------------------------------------------------------------- 1 | from wtforms import BooleanField 2 | from wtforms import HiddenField 3 | from wtforms import SelectField 4 | from wtforms import StringField 5 | from wtforms import SubmitField 6 | from wtforms import TextAreaField 7 | from wtforms.validators import DataRequired 8 | from wtforms.validators import Length 9 | from wtforms.validators import Optional 10 | 11 | from tracker.form.validators import ValidIssue 12 | from tracker.form.validators import ValidURLs 13 | from tracker.model.cve import CVE 14 | from tracker.model.cve import issue_types 15 | from tracker.model.enum import Remote 16 | from tracker.model.enum import Severity 17 | 18 | from .base import BaseForm 19 | 20 | 21 | class CVEForm(BaseForm): 22 | cve = StringField(u'CVE', validators=[DataRequired(), ValidIssue()]) 23 | description = TextAreaField(u'Description', validators=[Optional(), Length(max=CVE.DESCRIPTION_LENGTH)]) 24 | issue_type = SelectField(u'Type', choices=[(item, item.capitalize()) for item in issue_types], validators=[DataRequired()]) 25 | severity = SelectField(u'Severity', choices=[(e.name, e.label) for e in [*Severity]], validators=[DataRequired()]) 26 | remote = SelectField(u'Remote', choices=[(e.name, e.label) for e in [*Remote]], validators=[DataRequired()]) 27 | reference = TextAreaField(u'References', validators=[Optional(), Length(max=CVE.REFERENCES_LENGTH), ValidURLs()]) 28 | notes = TextAreaField(u'Notes', validators=[Optional(), Length(max=CVE.NOTES_LENGTH)]) 29 | changed = HiddenField(u'Changed', validators=[Optional()]) 30 | changed_latest = HiddenField(u'Latest Changed', validators=[Optional()]) 31 | force_submit = BooleanField(u'Force update', default=False, validators=[Optional()]) 32 | submit = SubmitField(u'submit') 33 | 34 | def __init__(self, edit=False): 35 | super().__init__() 36 | self.edit = edit 37 | if edit: 38 | self.cve.render_kw = {'readonly': True} 39 | -------------------------------------------------------------------------------- /tracker/form/group.py: -------------------------------------------------------------------------------- 1 | from pyalpm import vercmp 2 | from wtforms import BooleanField 3 | from wtforms import HiddenField 4 | from wtforms import SelectField 5 | from wtforms import StringField 6 | from wtforms import SubmitField 7 | from wtforms import TextAreaField 8 | from wtforms.validators import DataRequired 9 | from wtforms.validators import Length 10 | from wtforms.validators import Optional 11 | from wtforms.validators import Regexp 12 | 13 | from tracker.form.validators import SamePackageBase 14 | from tracker.form.validators import ValidIssues 15 | from tracker.form.validators import ValidPackageNames 16 | from tracker.form.validators import ValidURLs 17 | from tracker.model.cvegroup import CVEGroup 18 | from tracker.model.cvegroup import pkgver_regex 19 | from tracker.model.enum import Affected 20 | 21 | from .base import BaseForm 22 | 23 | 24 | class GroupForm(BaseForm): 25 | cve = TextAreaField(u'CVE', validators=[DataRequired(), ValidIssues()]) 26 | pkgnames = TextAreaField(u'Package', validators=[DataRequired(), ValidPackageNames(), SamePackageBase()]) 27 | affected = StringField(u'Affected', validators=[DataRequired(), Regexp(pkgver_regex)]) 28 | fixed = StringField(u'Fixed', validators=[Optional(), Regexp(pkgver_regex)]) 29 | status = SelectField(u'Status', choices=[(e.name, e.label) for e in [*Affected]], validators=[DataRequired()]) 30 | bug_ticket = StringField('Bug ticket', validators=[Optional(), Regexp(r'^\d+$')]) 31 | reference = TextAreaField(u'References', validators=[Optional(), Length(max=CVEGroup.REFERENCES_LENGTH), ValidURLs()]) 32 | notes = TextAreaField(u'Notes', validators=[Optional(), Length(max=CVEGroup.NOTES_LENGTH)]) 33 | advisory_qualified = BooleanField(u'Advisory qualified', default=True, validators=[Optional()]) 34 | changed = HiddenField(u'Changed', validators=[Optional()]) 35 | changed_latest = HiddenField(u'Latest Changed', validators=[Optional()]) 36 | force_update = BooleanField(u'Force update', default=False, validators=[Optional()]) 37 | force_creation = BooleanField(u'Force creation', default=False, validators=[Optional()]) 38 | submit = SubmitField(u'submit') 39 | 40 | def __init__(self, packages=[]): 41 | super().__init__() 42 | self.packages = packages 43 | 44 | def validate(self, **kwargs): 45 | rv = BaseForm.validate(self, kwargs) 46 | if not rv: 47 | return False 48 | if self.fixed.data and 0 <= vercmp(self.affected.data, self.fixed.data): 49 | self.fixed.errors.append('Version must be newer.') 50 | return False 51 | return True 52 | -------------------------------------------------------------------------------- /tracker/form/login.py: -------------------------------------------------------------------------------- 1 | from hmac import compare_digest 2 | 3 | from wtforms import PasswordField 4 | from wtforms import StringField 5 | from wtforms import SubmitField 6 | from wtforms.validators import DataRequired 7 | from wtforms.validators import Length 8 | 9 | from config import TRACKER_PASSWORD_LENGTH_MAX 10 | from config import TRACKER_PASSWORD_LENGTH_MIN 11 | from tracker.model.user import User 12 | from tracker.user import hash_password 13 | from tracker.user import random_string 14 | 15 | from .base import BaseForm 16 | 17 | ERROR_INVALID_USERNAME_PASSWORD = 'Invalid username or password.' 18 | ERROR_ACCOUNT_DISABLED = 'Account is disabled.' 19 | dummy_password = hash_password(random_string(), random_string()) 20 | 21 | 22 | class LoginForm(BaseForm): 23 | username = StringField(u'Username', validators=[DataRequired(), Length(max=User.NAME_LENGTH)]) 24 | password = PasswordField(u'Password', validators=[DataRequired(), Length(min=TRACKER_PASSWORD_LENGTH_MIN, max=TRACKER_PASSWORD_LENGTH_MAX)]) 25 | login = SubmitField(u'login') 26 | 27 | def validate(self, **kwargs): 28 | self.user = None 29 | rv = BaseForm.validate(self, kwargs) 30 | if not rv: 31 | return False 32 | 33 | def fail(): 34 | self.password.errors.append(ERROR_INVALID_USERNAME_PASSWORD) 35 | return False 36 | 37 | user = User.query.filter(User.name == self.username.data).first() 38 | if not user: 39 | compare_digest(dummy_password, hash_password(self.password.data, 'the cake is a lie!')) 40 | return fail() 41 | if not compare_digest(user.password, hash_password(self.password.data, user.salt)): 42 | return fail() 43 | if not user.active: 44 | self.username.errors.append(ERROR_ACCOUNT_DISABLED) 45 | return False 46 | self.user = user 47 | return True 48 | -------------------------------------------------------------------------------- /tracker/form/user.py: -------------------------------------------------------------------------------- 1 | from hmac import compare_digest 2 | 3 | from flask_login import current_user 4 | from wtforms import PasswordField 5 | from wtforms import SubmitField 6 | from wtforms.validators import DataRequired 7 | from wtforms.validators import Length 8 | 9 | from config import TRACKER_PASSWORD_LENGTH_MAX 10 | from config import TRACKER_PASSWORD_LENGTH_MIN 11 | from tracker.user import hash_password 12 | 13 | from .base import BaseForm 14 | 15 | ERROR_PASSWORD_CONTAINS_USERNAME = 'Password must not contain the username.' 16 | ERROR_PASSWORD_REPEAT_MISMATCHES = 'Repeated password mismatches.' 17 | ERROR_PASSWORD_INCORRECT = 'Current password incorrect.' 18 | 19 | 20 | class UserPasswordForm(BaseForm): 21 | password = PasswordField(u'New Password', validators=[DataRequired(), Length(min=TRACKER_PASSWORD_LENGTH_MIN, max=TRACKER_PASSWORD_LENGTH_MAX)]) 22 | password_repeat = PasswordField(u'Repeat Password', validators=[DataRequired(), Length(min=TRACKER_PASSWORD_LENGTH_MIN, max=TRACKER_PASSWORD_LENGTH_MAX)]) 23 | password_current = PasswordField(u'Current Password', validators=[DataRequired(), Length(min=TRACKER_PASSWORD_LENGTH_MIN, max=TRACKER_PASSWORD_LENGTH_MAX)]) 24 | submit = SubmitField(u'submit') 25 | 26 | def __init__(self, edit=False): 27 | super().__init__() 28 | 29 | def validate(self, **kwargs): 30 | rv = BaseForm.validate(self, kwargs) 31 | if not rv: 32 | return False 33 | 34 | if current_user.name in self.password.data: 35 | self.password.errors.append(ERROR_PASSWORD_CONTAINS_USERNAME) 36 | return False 37 | 38 | if self.password.data != self.password_repeat.data: 39 | self.password_repeat.errors.append(ERROR_PASSWORD_REPEAT_MISMATCHES) 40 | return False 41 | 42 | if not compare_digest(current_user.password, hash_password(self.password_current.data, current_user.salt)): 43 | self.password_current.errors.append(ERROR_PASSWORD_INCORRECT) 44 | return False 45 | 46 | return True 47 | -------------------------------------------------------------------------------- /tracker/form/validators.py: -------------------------------------------------------------------------------- 1 | from re import match 2 | from re import search 3 | 4 | from wtforms.validators import URL as URLValidator 5 | from wtforms.validators import ValidationError 6 | 7 | from tracker import db 8 | from tracker.advisory import advisory_fetch_from_mailman 9 | from tracker.advisory import generate_advisory 10 | from tracker.model import Package 11 | from tracker.model.advisory import advisory_regex 12 | from tracker.model.cve import cve_id_regex 13 | from tracker.model.cvegroup import pkgname_regex 14 | from tracker.util import multiline_to_list 15 | 16 | ERROR_ISSUE_ID_INVALID = u'Invalid issue.' 17 | ERROR_INVALID_URL = u'Invalid URL {}.' 18 | 19 | 20 | class ValidAdvisoryReference(object): 21 | def __call__(self, form, field): 22 | if not field.data: 23 | return 24 | 25 | mailman_content = advisory_fetch_from_mailman(field.data) 26 | if not mailman_content: 27 | raise ValidationError('Failed to fetch advisory') 28 | 29 | m = search(advisory_regex[1:-1], mailman_content) 30 | if not m: 31 | raise ValidationError('Failed to fetch advisory') 32 | 33 | found = m.group(1) 34 | if found != form.advisory_id: 35 | raise ValidationError('Advisory mismatched: {}'.format(found)) 36 | 37 | form.advisory_content = generate_advisory(advisory_id=form.advisory_id, with_subject=False, raw=True) 38 | 39 | 40 | class ValidPackageName(object): 41 | def __init__(self): 42 | self.message = u'Unknown package.' 43 | 44 | def __call__(self, form, field): 45 | if not match(pkgname_regex, field.data): 46 | self.fail(field.data) 47 | versions = Package.query.filter(name=field.data).first() 48 | if not versions: 49 | raise ValidationError(self.message) 50 | 51 | 52 | class ValidPackageNames(object): 53 | def __init__(self): 54 | self.message = u'Unknown package {}.' 55 | 56 | def fail(self, pkgname): 57 | raise ValidationError(self.message.format(pkgname)) 58 | 59 | def __call__(self, form, field): 60 | pkgnames = set(multiline_to_list(field.data)) 61 | for pkgname in pkgnames: 62 | if not match(pkgname_regex, pkgname): 63 | self.fail(pkgname) 64 | db_packages = db.session.query(Package) \ 65 | .filter(Package.name.in_(pkgnames)) \ 66 | .group_by(Package.name).all() 67 | db_packages = set([pkg.name for pkg in db_packages]) 68 | diff = [pkg for pkg in pkgnames if pkg not in db_packages] 69 | if hasattr(form, 'packages'): 70 | diff = [pkg for pkg in diff if pkg not in form.packages] 71 | for pkgname in diff: 72 | self.fail(pkgname) 73 | 74 | 75 | class SamePackageBase(object): 76 | def __init__(self): 77 | self.message = u'Mismatching pkgbases ({}).' 78 | 79 | def fail(self, pkgname): 80 | raise ValidationError(self.message.format(pkgname)) 81 | 82 | def __call__(self, form, field): 83 | pkgnames = set(multiline_to_list(field.data)) 84 | pkgbases = db.session.query(Package) \ 85 | .filter(Package.name.in_(pkgnames)) \ 86 | .group_by(Package.base).all() 87 | pkgbases = [pkg.base for pkg in pkgbases] 88 | if len(pkgbases) > 1: 89 | self.fail(', '.join(pkgbases)) 90 | 91 | 92 | class ValidIssue(object): 93 | def __init__(self): 94 | self.message = ERROR_ISSUE_ID_INVALID 95 | 96 | def __call__(self, form, field): 97 | if not match(cve_id_regex, field.data): 98 | raise ValidationError(self.message) 99 | 100 | 101 | class ValidIssues(object): 102 | def __init__(self): 103 | self.message = u'Invalid issue {}.' 104 | 105 | def fail(self, issue): 106 | raise ValidationError(self.message.format(issue)) 107 | 108 | def __call__(self, form, field): 109 | issues = multiline_to_list(field.data) 110 | for issue in issues: 111 | if not match(cve_id_regex, issue): 112 | self.fail(issue) 113 | 114 | 115 | class ValidURLs(object): 116 | def __init__(self): 117 | self.message = ERROR_INVALID_URL 118 | self.regex = URLValidator().regex 119 | 120 | def fail(self, url): 121 | raise ValidationError(self.message.format(url)) 122 | 123 | def __call__(self, form, field): 124 | urls = multiline_to_list(field.data) 125 | for url in urls: 126 | if not self.regex.match(url): 127 | self.fail(url) 128 | -------------------------------------------------------------------------------- /tracker/maintenance.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import datetime 3 | 4 | from sqlalchemy import func 5 | 6 | from tracker import db 7 | from tracker.model import CVE 8 | from tracker.model import CVEGroup 9 | from tracker.model import CVEGroupEntry 10 | from tracker.model import CVEGroupPackage 11 | from tracker.model import Package 12 | from tracker.model.enum import Status 13 | from tracker.model.enum import affected_to_status 14 | from tracker.model.enum import highest_severity 15 | from tracker.model.enum import status_to_affected 16 | from tracker.pacman import search 17 | 18 | 19 | def update_group_status(): 20 | updated = [] 21 | groups = (db.session.query(CVEGroup, func.group_concat(CVEGroupPackage.pkgname, ' ')) 22 | .join(CVEGroupPackage) 23 | .filter(CVEGroup.status.in_([Status.vulnerable, Status.testing])) 24 | .group_by(CVEGroupPackage.group_id)).all() 25 | for group, pkgnames in groups: 26 | pkgnames = pkgnames.split(' ') 27 | new_status = affected_to_status(status_to_affected(group.status), pkgnames[0], group.fixed) 28 | if group.status is not new_status: 29 | updated.append(dict(group=group, old_status=group.status)) 30 | group.status = new_status 31 | db.session.commit() 32 | return updated 33 | 34 | 35 | def recalc_group_status(): 36 | updated = [] 37 | groups = (db.session.query(CVEGroup, func.group_concat(CVEGroupPackage.pkgname, ' ')) 38 | .join(CVEGroupPackage) 39 | .group_by(CVEGroupPackage.group_id)).all() 40 | for group, pkgnames in groups: 41 | pkgnames = pkgnames.split(' ') 42 | new_status = affected_to_status(status_to_affected(group.status), pkgnames[0], group.fixed) 43 | if group.status is not new_status: 44 | updated.append(dict(group=group, old_status=group.status)) 45 | group.status = new_status 46 | db.session.commit() 47 | return updated 48 | 49 | 50 | def recalc_group_severity(): 51 | updated = [] 52 | entries = (db.session.query(CVEGroup, CVEGroupEntry, CVE) 53 | .join(CVEGroupEntry).join(CVE) 54 | .group_by(CVEGroupEntry.group_id).group_by(CVE.id)).all() 55 | issues = defaultdict(set) 56 | for group, entry, issue in entries: 57 | issues[group].add(issue) 58 | for group, issues in issues.items(): 59 | new_severity = highest_severity([issue.severity for issue in issues]) 60 | if group.severity is not new_severity: 61 | updated.append(dict(group=group, old_severity=group.severity)) 62 | group.severity = new_severity 63 | db.session.commit() 64 | return updated 65 | 66 | 67 | def update_package_cache(): 68 | print(' -> Querying alpm database...', end='', flush=True) 69 | packages = search('', filter_duplicate_packages=False, sort_results=False) 70 | print('done') 71 | 72 | if packages: 73 | latest = max(packages, key=lambda pkg: pkg.builddate) 74 | print(' -> Latest package: {} {} {}'.format( 75 | latest.name, latest.version, datetime.fromtimestamp(latest.builddate).strftime('%c'))) 76 | 77 | print(' -> Updating database cache...', end='', flush=True) 78 | new_packages = [] 79 | for package in packages: 80 | new_packages.append({ 81 | 'name': package.name, 82 | 'base': package.base if package.base else package.name, 83 | 'version': package.version, 84 | 'description': package.desc, 85 | 'url': package.url, 86 | 'arch': package.arch, 87 | 'database': package.db.name, 88 | 'filename': package.filename, 89 | 'sha256sum': package.sha256sum, 90 | 'builddate': package.builddate 91 | }) 92 | Package.query.delete() 93 | db.session.bulk_insert_mappings(Package, new_packages) 94 | db.session.commit() 95 | print('done') 96 | -------------------------------------------------------------------------------- /tracker/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .advisory import Advisory 2 | from .cve import CVE 3 | from .cvegroup import CVEGroup 4 | from .cvegroupentry import CVEGroupEntry 5 | from .cvegrouppackage import CVEGroupPackage 6 | from .package import Package 7 | from .user import User 8 | -------------------------------------------------------------------------------- /tracker/model/advisory.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tracker import db 4 | from tracker.model.cve import issue_types 5 | from tracker.model.enum import Publication 6 | 7 | advisory_regex = r'^(ASA\-(\d{6})\-(\d+))$' 8 | advisory_types = list(filter(lambda e: e != 'unknown', issue_types)) 9 | advisory_types.insert(0, 'multiple issues') 10 | 11 | 12 | class Advisory(db.Model): 13 | WORKAROUND_LENGTH = 4096 14 | IMPACT_LENGTH = 4096 15 | CONTENT_LENGTH = 65536 16 | REFERENCE_LENGTH = 120 17 | 18 | __versioned__ = {} 19 | __tablename__ = 'advisory' 20 | 21 | id = db.Column(db.String(15), index=True, unique=True, primary_key=True) 22 | group_package_id = db.Column(db.Integer(), db.ForeignKey('cve_group_package.id'), nullable=False, unique=True, index=True) 23 | advisory_type = db.Column(db.String(64), default='multiple issues', nullable=False) 24 | publication = db.Column(Publication.as_type(), nullable=False, default=Publication.scheduled) 25 | workaround = db.Column(db.String(WORKAROUND_LENGTH), nullable=True) 26 | impact = db.Column(db.String(IMPACT_LENGTH), nullable=True) 27 | content = db.Column(db.String(CONTENT_LENGTH), nullable=True) 28 | created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) 29 | changed = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) 30 | reference = db.Column(db.String(REFERENCE_LENGTH), nullable=True) 31 | 32 | group_package = db.relationship("CVEGroupPackage") 33 | 34 | def __repr__(self): 35 | return ''.format(self.id) 36 | -------------------------------------------------------------------------------- /tracker/model/cve.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tracker import db 4 | from tracker.util import issue_to_numeric 5 | 6 | from .enum import Remote 7 | from .enum import Severity 8 | 9 | cve_id_regex = r'^(CVE\-\d{4}\-\d{4,})$' 10 | issue_types = [ 11 | 'unknown', 12 | 'access restriction bypass', 13 | 'arbitrary code execution', 14 | 'arbitrary command execution', 15 | 'arbitrary file overwrite', 16 | 'arbitrary filesystem access', 17 | 'arbitrary file upload', 18 | 'authentication bypass', 19 | 'certificate verification bypass', 20 | 'content spoofing', 21 | 'cross-site request forgery', 22 | 'cross-site scripting', 23 | 'denial of service', 24 | 'directory traversal', 25 | 'incorrect calculation', 26 | 'information disclosure', 27 | 'insufficient validation', 28 | 'man-in-the-middle', 29 | 'open redirect', 30 | 'private key recovery', 31 | 'privilege escalation', 32 | 'proxy injection', 33 | 'same-origin policy bypass', 34 | 'sandbox escape', 35 | 'session hijacking', 36 | 'signature forgery', 37 | 'silent downgrade', 38 | 'sql injection', 39 | 'time alteration', 40 | 'url request injection', 41 | 'xml external entity injection' 42 | ] 43 | 44 | 45 | class CVE(db.Model): 46 | 47 | DESCRIPTION_LENGTH = 4096 48 | REFERENCES_LENGTH = 4096 49 | NOTES_LENGTH = 4096 50 | 51 | __versioned__ = {} 52 | __tablename__ = 'cve' 53 | 54 | id = db.Column(db.String(15), index=True, unique=True, primary_key=True) 55 | issue_type = db.Column(db.String(64), default='unknown') 56 | description = db.Column(db.String(DESCRIPTION_LENGTH)) 57 | severity = db.Column(Severity.as_type(), nullable=False, default=Severity.unknown) 58 | remote = db.Column(Remote.as_type(), nullable=False, default=Remote.unknown) 59 | reference = db.Column(db.String(REFERENCES_LENGTH)) 60 | notes = db.Column(db.String(NOTES_LENGTH)) 61 | created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) 62 | changed = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) 63 | 64 | @staticmethod 65 | def new(id): 66 | return CVE(id=id, 67 | issue_type='unknown', 68 | description='', 69 | severity=Severity.unknown, 70 | remote=Remote.unknown, 71 | reference='', 72 | notes='') 73 | 74 | def __repr__(self): 75 | return '{}'.format(self.id) 76 | 77 | @property 78 | def numerical_repr(self): 79 | return issue_to_numeric(self.id) 80 | 81 | def __gt__(self, other): 82 | return self.numerical_repr > other.numerical_repr 83 | 84 | def __lt__(self, other): 85 | return self.numerical_repr < other.numerical_repr 86 | -------------------------------------------------------------------------------- /tracker/model/cvegroup.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tracker import db 4 | 5 | from .enum import Severity 6 | from .enum import Status 7 | 8 | pkgname_regex = r'^([a-z\d@\.\_\+-]+)$' 9 | pkgver_regex = r'^(\d+:)?([\w]+[\._+]*)+\-\d+(\.\d+)?$' 10 | vulnerability_group_regex = r'^AVG-\d+$' 11 | 12 | 13 | class CVEGroup(db.Model): 14 | 15 | REFERENCES_LENGTH = 4096 16 | NOTES_LENGTH = 4096 17 | 18 | __versioned__ = {} 19 | __tablename__ = 'cve_group' 20 | 21 | id = db.Column(db.Integer(), index=True, unique=True, primary_key=True, autoincrement=True) 22 | status = db.Column(Status.as_type(), nullable=False, default=Status.unknown, index=True) 23 | severity = db.Column(Severity.as_type(), nullable=False, default=Severity.unknown) 24 | affected = db.Column(db.String(32), nullable=False) 25 | fixed = db.Column(db.String(32)) 26 | bug_ticket = db.Column(db.String(9)) 27 | reference = db.Column(db.String(REFERENCES_LENGTH)) 28 | notes = db.Column(db.String(NOTES_LENGTH)) 29 | created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) 30 | changed = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) 31 | advisory_qualified = db.Column(db.Boolean(), default=True, nullable=False) 32 | 33 | issues = db.relationship("CVEGroupEntry", back_populates="group", cascade="all,delete-orphan") 34 | packages = db.relationship("CVEGroupPackage", back_populates="group", cascade="all,delete-orphan") 35 | 36 | @property 37 | def name(self): 38 | return 'AVG-{}'.format(self.id) 39 | 40 | def __str__(self): 41 | return self.name 42 | 43 | def __repr__(self): 44 | return '' % (self.id) 45 | -------------------------------------------------------------------------------- /tracker/model/cvegroupentry.py: -------------------------------------------------------------------------------- 1 | from tracker import db 2 | 3 | 4 | class CVEGroupEntry(db.Model): 5 | 6 | __tablename__ = 'cve_group_entry' 7 | __versioned__ = {} 8 | 9 | id = db.Column(db.Integer(), index=True, unique=True, primary_key=True, autoincrement=True) 10 | group_id = db.Column(db.Integer(), db.ForeignKey('cve_group.id', ondelete="CASCADE"), nullable=False) 11 | cve_id = db.Column(db.String(15), db.ForeignKey('cve.id', ondelete="CASCADE"), nullable=False) 12 | 13 | group = db.relationship("CVEGroup", back_populates="issues") 14 | cve = db.relationship("CVE") 15 | 16 | __table_args__ = (db.Index('cve_group_entry__group_cve_idx', group_id, cve_id, unique=True),) 17 | 18 | def __repr__(self): 19 | return '' % (self.id, self.group_id, self.cve_id) 20 | -------------------------------------------------------------------------------- /tracker/model/cvegrouppackage.py: -------------------------------------------------------------------------------- 1 | from tracker import db 2 | 3 | 4 | class CVEGroupPackage(db.Model): 5 | 6 | __versioned__ = {} 7 | __tablename__ = 'cve_group_package' 8 | 9 | id = db.Column(db.Integer(), index=True, unique=True, primary_key=True, autoincrement=True) 10 | group_id = db.Column(db.Integer(), db.ForeignKey('cve_group.id', ondelete="CASCADE"), nullable=False) 11 | pkgname = db.Column(db.String(64), nullable=False) 12 | 13 | group = db.relationship("CVEGroup", back_populates="packages") 14 | 15 | __table_args__ = (db.Index('cve_group_package__group_pkgname_idx', group_id, pkgname, unique=True),) 16 | 17 | def __repr__(self): 18 | return '' % (self.id, self.group_id, self.pkgname) 19 | -------------------------------------------------------------------------------- /tracker/model/enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pyalpm import vercmp 4 | from sqlalchemy.types import Enum as SQLAlchemyEnum 5 | from sqlalchemy.types import SchemaType 6 | from sqlalchemy.types import TypeDecorator 7 | 8 | from tracker import db 9 | 10 | from .package import Package 11 | from .package import sort_packages 12 | 13 | 14 | class EnumType(SchemaType, TypeDecorator): 15 | def __init__(self, enum, name): 16 | self.enum = enum 17 | self.name = name 18 | members = (member._value_ for member in enum) 19 | kwargs = {'name': name} 20 | self.impl = SQLAlchemyEnum(*members, **kwargs) 21 | 22 | def _set_table(self, table, column): 23 | self.impl._set_table(table, column) 24 | 25 | def copy(self): 26 | return EnumType(self.enum, self.name) 27 | 28 | def process_bind_param(self, enum_instance, dialect): 29 | if enum_instance is None: 30 | return None 31 | return enum_instance._value_ 32 | 33 | def process_result_value(self, value, dialect): 34 | if value is None: 35 | return None 36 | return self.enum.fromstring(value) 37 | 38 | 39 | class DatabaseEnum(Enum): 40 | def __init__(self, db_repr, label=None): 41 | self._value_ = db_repr 42 | self.label = label if label else self.name 43 | 44 | def __lt__(self, other): 45 | return self._value_ < other._value_ 46 | 47 | def __str__(self): 48 | return self.label 49 | 50 | def __eq__(self, other): 51 | if not other: 52 | return False 53 | if type(other) is str: 54 | return self.label == other 55 | return self.name == other.name 56 | 57 | @classmethod 58 | def as_type(cls, name=None): 59 | if not name: 60 | name = "check_{}".format(cls.__name__.lower()) 61 | return EnumType(cls, name) 62 | 63 | @classmethod 64 | def get_description_mapping(cls): 65 | return dict((member.name, member.label) for member in cls) 66 | 67 | @classmethod 68 | def fromstring(cls, name): 69 | if name is None: 70 | return None 71 | return getattr(cls, name, None) 72 | 73 | 74 | class OrderedDatabaseEnum(DatabaseEnum): 75 | def __init__(self, label, order): 76 | super().__init__(db_repr=self.name, label=label) 77 | self.order = order 78 | 79 | def __lt__(self, other): 80 | return self.order < other.order 81 | 82 | 83 | class Status(OrderedDatabaseEnum): 84 | unknown = 'Unknown', 1 85 | vulnerable = 'Vulnerable', 2 86 | testing = 'Testing', 3 87 | fixed = 'Fixed', 4 88 | not_affected = 'Not affected', 4 89 | 90 | def open(self): 91 | return self in [Status.unknown, Status.vulnerable, Status.testing] 92 | 93 | def resolved(self): 94 | return not self.open() 95 | 96 | 97 | class Severity(OrderedDatabaseEnum): 98 | unknown = 'Unknown', 1 99 | critical = 'Critical', 2 100 | high = 'High', 3 101 | medium = 'Medium', 4 102 | low = 'Low', 5 103 | 104 | 105 | class Remote(OrderedDatabaseEnum): 106 | unknown = 'Unknown', 3 107 | remote = 'Remote', 1 108 | local = 'Local', 2 109 | 110 | 111 | class Affected(OrderedDatabaseEnum): 112 | unknown = 'Unknown', 2 113 | affected = 'Affected', 1 114 | not_affected = 'Not Affected', 3 115 | 116 | 117 | class Publication(OrderedDatabaseEnum): 118 | scheduled = 'Scheduled', 1 119 | published = 'Published', 2 120 | 121 | 122 | class UserRole(OrderedDatabaseEnum): 123 | administrator = 'Administrator', 1 124 | security_team = 'Security Team', 2 125 | reporter = 'Reporter', 3 126 | guest = 'Guest', 4 127 | 128 | @property 129 | def is_guest(self): 130 | return self == UserRole.guest 131 | 132 | @property 133 | def is_reporter(self): 134 | return self in [UserRole.reporter, UserRole.security_team, UserRole.administrator] 135 | 136 | @property 137 | def is_security_team(self): 138 | return self in [UserRole.security_team, UserRole.administrator] 139 | 140 | @property 141 | def is_administrator(self): 142 | return self in [UserRole.administrator] 143 | 144 | 145 | def status_to_affected(status): 146 | if Status.unknown == status: 147 | return Affected.unknown 148 | if Status.not_affected == status: 149 | return Affected.not_affected 150 | return Affected.affected 151 | 152 | 153 | def affected_to_status(affected, pkgname, fixed_version): 154 | # early exit if unknown or not affected 155 | if Affected.not_affected == affected: 156 | return Status.not_affected 157 | if Affected.unknown == affected: 158 | return Status.unknown 159 | versions = db.session.query(Package).filter_by(name=pkgname) \ 160 | .group_by(Package.name, Package.version).all() 161 | versions = sort_packages(versions) 162 | # unknown if no version was found 163 | if not versions: 164 | return Status.unknown 165 | version = versions[0] 166 | # vulnerable if the latest version is still affected 167 | if not fixed_version or 0 > vercmp(version.version, fixed_version): 168 | return Status.vulnerable 169 | # at least one version is fixed 170 | fixed_versions = [p for p in versions if vercmp(p.version, fixed_version) >= 0] 171 | # if the only fixed versions are in [testing], return testing 172 | if all('testing' in p.database for p in fixed_versions): 173 | return Status.testing 174 | # otherwise a fixed version exists outside [testing] 175 | return Status.fixed 176 | 177 | def highest_severity(cves): 178 | severity = list(filter(lambda severity: Severity.unknown != severity, cves)) 179 | return min(severity) if severity else Severity.unknown 180 | -------------------------------------------------------------------------------- /tracker/model/package.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | 3 | from pyalpm import vercmp 4 | 5 | from tracker import db 6 | from tracker.util import cmp_to_key 7 | 8 | 9 | class Package(db.Model): 10 | __tablename__ = 'package' 11 | 12 | id = db.Column(db.Integer(), index=True, unique=True, primary_key=True, autoincrement=True) 13 | name = db.Column(db.String(96), index=True, nullable=False) 14 | base = db.Column(db.String(96), index=True, nullable=False) 15 | version = db.Column(db.String(64), nullable=False) 16 | arch = db.Column(db.String(16), index=True, nullable=False) 17 | database = db.Column(db.String(32), index=True, nullable=False) 18 | description = db.Column(db.String(256), nullable=False) 19 | url = db.Column(db.String(192)) 20 | filename = db.Column(db.String(128), nullable=False) 21 | sha256sum = db.Column(db.String(64), nullable=False) 22 | builddate = db.Column(db.Integer(), nullable=False) 23 | 24 | def __repr__(self): 25 | return ''.format(self.name, self.version) 26 | 27 | 28 | def filter_duplicate_packages(packages, filter_arch=False): 29 | filtered = [] 30 | for pkg in packages: 31 | contains = False 32 | for f in filtered: 33 | if f.version != pkg.version or f.database != pkg.database: 34 | continue 35 | if not filter_arch and f.arch != pkg.arch: 36 | continue 37 | contains = True 38 | break 39 | if not contains: 40 | filtered.append(pkg) 41 | return filtered 42 | 43 | 44 | def sort_packages(packages): 45 | packages = sorted(packages, key=lambda item: item.arch, reverse=True) 46 | packages = sorted(packages, key=lambda item: item.database) 47 | packages = sorted(packages, key=cmp_to_key(vercmp, attrgetter('version')), reverse=True) 48 | return packages 49 | -------------------------------------------------------------------------------- /tracker/model/user.py: -------------------------------------------------------------------------------- 1 | from tracker import db 2 | 3 | from .enum import UserRole 4 | 5 | username_regex = r'^([\w]+)$' 6 | 7 | 8 | class User(db.Model): 9 | 10 | NAME_LENGTH = 32 11 | EMAIL_LENGTH = 128 12 | SALT_LENGTH = 20 13 | PASSWORD_LENGTH = 80 14 | TOKEN_LENGTH = 120 15 | IDP_ID_LENGTH = 255 16 | 17 | __tablename__ = 'user' 18 | id = db.Column(db.Integer(), index=True, unique=True, primary_key=True, autoincrement=True) 19 | name = db.Column(db.String(NAME_LENGTH), index=True, unique=True, nullable=False) 20 | email = db.Column(db.String(EMAIL_LENGTH), index=True, unique=True, nullable=False) 21 | salt = db.Column(db.String(SALT_LENGTH), nullable=False) 22 | password = db.Column(db.String(SALT_LENGTH), nullable=False) 23 | token = db.Column(db.String(TOKEN_LENGTH), index=True, unique=True, nullable=True) 24 | role = db.Column(UserRole.as_type(), nullable=False, default=UserRole.reporter) 25 | active = db.Column(db.Boolean(), nullable=False, default=True) 26 | idp_id = db.Column(db.String(IDP_ID_LENGTH), nullable=True, default=None, index=True, unique=True) 27 | 28 | is_authenticated = False 29 | is_anonymous = False 30 | 31 | @property 32 | def is_active(self): 33 | return self.active 34 | 35 | def get_id(self): 36 | return '{}'.format(self.token) 37 | 38 | def __str__(self): 39 | return self.name 40 | 41 | def __repr__(self): 42 | return '' % (self.name) 43 | 44 | 45 | class Guest(User): 46 | def __init__(self): 47 | super().__init__() 48 | self.name = 'Guest' 49 | self.id = -42 50 | self.active = False 51 | self.is_anonymous = True 52 | self.is_authenticated = False 53 | self.role = UserRole.guest 54 | 55 | def get_id(self): 56 | return None 57 | -------------------------------------------------------------------------------- /tracker/pacman.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | from os import chdir 3 | from time import time 4 | 5 | from pyalpm import vercmp 6 | from pycman.config import init_with_config 7 | 8 | from config import PACMAN_HANDLE_CACHE_TIME 9 | from config import basedir 10 | from tracker.util import cmp_to_key 11 | 12 | archs = ['x86_64'] 13 | primary_arch = 'x86_64' 14 | repos = {'x86_64': ['core', 'core-testing', 'extra', 'extra-testing', 'multilib', 'multilib-testing']} 15 | configpath = './pacman/arch/{}/pacman.conf' 16 | handles = {} 17 | chdir(basedir) 18 | 19 | 20 | def get_configpath(arch): 21 | return configpath.format(arch) 22 | 23 | 24 | def get_handle(arch, force_fresh_handle=False): 25 | if not force_fresh_handle and arch in handles: 26 | handle, creation_time = handles[arch] 27 | if creation_time > time() - PACMAN_HANDLE_CACHE_TIME: 28 | return handle 29 | handle = init_with_config(get_configpath(arch)) 30 | handles[arch] = (handle, time()) 31 | return handle 32 | 33 | 34 | def update(arch=None, force=False): 35 | update_archs = [arch] if arch else archs 36 | for arch in update_archs: 37 | for syncdb in get_handle(arch).get_syncdbs(): 38 | syncdb.update(force) 39 | 40 | 41 | def get_pkg(pkgname, arch=None, testing=True, filter_arch=False, force_fresh_handle=False, sort_results=True, filter_duplicate_packages=True): 42 | get_archs = [arch] if arch else archs 43 | results = set() 44 | for arch in get_archs: 45 | for syncdb in get_handle(arch, force_fresh_handle=force_fresh_handle).get_syncdbs(): 46 | if not testing and 'testing' in syncdb.name: 47 | continue 48 | result = syncdb.get_pkg(pkgname) 49 | if result: 50 | results.add(result) 51 | if sort_results: 52 | results = sort_packages(results) 53 | if filter_duplicate_packages: 54 | results = filter_duplicates(results, filter_arch) 55 | return results 56 | 57 | 58 | def search(pkgname, arch=None, testing=True, filter_arch=False, force_fresh_handle=False, sort_results=True, filter_duplicate_packages=True): 59 | search_archs = [arch] if arch else archs 60 | results = [] 61 | for arch in search_archs: 62 | for syncdb in get_handle(arch, force_fresh_handle=force_fresh_handle).get_syncdbs(): 63 | if not testing and 'testing' in syncdb.name: 64 | continue 65 | result = syncdb.search(pkgname) 66 | if result: 67 | results.extend(result) 68 | if sort_results: 69 | results = sort_packages(results) 70 | if filter_duplicate_packages: 71 | results = filter_duplicates(results, filter_arch) 72 | return results 73 | 74 | 75 | def filter_duplicates(packages, filter_arch=False): 76 | filtered = [] 77 | for pkg in packages: 78 | contains = False 79 | for f in filtered: 80 | if f.version != pkg.version or f.db.name != pkg.db.name: 81 | continue 82 | if not filter_arch and f.arch != pkg.arch: 83 | continue 84 | contains = True 85 | break 86 | if not contains: 87 | filtered.append(pkg) 88 | return filtered 89 | 90 | 91 | def sort_packages(packages): 92 | packages = sorted(packages, key=lambda item: item.arch) 93 | packages = sorted(packages, key=lambda item: item.db.name) 94 | packages = sorted(packages, key=cmp_to_key(vercmp, attrgetter('version')), reverse=True) 95 | return packages 96 | -------------------------------------------------------------------------------- /tracker/static/archlogo.8a05bc7f6cd1.svg: -------------------------------------------------------------------------------- 1 | ../../.external/archlinux-common-style/img/archlogo.8a05bc7f6cd1.svg -------------------------------------------------------------------------------- /tracker/static/favicon.ico: -------------------------------------------------------------------------------- 1 | ../../.external/archlinux-common-style/img/favicon.ico -------------------------------------------------------------------------------- /tracker/static/feed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tracker/static/normalize.css: -------------------------------------------------------------------------------- 1 | ../../.external/normalize.css/normalize.css -------------------------------------------------------------------------------- /tracker/static/opensans.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/archlinux/arch-security-tracker/a18d5c9d8291bf4eeb21b450eca77c62c8c006f9/tracker/static/opensans.woff -------------------------------------------------------------------------------- /tracker/static/opensans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/archlinux/arch-security-tracker/a18d5c9d8291bf4eeb21b450eca77c62c8c006f9/tracker/static/opensans.woff2 -------------------------------------------------------------------------------- /tracker/symbol.py: -------------------------------------------------------------------------------- 1 | smileys_happy = [u'😀', u'😁', u'😂', u'😃', u'😄', u'😅', u'😆', u'😇', u'😈', u'😉', 2 | u'😊', u'😋', u'😌', u'😍', u'😎', u'😏'] 3 | 4 | smileys_sad = [u'😐', u'😑', u'😒', u'😓', u'😔', u'😕', u'😖', u'😝', u'😞', u'😟', 5 | u'😠', u'😡', u'😢', u'😣', u'😥', u'😦', u'😧', u'😨', u'😩', u'😪', 6 | u'😫', u'😭', u'😮', u'😯', u'😰', u'😱', u'😲', u'😵', u'😶', u'😾', 7 | u'😿', u'🙀'] 8 | 9 | check_mark = u'✓' 10 | check_mark_heavy = u'✔' 11 | 12 | ballot = u'✗' 13 | ballot_heavy = u'✘' 14 | -------------------------------------------------------------------------------- /tracker/templates/_formhelpers.html: -------------------------------------------------------------------------------- 1 | {%- macro render_field(field, show_label=True, indent=1) -%} 2 | {%- set indention = '\t' * indent -%} 3 | {%- if show_label %}{{ field.label }}{% endif %} 4 | {{ indention }}{{ field(**kwargs) }} 5 | {%- if field.errors %} 6 | {{ indention }}
7 | {{ indention }}
    8 | {%- for error in field.errors %} 9 | {{ indention }}
  • {{ error }}
  • 10 | {%- endfor %} 11 | {{ indention }}
12 | {{ indention }}
13 | {%- endif %} 14 | {%- endmacro -%} 15 | 16 | {%- macro render_field_unlabeled(field, indent=1) -%} 17 | {{- render_field(field, show_label=False, indent=indent, **kwargs)|safe }} 18 | {%- endmacro -%} 19 | 20 | {%- macro render_checkbox(field, indent=1) -%} 21 | {%- set indention = '\t' * indent -%} 22 | 26 | {%- endmacro -%} 27 | 28 | {%- macro nullable_value(value, default='') -%} 29 | {%- if value -%}{{ value }}{%- else -%}{{ default }}{%- endif -%} 30 | {%- endmacro -%} 31 | 32 | {%- macro colorize_severity(severity, label="") -%} 33 | {%- if not severity or severity == "Unknown" -%} 34 | {% if label %}{{ label }}{% else %}Unknown{% endif %} 35 | {%- elif severity == "Low" -%} 36 | {% if label %}{{ label }}{% else %}Low{% endif %} 37 | {%- elif severity == "Medium" -%} 38 | {% if label %}{{ label }}{% else %}Medium{% endif %} 39 | {%- elif severity == "High" -%} 40 | {% if label %}{{ label }}{% else %}High{% endif %} 41 | {%- elif severity == "Critical" -%} 42 | {% if label %}{{ label }}{% else %}Critical{% endif %} 43 | {%- else -%} 44 | {{ severity }} 45 | {%- endif -%} 46 | {%- endmacro -%} 47 | 48 | {%- macro colorize_status(status, label="") -%} 49 | {%- if not status or status == "Unknown" -%} 50 | {% if label %}{{ label }}{% else %}Unknown{% endif %} 51 | {%- elif status == "Testing" -%} 52 | {% if label %}{{ label }}{% else %}Testing{% endif %} 53 | {%- elif status == "Vulnerable" -%} 54 | {% if label %}{{ label }}{% else %}Vulnerable{% endif %} 55 | {%- elif status == "Fixed" -%} 56 | {% if label %}{{ label }}{% else %}Fixed{% endif %} 57 | {%- elif status == "Not affected" -%} 58 | {% if label %}{{ label }}{% else %}Not affected{% endif %} 59 | {%- else -%} 60 | {{ status }} 61 | {%- endif -%} 62 | {%- endmacro -%} 63 | 64 | {% macro colorize_remote(value) %} 65 | {%- if value == None or value == "Unknown" -%} 66 | Unknown 67 | {%- elif value == "Remote" -%} 68 | Yes 69 | {%- else -%} 70 | No 71 | {%- endif -%} 72 | {%- endmacro -%} 73 | 74 | {%- macro colorize_unknown(value) -%} 75 | {%- if value == None or value == "Unknown" -%} 76 | Unknown 77 | {%- else -%} 78 | {{ value }} 79 | {%- endif -%} 80 | {%- endmacro -%} 81 | 82 | {%- macro colorize_boolean(value) -%} 83 | {%- if value == None or value == "Unknown" -%} 84 | Unknown 85 | {%- elif value -%} 86 | Yes 87 | {%- else -%} 88 | No 89 | {%- endif -%} 90 | {%- endmacro -%} 91 | 92 | {%- macro boolean_value(value, default='Unknown') -%} 93 | {%- if value is none -%} 94 | {{ default }} 95 | {%- elif value -%} 96 | Yes 97 | {%- else -%} 98 | No 99 | {%- endif -%} 100 | {%- endmacro -%} 101 | 102 | {%- macro bug_ticket(id) -%} 103 | {%- if id -%} 104 | FS#{{ id }} 105 | {%- endif -%} 106 | {%- endmacro -%} 107 | 108 | {%- macro colorize_diff(previous, current) %} 109 | 110 | {%- for line in previous|diff(current) %} 111 | {%- if not line.startswith('?') %} 112 | 113 | 114 | 115 | 116 | {%- endif %} 117 | {%- endfor %} 118 |
{{ line[:1] }}{{ line[2:] }}
119 | {%- endmacro -%} 120 | 121 | {%- macro render_pagination(pagination) -%} 122 | {%- if pagination.has_prev or pagination.has_next -%} 123 | 146 | {%- endif -%} 147 | {%- endmacro -%} 148 | 149 | {%- macro transaction_operation_label(operation) -%} 150 | {%- if 0 == operation -%} 151 | created 152 | {%- elif 1 == operation -%} 153 | edited 154 | {%- elif 2 == operation -%} 155 | deleted 156 | {%- else -%} 157 | unknown 158 | {%- endif -%} 159 | {%- endmacro -%} 160 | 161 | {%- macro label_from_model(model) -%} 162 | {%- if model.__class__.__name__ in ['CVEGroupVersion', 'CVEGroup'] %}AVG-{% endif %}{{ model.id }} 163 | {%- endmacro -%} 164 | 165 | {%- macro link_to_model(model) -%} 166 | {%- set label = label_from_model(model) -%} 167 | {%- if not model.operation_type == 2 -%} 168 | {{ label }} 169 | {%- else -%} 170 | {{ label }} 171 | {%- endif -%} 172 | {%- endmacro -%} 173 | 174 | {%- macro link_to_user_log(username) -%} 175 | {%- if username -%} 176 | {{ username }} 177 | {%- else -%} 178 | system 179 | {%- endif -%} 180 | {%- endmacro -%} 181 | 182 | {%- macro log_transaction_header(model, show_user) -%} 183 | {%- set transaction = model.transaction -%} 184 | {{ link_to_model(model) }} {{ transaction_operation_label(model.operation_type) }}{% if show_user %} by {{ link_to_user_log(transaction.user.name) }}{% endif %} at {{ transaction.issued_at.strftime('%d %b %Y %H:%M:%S') }} 185 | {%- endmacro -%} 186 | -------------------------------------------------------------------------------- /tracker/templates/admin/form/delete_user.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{% if heading %}{{ heading }}{% else %}Confirm{% endif %}

4 |
5 |
6 | {{ form.hidden_tag() }} 7 |
8 |

Details

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
E-Mail{{ user.email }}
Role{{ user.role.label }}
Active
25 |
26 |
27 | Are you sure you want to delete {{ user.name }}?
28 |
29 | {{ form.confirm(class='button-primary') }} 30 | {{ form.abort(class='button-primary', accesskey='c') }} 31 |
32 |
33 |
34 | {%- endblock %} 35 | -------------------------------------------------------------------------------- /tracker/templates/admin/form/user.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ title }}

4 |
5 |
6 | {{ form.hidden_tag() }} 7 |
8 |
9 | {{ render_field(form.username, class='full size', maxlength=User.NAME_LENGTH, placeholder='Username', autofocus='', required='', indent=7) }} 10 |
11 |
12 | {{ render_field(form.email, class='full size', maxlength=User.EMAIL_LENGTH, placeholder='E-Mail', required='', indent=7) }} 13 |
14 |
15 |
16 |
17 | {{ render_field(form.password, class='full size', placeholder='Password (optional)', maxlength=password_length.max, indent=7) }} 18 |
19 |
20 | {{ render_field(form.role, class='full size', required='', indent=7) }} 21 |
22 |
23 |
24 | {%- if random_password %} 25 |
26 | {{ render_checkbox(form.random_password, indent=7) }} 27 |
28 |
29 | {% else %} 30 |
31 | {% endif %} 32 | {{ render_checkbox(form.active, indent=7) }} 33 |
34 |
35 | {{ form.submit(class='button-primary', accesskey='s') }} 36 | 37 |
38 | {%- endblock %} 39 | -------------------------------------------------------------------------------- /tracker/templates/admin/user.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

User{% if current_user.role.is_administrator and not SSO_ENABLED %} create{% endif %}

4 | 5 | 6 | 7 | 8 | 9 | 10 | {%- if current_user.role.is_administrator %} 11 | 12 | {%- if not SSO_ENABLED %} 13 | 14 | {%- endif %} 15 | {%- endif %} 16 | 17 | 18 | 19 | {%- for user in users %} 20 | 21 | 22 | 23 | 24 | {%- if current_user.role.is_administrator %} 25 | 26 | 32 | {%- endif %} 33 | 34 | {%- endfor %} 35 | 36 |
UserE-MailRoleActiveAction
{{ user.name }}{{ user.email }}{{ user.role }} 27 | {%- if not SSO_ENABLED %} 28 | edit 29 | delete 30 | {%- endif %} 31 |
37 | {%- endblock %} 38 | -------------------------------------------------------------------------------- /tracker/templates/advisories.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import nullable_value, colorize_severity, colorize_status, bug_ticket -%} 3 | {% block content %} 4 |

Advisories 5 | rss feed 6 |

7 | {%- if scheduled %} 8 |

Scheduled

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {%- for entry in scheduled %} 22 | {%- set advisory = entry.advisory %} 23 | {%- set group = entry.group %} 24 | {%- set package = entry.package %} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {%- endfor %} 35 |
DateAdvisoryGroupPackageSeverityType
{{ advisory.created.strftime('%d %b %Y') }}{{ advisory.id }}{{ group.name }}{{ package.pkgname }}{{ colorize_severity(group.severity) }}{{ advisory.advisory_type }}
36 | {%- endif %} 37 |

Published

38 | {%- for month, advisories in published.items() %} 39 |

{{ month }}

40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {%- for entry in advisories %} 53 | {%- set advisory = entry.advisory %} 54 | {%- set group = entry.group %} 55 | {%- set package = entry.package %} 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {%- endfor %} 65 | 66 |
DateAdvisoryGroupPackageSeverityType
{{ advisory.created.strftime('%d %b %Y') }}{{ advisory.id }}{{ group.name }}{{ package.pkgname }}{{ colorize_severity(group.severity) }}{{ advisory.advisory_type }}
67 | {%- endfor %} 68 | {%- endblock %} 69 | -------------------------------------------------------------------------------- /tracker/templates/advisory.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ advisory.id }} 4 | log 5 | {%- if can_handle_advisory %} 6 | edit 7 | {%- if advisory.publication == Publication.scheduled %} 8 | delete 9 | {%- endif %} 10 | {%- endif %} 11 | {%- if not generated %} 12 | generated 13 | {%- elif advisory.content %} 14 | original 15 | {%- endif %} 16 | {%- if advisory.reference %} 17 | external 18 | {%- endif %} 19 | raw 20 |

21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 |
[{{ advisory.id }}] {{package.pkgname}}: {{advisory.advisory_type}}
30 |
{{ raw_asa|safe|urlize }}
31 |
35 | {%- endblock %} 36 | -------------------------------------------------------------------------------- /tracker/templates/advisory.txt: -------------------------------------------------------------------------------- 1 | {%- if with_subject %}Subject: [{{ advisory.id }}] {{ package.pkgname }}: {{ advisory.advisory_type }} 2 | 3 | {% endif -%} 4 | {% set asa_title = 'Arch Linux Security Advisory ' + advisory.id %} 5 | {{- asa_title }} 6 | {% set asa_title_separator = '=' * asa_title|length %} 7 | {{- asa_title_separator }} 8 | 9 | Severity: {{ group.severity }} 10 | Date : {{ advisory.created.strftime('%Y-%m-%d') }} 11 | CVE-ID : {{ issue_listing_formatted }} 12 | Package : {{ package.pkgname }} 13 | Type : {{ advisory.advisory_type }} 14 | Remote : {%if not remote %}No{% else %}Yes{% endif %} 15 | Link : {{ link }} 16 | 17 | Summary 18 | ======= 19 | 20 | {% macro summary() -%} 21 | The package {{ package.pkgname }} before version {{ group.fixed }} is vulnerable to {{ advisory.advisory_type}} 22 | {%- if unique_issue_types|length > 1 %} including{%- raw %} {% endraw %} 23 | {%- for issue_type in unique_issue_types %} 24 | {{- issue_type }} 25 | {%- if not loop.last %} 26 | {%- if loop.revindex > 2 %}, {% else %} and {% endif %} 27 | {%- endif %} 28 | {%- endfor %} 29 | {%- endif %}. 30 | {%- endmacro %} 31 | {{- summary()|wordwrap(71) }} 32 | 33 | Resolution 34 | ========== 35 | 36 | Upgrade to {{ group.fixed }}. 37 | 38 | # pacman -Syu "{{ package.pkgname }}>={{ group.fixed }}" 39 | 40 | The problem{% if issues|length > 1 %}s have{% else %} has{% endif %} been fixed upstream 41 | {%- if upstream_released %} in version {{ upstream_version }} 42 | {%- else %} but no release is available yet 43 | {%- endif %}. 44 | 45 | Workaround 46 | ========== 47 | 48 | {% if workaround %} 49 | {%- set splitted = workaround.split('\n') %} 50 | {%- for line in splitted %} 51 | {{- line|wordwrap(71) }} 52 | {%- if not loop.last %} 53 | {% endif %} 54 | {%- endfor %} 55 | {%- else -%} 56 | None. 57 | {%- endif %} 58 | 59 | Description 60 | =========== 61 | 62 | {% for issue in issues %} 63 | {%- if issues|length > 1 %}- {{issue.id}} ({{ issue.issue_type }}) 64 | 65 | {% endif %} 66 | {%- set splitted = '' if not issue.description else issue.description.split('\n') %} 67 | {%- for line in splitted %} 68 | {{- line|wordwrap(71) }} 69 | {%- if not loop.last %} 70 | {% endif %} 71 | {%- endfor %} 72 | {%- if not loop.last %} 73 | 74 | {% endif %} 75 | {%- endfor %} 76 | 77 | Impact 78 | ====== 79 | 80 | {% if impact %}{{ impact|wordwrap(71) }}{% endif %} 81 | 82 | References 83 | ========== 84 | 85 | {% for reference in references %} 86 | {{- reference }} 87 | {% endfor %} 88 | {%- for issue in issues %}{{ TRACKER_ISSUE_URL.format(issue.id) }} 89 | {% endfor %} 90 | -------------------------------------------------------------------------------- /tracker/templates/base.html: -------------------------------------------------------------------------------- 1 | {%- from "_formhelpers.html" import render_field, render_field_unlabeled, render_checkbox -%} 2 | 3 | 4 | 5 | {%- if title %} 6 | {{ title }} - Arch Linux 7 | {%- else %} 8 | Arch Linux Security Tracker 9 | {%- endif %} 10 | 11 | 12 | 13 | 14 | {%- for feed in ATOM_FEEDS %} 15 | 16 | {%- endfor %} 17 | 18 | 19 | {%- macro navbar() %}{% include "navbar.html" %}{% endmacro %} 20 | {{ navbar()|replace('\n', "\n\t\t") }} 21 |
22 | 42 |
43 | {%- with messages = get_flashed_messages(with_categories=true) -%} 44 | {% if messages %} 45 | {%- for category, message in messages %} 46 |
{{ message }}
47 | {%- endfor -%} 48 | {%- endif -%} 49 | {%- endwith -%} 50 | {%- block content -%}{%- endblock %} 51 |
52 | 53 | 54 | -------------------------------------------------------------------------------- /tracker/templates/bug.txt: -------------------------------------------------------------------------------- 1 | Summary 2 | ======= 3 | 4 | {% macro summary() -%} 5 | The package{% if pkgs|length > 1 %}s{% endif %}{%- raw %} {% endraw %} 6 | {%- for pkg in pkgs %} 7 | {{- pkg.pkgname -}} 8 | {%- if not loop.last %} 9 | {%- if loop.revindex > 2 %}, {% else %} and {% endif %} 10 | {%- endif %} 11 | {%- endfor -%} 12 | {%- if pkgs|length > 1 %} are{% else %} is{% endif %} vulnerable to 13 | {%- if unique_issue_types|length > 1 %} 14 | {%- raw %} {% endraw %}multiple issues including{%- raw %} {% endraw %} 15 | {%- for issue_type in unique_issue_types %} 16 | {{- issue_type }} 17 | {%- if not loop.last %} 18 | {%- if loop.revindex > 2 %}, {% else %} and {% endif %} 19 | {%- endif %} 20 | {%- endfor %} 21 | {%- else -%} 22 | {%- raw %} {% endraw %}{{- unique_issue_types[0] -}} 23 | {%- endif %} via{%- raw %} {% endraw %} 24 | {%- for issue in cves %} 25 | {{- issue.id -}} 26 | {%- if not loop.last %} 27 | {%- if loop.revindex > 2 %}, {% else %} and {% endif %} 28 | {%- endif %} 29 | {%- endfor -%}. 30 | {%- endmacro %} 31 | {{- summary() }} 32 | 33 | Guidance 34 | ======== 35 | 36 | 37 | 38 | References 39 | ========== 40 | 41 | {{ TRACKER_GROUP_URL.format(group.id) }} 42 | {%- for reference in references %} 43 | {{ reference }} 44 | {%- endfor -%} 45 | {%- for issue in issues %}{{ TRACKER_ISSUE_URL.format(issue.id) }} 46 | {% endfor %} 47 | -------------------------------------------------------------------------------- /tracker/templates/cve.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import colorize_severity, colorize_status, colorize_remote, colorize_unknown, nullable_value, bug_ticket -%} 3 | {% block content %} 4 |

{{ issue.id }} 5 | {%- if can_watch_log %} log{% endif %} 6 | {%- if can_edit %} edit{% endif %} 7 | {%- if can_edit %} copy{% endif %} 8 | {%- if can_delete %} delete{% endif %}

9 | 10 | 11 | 12 | 13 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
Source 14 | 52 |
Severity{{ colorize_severity(issue.severity) }}
Remote{{ colorize_remote(issue.remote) }}
Type{{ colorize_unknown(issue.issue_type|capitalize) }}
Description
{{ nullable_value(issue.description)|urlize }}
72 | {%- if groups %} 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | {%- for group in groups %} 87 | 88 | 89 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | {%- endfor %} 101 | 102 |
GroupPackageAffectedFixedSeverityStatusTicket
{{ group.name }} 90 | {%- for pkgname in group_packages[group] %} 91 | {{ pkgname }}{% if not loop.last %}, {% endif %} 92 | {%- endfor %} 93 | {{ group.affected }}{{ group.fixed }}{{ colorize_severity(group.severity) }}{{ colorize_status(group.status) }}{{ bug_ticket(group.bug_ticket) }}
103 | {%- endif %} 104 | {%- if advisories %} 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | {%- for advisory in advisories %} 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | {%- endfor %} 128 |
DateAdvisoryGroupPackageSeverityType
{{ advisory.created.strftime('%d %b %Y') }}{{ advisory.id }}{{ advisory.group_package.group.name }}{{ advisory.group_package.pkgname }}{{ colorize_severity(advisory.group_package.group.severity) }}{{ advisory.advisory_type }}
129 | {%- endif %} 130 | {%- if issue.reference %} 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 |
References
{{ issue.reference|urlize }}
143 | {%- endif %} 144 | {%- if issue.notes %} 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 |
Notes
{{ issue.notes|urlize }}
157 | {%- endif %} 158 | {%- endblock %} 159 | -------------------------------------------------------------------------------- /tracker/templates/error.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |
4 |

{{ smiley }}

5 |
{{ text }}
6 |
7 | {%- endblock %} 8 | -------------------------------------------------------------------------------- /tracker/templates/feed.html: -------------------------------------------------------------------------------- 1 |
{{ content }}
2 | -------------------------------------------------------------------------------- /tracker/templates/form/advisory.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ title }}

4 |
5 | {%- if concurrent_modification %} 6 | {%- include 'log/advisory_log_table.html' %} 7 | {%- endif %} 8 |
9 | {{ form.hidden_tag() }} 10 |
11 |
12 | {{ render_field(form.reference, class='full size', maxlength=Advisory.REFERENCE_LENGTH, placeholder='External reference...', indent=7) }} 13 |
14 |
15 |
16 |
17 | {{ render_field(form.workaround, class='full size', maxlength=Advisory.WORKAROUND_LENGTH, placeholder='Optional workaround...', indent=7) }} 18 |
19 |
20 |
21 |
22 | {{ render_field(form.impact, class='full size', maxlength=Advisory.IMPACT_LENGTH, placeholder='Overall impact...', indent=7, autofocus='') }} 23 |
24 |
25 | {%- if concurrent_modification %} 26 |
27 |
28 | {{ render_checkbox(form.force_submit, indent=7) }} 29 |
30 |
31 | {%- endif %} 32 | {{ form.edit(class='button-primary', accesskey='s') }} 33 |
34 |
35 | {%- endblock %} 36 | -------------------------------------------------------------------------------- /tracker/templates/form/cve.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ title }}

4 |
5 | {%- if concurrent_modification %} 6 | {%- include 'log/cve_log_table.html' %} 7 | {%- endif %} 8 |
9 | {{ form.hidden_tag() }} 10 |
11 |
12 | {{ render_field(form.cve, class='full size', placeholder='Issue identifier', autofocus='', required='', indent=7) }} 13 |
14 |
15 | {{ render_field(form.issue_type, class='full size', indent=7) }} 16 |
17 |
18 |
19 |
20 | {{ render_field(form.severity, class='full size', indent=7) }} 21 |
22 |
23 | {{ render_field(form.remote, class='full size', indent=7) }} 24 |
25 |
26 | {{ render_field(form.description, class='full size', maxlength=CVE.DESCRIPTION_LENGTH, placeholder='Detailed description...', indent=5) }} 27 | {{ render_field(form.reference, class='full size', maxlength=CVE.REFERENCES_LENGTH, placeholder='Relevant external references...', indent=5) }} 28 | {{ render_field(form.notes, class='full size', maxlength=CVE.NOTES_LENGTH, placeholder='Internal side notes...', indent=5) }} 29 | {%- if concurrent_modification %} 30 |
31 |
32 | {{ render_checkbox(form.force_submit, indent=7) }} 33 |
34 |
35 | {%- endif %} 36 | {{ form.submit(class='button-primary', accesskey='s') }} 37 |
38 |
39 | {%- endblock %} 40 | -------------------------------------------------------------------------------- /tracker/templates/form/delete_advisory.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import colorize_severity -%} 3 | {% block content %} 4 |

{% if heading %}{{ heading }}{% else %}Confirm{% endif %}

5 |
6 |
7 | {{ form.hidden_tag() }} 8 |
9 |

Details

10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Package 15 | {{ pkg.pkgname }} 16 |
Type{{ advisory.advisory_type }}
Severity{{ colorize_severity(group.severity) }}
Group{{ group }}
32 |
33 |
34 | Are you sure you want to delete {{ advisory.id }}?
35 |
36 | {{ form.confirm(class='button-primary') }} 37 | {{ form.abort(class='button-primary', accesskey='c') }} 38 |
39 |
40 |
41 | {%- endblock %} 42 | -------------------------------------------------------------------------------- /tracker/templates/form/delete_cve.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import colorize_severity, colorize_status, bug_ticket -%} 3 | {% block content %} 4 |

{% if heading %}{{ heading }}{% else %}Confirm{% endif %}

5 |
6 |
7 | {{ form.hidden_tag() }} 8 |
9 | {%- if groups %} 10 |

References

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {%- for group in groups %} 24 | 25 | 26 | 31 | 32 | 33 | 34 | 39 | 40 | {%- endfor %} 41 | 42 |
GroupPackageSeverityStatusTicketIssue
{{ group.name }} 27 | {%- for pkgname in group_packages[group] %} 28 | {{ pkgname }}{% if not loop.last %}, {% endif %} 29 | {%- endfor %} 30 | {{ colorize_severity(group.severity) }}{{ colorize_status(group.status) }}{{ bug_ticket(group.bug_ticket) }} 35 | {%- for group_issue in group_issues[group] %} 36 | {{ group_issue }}{% if not loop.last %}, {% endif %} 37 | {%- endfor %} 38 |
43 | {%- endif %} 44 |
45 |
46 | Are you sure you want to delete {{ issue }}?
47 |
48 | {{ form.confirm(class='button-primary') }} 49 | {{ form.abort(class='button-primary', accesskey='c') }} 50 |
51 |
52 |
53 | {%- endblock %} 54 | -------------------------------------------------------------------------------- /tracker/templates/form/delete_group.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import colorize_severity, colorize_status -%} 3 | {% block content %} 4 |

{% if heading %}{{ heading }}{% else %}Confirm{% endif %}

5 |
6 |
7 | {{ form.hidden_tag() }} 8 |
9 |

Details

10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 35 | 36 | 37 |
Packages 15 | {%- for pkg in packages %} 16 | {{ pkg.pkgname }}{% if not loop.last %}, {% endif %} 17 | {%- endfor %} 18 |
Status{{ colorize_status(group.status) }}
Severity{{ colorize_severity(group.severity) }}
Issues 31 | {%- for issue in issues %} 32 | {{ issue.id }}{% if not loop.last %}, {% endif %} 33 | {%- endfor %} 34 |
38 |
39 |
40 | Are you sure you want to delete {{ group }}?
41 |
42 | {{ form.confirm(class='button-primary') }} 43 | {{ form.abort(class='button-primary', accesskey='c') }} 44 |
45 |
46 |
47 | {%- endblock %} 48 | -------------------------------------------------------------------------------- /tracker/templates/form/group.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ title }}

4 |
5 | {%- if concurrent_modification %} 6 | {%- include 'log/group_log_table.html' %} 7 | {%- endif %} 8 |
9 | {{ form.hidden_tag() }} 10 |
11 |
12 | {{ render_field(form.pkgnames, class='full size', placeholder='Package names...', autofocus='', required='', indent=7) }} 13 |
14 |
15 | {{ render_field(form.cve, class='full size', placeholder='Issue identifiers...', required='', indent=7) }} 16 |
17 |
18 |
19 |
20 | {{ render_field(form.status, class='full size', indent=7) }} 21 |
22 |
23 | {{ render_field(form.bug_ticket, class='full size', placeholder='ID', indent=7) }} 24 |
25 |
26 | {{ render_field(form.affected, class='full size', placeholder='Package version', required='', indent=7) }} 27 |
28 |
29 | {{ render_field(form.fixed, class='full size', placeholder='Package version', indent=7) }} 30 |
31 |
32 | {{ render_field(form.reference, class='full size', maxlength=CVEGroup.REFERENCES_LENGTH, placeholder='Relevant external group references...', indent=5) }} 33 | {{ render_field(form.notes, class='full size', maxlength=CVEGroup.NOTES_LENGTH, placeholder='Internal group side notes...', indent=5) }} 34 |
35 |
36 | {{ render_checkbox(form.advisory_qualified, indent=7) }} 37 |
38 | {%- if concurrent_modification %} 39 |
40 | {{ render_checkbox(form.force_update, indent=7) }} 41 |
42 | {%- endif %} 43 | {%- if show_force_creation %} 44 |
45 | {{ render_checkbox(form.force_creation, indent=7) }} 46 |
47 | {%- endif %} 48 |
49 | {{ form.submit(class='button-primary', accesskey='s') }} 50 |
51 |
52 | {%- endblock %} 53 | -------------------------------------------------------------------------------- /tracker/templates/form/profile.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ title }}

4 |
5 |
6 | {{ form.hidden_tag() }} 7 |
8 |
9 | {{ render_field(form.password, class='full size', maxlength=password_length.max, placeholder='New password', autofocus='', required='', indent=7) }} 10 |
11 |
12 | {{ render_field(form.password_repeat, class='full size', maxlength=password_length.max, placeholder='Repeat password', required='', indent=7) }} 13 |
14 |
15 |
16 |
17 | {{ render_field(form.password_current, class='full size', maxlength=password_length.max, placeholder='Current password', required='', indent=7) }} 18 |
19 |
20 | {{ form.submit(class='button-primary', accesskey='s') }} 21 |
22 |
23 | {%- endblock %} 24 | -------------------------------------------------------------------------------- /tracker/templates/form/publish.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ title }}

4 |
5 |
6 | {{ form.hidden_tag() }} 7 |
8 |
9 | {{ render_field(form.reference, class='full size', maxlength=Advisory.REFERENCE_LENGTH, placeholder='External reference...', autofocus='', required='', indent=7) }} 10 |
11 |
12 | {{ form.submit(class='button-primary', accesskey='s') }} 13 |
14 |
15 | {%- endblock %} 16 | -------------------------------------------------------------------------------- /tracker/templates/group.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import nullable_value, colorize_severity, colorize_status, colorize_remote, colorize_unknown, bug_ticket -%} 3 | {% block content %} 4 |

{{ group.name }} 5 | {%- if can_watch_log %} log{% endif %} 6 | {%- if can_edit %} edit{% endif %} 7 | {%- if can_edit %} copy{% endif %} 8 | {%- if can_delete %} delete{% endif %}

9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% if not versions -%} 42 | 43 | {%- else -%} 44 | 49 | {%- endif %} 50 | 51 | 52 | 53 | {%- if group.bug_ticket %} 54 | 55 | {%- elif group.status == "Vulnerable" %} 56 | 57 | {%- else %} 58 | 59 | {%- endif %} 60 | 61 | 62 | 63 | 64 | 65 | {%- if advisories_pending %} 66 | 67 | 68 | {%- if can_handle_advisory %} 69 | 76 | {%- else %} 77 | 78 | {%- endif %} 79 | 80 | {%- endif %} 81 | 82 |
Package 14 | {%- for pkg in packages %} 15 | {{ pkg.pkgname }}{% if not loop.last %}, {% endif %} 16 | {%- endfor %} 17 |
Status{{ colorize_status(group.status) }}
Severity{{ colorize_severity(group.severity) }}
Type{{ issue_type }}
Affected{{ group.affected }}
Fixed{% if group.fixed %}{{ group.fixed }}{% elif group.status != Status.not_affected %}{{ colorize_unknown('Unknown') }}{% else %}{{ Status.not_affected.label }}{% endif %}
CurrentRemoved 45 | {%- for version in versions %} 46 | {{ version.version }} [{{ version.database }}]{% if not loop.last %}
{% endif %} 47 | {%- endfor %} 48 |
Ticket{{ bug_ticket(group.bug_ticket) }}CreateNone
Created{{ group.created.strftime('%c') }}
Advisory 70 |
71 | {{ form.hidden_tag() }} 72 | {{ form.advisory_type(class='no-margin input-compact') }} 73 | {{ form.submit(class='button button-table button-primary', accesskey='s') }} 74 |
75 |
Pending
83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | {%- for cve in issues %} 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | {%- endfor %} 103 | 104 |
IssueSeverityRemoteTypeDescription
{{ cve.id }}{{ colorize_severity(cve.severity) }}{{ colorize_remote(cve.remote) }}{{ colorize_unknown(cve.issue_type|capitalize) }}
{% if cve.description %}{{ cve.description|wordwrap(70, wrapstring=' ')|truncate(160) }}{% endif %}
105 | {%- if advisories %} 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | {% for advisory in advisories %} 117 | 118 | 119 | 120 | 121 | 122 | 123 | {% endfor %} 124 | 125 |
DateAdvisoryPackageType
{{ advisory.created.strftime('%d %b %Y') }}{{ advisory.id }}{{ advisory.group_package.pkgname }}{{ advisory.advisory_type }}
126 | {%- endif %} 127 | {%- if group.reference %} 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
References
{{ group.reference|urlize }}
140 | {%- endif %} 141 | {%- if group.notes %} 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 |
Notes
{{ group.notes|urlize }}
154 | {%- endif %} 155 | {%- endblock %} 156 | -------------------------------------------------------------------------------- /tracker/templates/index.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import nullable_value, colorize_severity, colorize_status, bug_ticket -%} 3 | {% block content %} 4 |

Issues

5 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {%- for entry in entries %} 28 | 29 | 30 | 35 | 40 | 41 | 42 | 43 | 44 | 45 | 54 | 55 | {%- endfor %} 56 | 57 |
GroupIssuePackageAffectedFixedSeverityStatusTicketAdvisory
{{ entry.group.name }} 31 | {%- for cve in entry.issues %} 32 | {{ cve.id }}{% if not loop.last %}
{% endif %} 33 | {%- endfor %} 34 |
36 | {%- for pkg in entry.pkgs %} 37 | {{ pkg }}{% if not loop.last %}, {% endif %} 38 | {%- endfor %} 39 | {{ entry.group.affected }}{{ entry.group.fixed }}{{ colorize_severity(entry.group.severity) }}{{ colorize_status(entry.group.status) }}{{ bug_ticket(entry.group.bug_ticket) }} 46 | {%- if entry.advisories -%} 47 | {%- for advisory in entry.advisories -%} 48 | {{ advisory }}{% if not loop.last %}
{% endif %} 49 | {%- endfor -%} 50 | {%- elif not entry.group.advisory_qualified -%} 51 | None 52 | {%- endif -%} 53 |
58 |
59 | {%- endblock %} 60 | -------------------------------------------------------------------------------- /tracker/templates/log/advisory_log.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ advisory.id }} - log 4 | back

5 | {%- for advisory in advisory.versions|reverse %} 6 | {% include 'log/advisory_log_table.html' %} 7 | {%- endfor %} 8 | {%- endblock %} 9 | -------------------------------------------------------------------------------- /tracker/templates/log/advisory_log_table.html: -------------------------------------------------------------------------------- 1 | {%- from "_formhelpers.html" import colorize_diff, log_transaction_header %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {%- if advisory.workaround_mod %} 10 | 11 | 12 | 15 | 16 | {%- endif %} 17 | {%- if advisory.impact_mod %} 18 | 19 | 20 | 23 | 24 | {%- endif %} 25 | 26 |
{{ log_transaction_header(advisory, can_watch_user_log) }}
Workaround 13 | {{- colorize_diff(advisory.previous.workaround, diff_content(advisory, advisory.workaround)) }} 14 |
Impact 21 | {{- colorize_diff(advisory.previous.impact, diff_content(advisory, advisory.impact)) }} 22 |
27 | -------------------------------------------------------------------------------- /tracker/templates/log/cve_log.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ issue.id }} - log 4 | back

5 | {%- for issue in issue.versions|reverse %} 6 | {% include 'log/cve_log_table.html' %} 7 | {%- endfor %} 8 | {%- endblock %} 9 | -------------------------------------------------------------------------------- /tracker/templates/log/cve_log_table.html: -------------------------------------------------------------------------------- 1 | {%- from "_formhelpers.html" import colorize_diff, log_transaction_header %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {%- if issue.severity_mod %} 10 | 11 | 12 | 15 | 16 | {%- endif %} 17 | {%- if issue.remote_mod %} 18 | 19 | 20 | 23 | 24 | {%- endif %} 25 | {%- if issue.issue_type_mod %} 26 | 27 | 28 | 31 | 32 | {%- endif %} 33 | {%- if issue.description_mod %} 34 | 35 | 36 | 39 | 40 | {%- endif %} 41 | {%- if issue.reference_mod %} 42 | 43 | 44 | 47 | 48 | {%- endif %} 49 | {%- if issue.notes_mod %} 50 | 51 | 52 | 55 | 56 | {%- endif %} 57 | 58 |
{{ log_transaction_header(issue, can_watch_user_log) }}
Severity 13 | {{- colorize_diff(issue.previous.severity, diff_content(issue, issue.severity)) }} 14 |
Remote 21 | {{- colorize_diff(issue.previous.remote, diff_content(issue, issue.remote)) }} 22 |
Type 29 | {{- colorize_diff(issue.previous.issue_type|capitalize, diff_content(issue, issue.issue_type|capitalize)) }} 30 |
Description 37 | {{- colorize_diff(issue.previous.description, diff_content(issue, issue.description)) }} 38 |
References 45 | {{- colorize_diff(issue.previous.reference, diff_content(issue, issue.reference)) }} 46 |
Notes 53 | {{- colorize_diff(issue.previous.notes, diff_content(issue, issue.notes)) }} 54 |
59 | -------------------------------------------------------------------------------- /tracker/templates/log/group_log.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

{{ group.name }} - log 4 | back

5 | {%- for group in group.versions|reverse %} 6 | {% include 'log/group_log_table.html' %} 7 | {%- endfor %} 8 | {%- endblock %} 9 | -------------------------------------------------------------------------------- /tracker/templates/log/group_log_table.html: -------------------------------------------------------------------------------- 1 | {%- from "_formhelpers.html" import colorize_diff, log_transaction_header, boolean_value %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {%- if (group.packages or group.previous.packages) and group.packages|map(attribute='pkgname')|sort != group.previous.packages|map(attribute='pkgname')|sort %} 10 | 11 | 12 | 15 | 16 | {%- endif %} 17 | {%- if (group.issues or group.previous.issues) and group.issues|map(attribute='cve_id')|issuesort != group.previous.issues|map(attribute='cve_id')|issuesort %} 18 | 19 | 20 | 23 | 24 | {%- endif %} 25 | {%- if group.status_mod %} 26 | 27 | 28 | 31 | 32 | {%- endif %} 33 | {%- if group.severity_mod %} 34 | 35 | 36 | 39 | 40 | {%- endif %} 41 | {%- if group.affected_mod %} 42 | 43 | 44 | 47 | 48 | {%- endif %} 49 | {%- if group.fixed_mod %} 50 | 51 | 52 | 55 | 56 | {%- endif %} 57 | {%- if group.bug_ticket_mod %} 58 | 59 | 60 | 63 | 64 | {%- endif %} 65 | {%- if group.advisory_qualified_mod %} 66 | 67 | 68 | 71 | 72 | {%- endif %} 73 | {%- if group.reference_mod %} 74 | 75 | 76 | 79 | 80 | {%- endif %} 81 | {%- if group.notes_mod %} 82 | 83 | 84 | 87 | 88 | {%- endif %} 89 | 90 |
{{ log_transaction_header(group, can_watch_user_log) }}
Packages 13 | {{- colorize_diff(group.previous.packages|map(attribute='pkgname')|sort|join('\n'), group.packages|map(attribute='pkgname')|sort|join('\n')) }} 14 |
Issues 21 | {{- colorize_diff(group.previous.issues|map(attribute='cve_id')|issuesort|join('\n'), group.issues|map(attribute='cve_id')|issuesort|join('\n')) }} 22 |
Status 29 | {{- colorize_diff(group.previous.status, diff_content(group, group.status)) }} 30 |
Severity 37 | {{- colorize_diff(group.previous.severity, diff_content(group, group.severity)) }} 38 |
Affected 45 | {{- colorize_diff(group.previous.affected, diff_content(group, group.affected)) }} 46 |
Fixed 53 | {{- colorize_diff(group.previous.fixed, diff_content(group, group.fixed)) }} 54 |
Ticket 61 | {{- colorize_diff(group.previous.bug_ticket, diff_content(group, group.bug_ticket)) }} 62 |
Advisory qualified 69 | {{- colorize_diff(boolean_value(group.previous.advisory_qualified|default(None), ''), diff_content(group, boolean_value(group.advisory_qualified))) }} 70 |
References 77 | {{- colorize_diff(group.previous.reference, diff_content(group, group.reference)) }} 78 |
Notes 85 | {{- colorize_diff(group.previous.notes, diff_content(group, group.notes)) }} 86 |
91 | -------------------------------------------------------------------------------- /tracker/templates/log/log.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import boolean_value, colorize_diff, render_pagination, log_transaction_header -%} 3 | {% block content %} 4 |

{{ title }}

5 | {{ render_pagination(pagination) }} 6 | {%- for log in pagination.items %} 7 | {%- set transaction = log[0] -%} 8 | {%- set issue = log[1] -%} 9 | {%- set group = log[2] -%} 10 | {%- set advisory = log[3] -%} 11 | {%- if group.__class__.__name__ == 'CVEGroupVersion' %} 12 | {% include 'log/group_log_table.html' %} 13 | {%- endif -%} 14 | {%- if issue.__class__.__name__ == 'CVEVersion' %} 15 | {% include 'log/cve_log_table.html' %} 16 | {%- endif -%} 17 | {%- if advisory.__class__.__name__ == 'AdvisoryVersion' %} 18 | {% include 'log/advisory_log_table.html' %} 19 | {%- endif -%} 20 | {%- endfor %} 21 | {%- endblock %} 22 | -------------------------------------------------------------------------------- /tracker/templates/log/user_log.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import boolean_value, colorize_diff, render_pagination, log_transaction_header -%} 3 | {% block content %} 4 |

{{ username }} - log

5 | {{ render_pagination(pagination) }} 6 | {%- for log in pagination.items %} 7 | {%- set transaction = log[0] -%} 8 | {%- set issue = log[1] -%} 9 | {%- set group = log[2] -%} 10 | {%- set advisory = log[3] -%} 11 | {%- if group.__class__.__name__ == 'CVEGroupVersion' %} 12 | {% include 'log/group_log_table.html' %} 13 | {%- endif -%} 14 | {%- if issue.__class__.__name__ == 'CVEVersion' %} 15 | {% include 'log/cve_log_table.html' %} 16 | {%- endif -%} 17 | {%- if advisory.__class__.__name__ == 'AdvisoryVersion' %} 18 | {% include 'log/advisory_log_table.html' %} 19 | {%- endif -%} 20 | {%- endfor %} 21 | {%- endblock %} 22 | -------------------------------------------------------------------------------- /tracker/templates/login.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {% block content %} 3 |

Login

4 |
5 |
6 | {{ form.hidden_tag() }} 7 |
8 |
9 | {{ render_field(form.username, class='full size', maxlength=User.NAME_LENGTH, placeholder='Username', autofocus='', required='', indent=7) }} 10 |
11 |
12 |
13 |
14 | {{ render_field(form.password, class='full size', placeholder='Password', required='', maxlength=password_length.max, indent=7) }} 15 |
16 |
17 | {{ form.login(class='button-primary') }} 18 |
19 |
20 | {%- endblock %} 21 | -------------------------------------------------------------------------------- /tracker/templates/navbar.html: -------------------------------------------------------------------------------- 1 | ../../.external/archlinux-common-style/html/navbar.html -------------------------------------------------------------------------------- /tracker/templates/package.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import nullable_value, colorize_severity, colorize_status, colorize_remote, colorize_unknown, bug_ticket -%} 3 | {%- macro render_groups(groups) -%} 4 | {%- if groups %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {%- for group in groups %} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {%- endfor %} 27 | 28 |
GroupAffectedFixedSeverityStatusTicket
{{ group.name }}{{ group.affected }}{{ group.fixed }}{{ colorize_severity(group.severity) }}{{ colorize_status(group.status) }}{{ bug_ticket(group.bug_ticket) }}
29 | {%- endif %} 30 | {%- endmacro -%} 31 | 32 | {%- macro render_issues(issues) -%} 33 | {%- if issues %} 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {%- for issue in issues %} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 60 | 61 | {%- endfor %} 62 | 63 |
IssueGroupSeverityRemoteTypeDescription
{{ issue.issue.id }}{{ issue.group.name }}{{ colorize_severity(issue.issue.severity) }}{{ colorize_remote(issue.issue.remote) }}{{ colorize_unknown(issue.issue.issue_type|capitalize) }} 54 | {%- if issue.issue.description -%} 55 |
{{ issue.issue.description|wordwrap(70, wrapstring=' ')|truncate(160) }}
56 | {%- else -%} 57 | {{colorize_unknown("Unknown")}} 58 | {%- endif -%} 59 |
64 | {%- endif %} 65 | {%- endmacro -%} 66 | {% block content %} 67 |

{{ package.pkgname }}

68 | 69 | 70 | 71 | 72 | 85 | 86 | 87 | 88 | 95 | 96 | 97 | 98 | {% if not package.versions -%} 99 | 100 | {%- else -%} 101 | 106 | {%- endif %} 107 | 108 | 109 |
Link 73 | {% if package.versions[0] -%} 74 | {%- set version = package.versions[0] -%} 75 | package | 76 | {%- else -%} 77 | package | 78 | {%- endif %} 79 | bugs open | 80 | bugs closed | 81 | Wiki | 82 | GitHub | 83 | web search 84 |
Description 89 | {%- if package.versions[0] -%} 90 | {{ package.versions[0].description }} 91 | {%- else -%} 92 | {{colorize_unknown("Unknown")}} 93 | {%- endif -%} 94 |
VersionRemoved 102 | {%- for version in package.versions %} 103 | {{ version.version }} [{{ version.database }}]{% if not loop.last %}
{% endif %} 104 | {%- endfor %} 105 |
110 | {%- if not package.groups.open and not package.issues.open and not package.groups.resolved and not package.issues.resolved %} 111 |

No issues

112 | {%- endif %} 113 | {%- if package.groups.open or package.issues.open %} 114 |

Open

115 | {{- render_groups(package.groups.open) }} 116 | {{- render_issues(package.issues.open) }} 117 | {%- endif %} 118 | {%- if package.groups.resolved or package.issues.resolved %} 119 |

Resolved

120 | {{- render_groups(package.groups.resolved) }} 121 | {{- render_issues(package.issues.resolved) }} 122 | {%- endif %} 123 | {%- if package.advisories %} 124 |

Advisories

125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | {%- for advisory in package.advisories %} 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | {%- endfor %} 145 | 146 |
DateAdvisoryGroupSeverityType
{{ advisory.created.strftime('%d %b %Y') }}{{ advisory.id }}{{ advisory.group_package.group.name }}{{ colorize_severity(advisory.group_package.group.severity) }}{{ advisory.advisory_type }}
147 | {%- endif %} 148 | {%- endblock %} 149 | -------------------------------------------------------------------------------- /tracker/templates/stats.html: -------------------------------------------------------------------------------- 1 | {%- extends "base.html" -%} 2 | {%- from "_formhelpers.html" import colorize_severity, colorize_status, colorize_remote, colorize_unknown -%} 3 | {% block content %} 4 |

Stats

5 |

Status

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {%- for severity in Severity %} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {%- endfor %} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
TypeIssues{{ colorize_severity(Severity.medium, 'Local') }}{{ colorize_severity(Severity.high, 'Remote') }}{{ colorize_status(Status.vulnerable, 'Open') }}{{ colorize_status(Status.fixed) }}Groups{{ colorize_status(Status.vulnerable, 'Open') }}{{ colorize_status(Status.fixed) }}Packages{{ colorize_status(Status.vulnerable, 'Open') }}{{ colorize_status(Status.fixed) }}Advisories
{{ colorize_severity(severity.label) }}{{ issues.severity.total[severity.name] }}{{ issues.severity.local[severity.name] }}{{ issues.severity.remote[severity.name] }}{{ issues.severity.vulnerable[severity.name] }}{{ issues.severity.fixed[severity.name] }}{{ groups.severity.total[severity.name] }}{{ groups.severity.vulnerable[severity.name] }}{{ groups.severity.fixed[severity.name] }}{{ packages.severity.total[severity.name] }}{{ packages.severity.vulnerable[severity.name] }}{{ packages.severity.fixed[severity.name] }}{{ advisories.severity[severity.name] }}
Total{{ issues.total }}{{ issues.severity.local.total }}{{ issues.severity.remote.total }}{{ issues.severity.vulnerable.total }}{{ issues.severity.fixed.total }}{{ groups.total }}{{ groups.severity.vulnerable.total }}{{ groups.severity.fixed.total }}{{ packages.total }}{{ packages.severity.vulnerable.total }}{{ packages.severity.fixed.total }}{{ advisories.total }}
62 |

Types

63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | {%- for issue_type in issue_types %} 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | {%- endfor %} 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |
TypeIssues{{ colorize_severity(Severity.medium, 'Local') }}{{ colorize_severity(Severity.high, 'Remote') }}{{ colorize_status(Status.vulnerable, 'Open') }}{{ colorize_status(Status.fixed) }}Advisories
multiple issues-----{{ advisories.type['multiple issues'] }}
{{ issue_type }}{{ issues.type.total[issue_type] }}{{ issues.type.local[issue_type] }}{{ issues.type.remote[issue_type] }}{{ issues.type.vulnerable[issue_type] }}{{ issues.type.fixed[issue_type] }}{{ advisories.type[issue_type] }}
Total{{ issues.total }}{{ issues.type.local.total }}{{ issues.type.remote.total }}{{ issues.type.vulnerable.total }}{{ issues.type.fixed.total }}{{ advisories.total }}
111 |

Misc

112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 |
Bug tickets{{ tickets.total }}
Team members{{ users.team }}
Reporters{{ users.reporter }}
131 | {%- endblock %} 132 | -------------------------------------------------------------------------------- /tracker/user.py: -------------------------------------------------------------------------------- 1 | from base64 import b85encode 2 | from functools import wraps 3 | from os import urandom 4 | 5 | from flask_login import current_user 6 | from flask_login import login_required 7 | from scrypt import hash as shash 8 | from sqlalchemy.exc import IntegrityError 9 | 10 | from config import SSO_ADMINISTRATOR_GROUP 11 | from config import SSO_ENABLED 12 | from config import SSO_REPORTER_GROUP 13 | from config import SSO_SECURITY_TEAM_GROUP 14 | from config import TRACKER_PASSWORD_LENGTH_MIN 15 | from tracker import db 16 | from tracker import login_manager 17 | from tracker.model.user import Guest 18 | from tracker.model.user import User 19 | from tracker.model.user import UserRole 20 | 21 | login_manager.anonymous_user = Guest 22 | 23 | 24 | def random_string(length=TRACKER_PASSWORD_LENGTH_MIN): 25 | salt = b85encode(urandom(length)) 26 | return salt.decode() 27 | 28 | 29 | def hash_password(password, salt): 30 | hashed = b85encode(shash(password, salt[:User.SALT_LENGTH])) 31 | return hashed.decode()[:User.PASSWORD_LENGTH] 32 | 33 | 34 | @login_manager.user_loader 35 | def load_user(session_token): 36 | if not session_token: 37 | return Guest() 38 | 39 | user = User.query.filter_by(token=session_token).first() 40 | if not user: 41 | return Guest() 42 | user.is_authenticated = True 43 | return user 44 | 45 | 46 | def permission_required(permission): 47 | def decorator(func): 48 | @wraps(func) 49 | def decorated_view(*args, **kwargs): 50 | if not permission.fget(current_user.role): 51 | from tracker.view.error import forbidden 52 | return forbidden() 53 | return func(*args, **kwargs) 54 | return login_required(decorated_view) 55 | return decorator 56 | 57 | 58 | def reporter_required(func): 59 | return permission_required(UserRole.is_reporter)(func) 60 | 61 | 62 | def security_team_required(func): 63 | return permission_required(UserRole.is_security_team)(func) 64 | 65 | 66 | def administrator_required(func): 67 | return permission_required(UserRole.is_administrator)(func) 68 | 69 | 70 | def require_expected_sso_state(expected): 71 | def decorator(func): 72 | @wraps(func) 73 | def decorated_view(*args, **kwargs): 74 | if SSO_ENABLED is not expected: 75 | from tracker.view.error import not_found 76 | return not_found() 77 | return func(*args, **kwargs) 78 | return decorated_view 79 | return decorator 80 | 81 | 82 | def only_with_sso(func): 83 | return require_expected_sso_state(expected=True)(func) 84 | 85 | 86 | def only_without_sso(func): 87 | return require_expected_sso_state(expected=False)(func) 88 | 89 | 90 | def user_can_edit_issue(advisories=[]): 91 | role = current_user.role 92 | if not role.is_reporter: 93 | return False 94 | if role.is_security_team: 95 | return True 96 | return 0 == len(advisories) 97 | 98 | 99 | def user_can_delete_issue(advisories=[]): 100 | role = current_user.role 101 | if not role.is_reporter: 102 | return False 103 | return 0 == len(advisories) 104 | 105 | 106 | def user_can_edit_group(advisories=[]): 107 | return user_can_edit_issue(advisories) 108 | 109 | 110 | def user_can_delete_group(advisories=[]): 111 | return user_can_delete_issue(advisories) 112 | 113 | 114 | def user_can_handle_advisory(): 115 | return current_user.role.is_security_team 116 | 117 | 118 | def user_can_watch_log(): 119 | return True 120 | 121 | 122 | def user_can_watch_user_log(): 123 | return current_user.role.is_reporter 124 | 125 | 126 | def user_invalidate(user): 127 | user.token = None 128 | user.is_authenticated = False 129 | 130 | 131 | def user_assign_new_token(user, max_tries=32): 132 | def assign_token(token): 133 | user.token = token 134 | return user 135 | return user_generate_new_token(assign_token, max_tries) 136 | 137 | 138 | def user_generate_new_token(callback, max_tries=32): 139 | failed = 0 140 | while failed < max_tries: 141 | try: 142 | token = random_string(User.TOKEN_LENGTH) 143 | token_owners = User.query.filter_by(token=token).count() 144 | if 0 != token_owners: 145 | failed += 1 146 | continue 147 | user = callback(token) 148 | db.session.commit() 149 | return user 150 | except IntegrityError: 151 | db.session.rollback() 152 | failed += 1 153 | raise Exception('Failed to obtain unique token within {} tries'.format(max_tries)) 154 | 155 | 156 | def get_user_role_from_idp_groups(idp_groups): 157 | group_names_for_roles = { 158 | SSO_ADMINISTRATOR_GROUP: UserRole.administrator, 159 | SSO_SECURITY_TEAM_GROUP: UserRole.security_team, 160 | SSO_REPORTER_GROUP: UserRole.reporter 161 | } 162 | 163 | eligible_roles = [group_names_for_roles[group] for group in idp_groups if group in group_names_for_roles] 164 | 165 | if eligible_roles: 166 | return sorted(eligible_roles, reverse=False)[0] 167 | return None 168 | -------------------------------------------------------------------------------- /tracker/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import wraps 3 | 4 | from flask import json 5 | from requests.models import PreparedRequest 6 | 7 | from config import atom_feeds 8 | 9 | word_split_re = re.compile(r'(\s+)') 10 | punctuation_re = re.compile( 11 | '^(?P(?:%s)*)(?P.*?)(?P(?:%s)*)$' % ( 12 | '|'.join(map(re.escape, ('(', '<', '<'))), 13 | '|'.join(map(re.escape, ('.', ',', ')', '>', '\n', '>'))) 14 | ) 15 | ) 16 | 17 | 18 | def multiline_to_list(data, whitespace_separator=True, unique_only=True, filter_empty=True): 19 | if not data: 20 | return [] 21 | if whitespace_separator: 22 | data = data.replace(' ', '\n') 23 | data_list = data.replace('\r', '').split('\n') 24 | if unique_only: 25 | data_list = list_uniquify(data_list) 26 | if filter_empty: 27 | data_list = list(filter(lambda e: len(e) > 0, data_list)) 28 | return data_list 29 | 30 | 31 | def list_uniquify(data): 32 | used = set() 33 | return [e for e in data if e not in used and (used.add(e) or True)] 34 | 35 | 36 | def cmp_to_key(cmp_func, getter=None): 37 | class K(object): 38 | def __init__(self, obj, *args): 39 | self.obj = obj 40 | 41 | def extract(self, obj): 42 | if getter: 43 | return getter(obj) 44 | return obj 45 | 46 | def __lt__(self, other): 47 | return cmp_func(self.extract(self.obj), self.extract(other.obj)) < 0 48 | 49 | def __gt__(self, other): 50 | return cmp_func(self.extract(self.obj), self.extract(other.obj)) > 0 51 | 52 | def __eq__(self, other): 53 | return cmp_func(self.extract(self.obj), self.extract(other.obj)) == 0 54 | 55 | def __le__(self, other): 56 | return cmp_func(self.extract(self.obj), self.extract(other.obj)) <= 0 57 | 58 | def __ge__(self, other): 59 | return cmp_func(self.extract(self.obj), self.extract(other.obj)) >= 0 60 | 61 | def __ne__(self, other): 62 | return cmp_func(self.extract(self.obj), self.extract(other.obj)) != 0 63 | return K 64 | 65 | 66 | def chunks(l, n): 67 | """Yield successive n-sized chunks from l.""" 68 | for i in range(0, len(l), n): 69 | yield l[i:i + n] 70 | 71 | 72 | def json_response(func): 73 | @wraps(func) 74 | def wrapped(*args, **kwargs): 75 | response = func(*args, **kwargs) 76 | code = 200 77 | if isinstance(response, tuple): 78 | response, code = response 79 | dump = json.dumps(response, indent=2, sort_keys=False) 80 | return dump, code, {'Content-Type': 'application/json; charset=utf-8'} 81 | return wrapped 82 | 83 | 84 | def atom_feed(title): 85 | def decorator(func): 86 | atom_feeds.append({'func': 'tracker.{}'.format(func.__name__), 'title': title}) 87 | 88 | @wraps(func) 89 | def wrapped(*args, **kwargs): 90 | return func(*args, **kwargs) 91 | return wrapped 92 | return decorator 93 | 94 | 95 | def issue_to_numeric(issue_label): 96 | self_parts = issue_label.split('-') 97 | return int(self_parts[1] + self_parts[2].rjust(7, '0')) 98 | 99 | 100 | def add_params_to_uri(url, params): 101 | req = PreparedRequest() 102 | req.prepare_url(url, params) 103 | return req.url 104 | -------------------------------------------------------------------------------- /tracker/view/__init__.py: -------------------------------------------------------------------------------- 1 | from .add import * 2 | from .admin import * 3 | from .advisory import * 4 | from .copy import * 5 | from .delete import * 6 | from .edit import * 7 | from .error import * 8 | from .index import * 9 | from .login import * 10 | from .show import * 11 | from .stats import * 12 | from .todo import * 13 | from .user import * 14 | -------------------------------------------------------------------------------- /tracker/view/admin.py: -------------------------------------------------------------------------------- 1 | from flask import flash 2 | from flask import redirect 3 | from flask import render_template 4 | from flask_login import current_user 5 | from flask_login import login_required 6 | 7 | from config import SSO_ENABLED 8 | from config import TRACKER_PASSWORD_LENGTH_MAX 9 | from config import TRACKER_PASSWORD_LENGTH_MIN 10 | from tracker import db 11 | from tracker import tracker 12 | from tracker.form.admin import UserForm 13 | from tracker.form.confirm import ConfirmForm 14 | from tracker.model.enum import UserRole 15 | from tracker.model.user import Guest 16 | from tracker.model.user import User 17 | from tracker.model.user import username_regex 18 | from tracker.user import administrator_required 19 | from tracker.user import hash_password 20 | from tracker.user import only_without_sso 21 | from tracker.user import random_string 22 | from tracker.user import user_invalidate 23 | from tracker.view.error import forbidden 24 | from tracker.view.error import not_found 25 | 26 | 27 | @tracker.route('/admin', methods=['GET', 'POST']) 28 | @tracker.route('/user', methods=['GET', 'POST']) 29 | @login_required 30 | def list_user(): 31 | users = User.query.order_by(User.name).all() 32 | users = sorted(users, key=lambda u: u.name) 33 | 34 | if not current_user.role.is_administrator: 35 | masked = [] 36 | for user in users: 37 | guest = Guest() 38 | guest.name = user.name 39 | guest.email = user.email 40 | guest.role = user.role if not user.role.is_administrator else UserRole.security_team 41 | guest.active = user.active 42 | if user.active: 43 | masked.append(guest) 44 | users = masked 45 | 46 | users = sorted(users, key=lambda u: u.role) 47 | return render_template('admin/user.html', 48 | title='User list', 49 | users=users) 50 | 51 | 52 | @tracker.route('/user/create', methods=['GET', 'POST']) 53 | @only_without_sso 54 | @administrator_required 55 | def create_user(): 56 | form = UserForm() 57 | if not form.validate_on_submit(): 58 | return render_template('admin/form/user.html', 59 | title='Create user', 60 | form=form, 61 | User=User, 62 | password_length={'min': TRACKER_PASSWORD_LENGTH_MIN, 63 | 'max': TRACKER_PASSWORD_LENGTH_MAX}) 64 | 65 | password = random_string() if not form.password.data else form.password.data 66 | salt = random_string() 67 | 68 | user = db.create(User, 69 | name=form.username.data, 70 | email=form.email.data, 71 | salt=salt, 72 | password=hash_password(password, salt), 73 | role=UserRole.fromstring(form.role.data), 74 | active=form.active.data) 75 | db.session.commit() 76 | 77 | flash('Created user {} with password {}'.format(user.name, password)) 78 | return redirect('/user') 79 | 80 | 81 | @tracker.route('/user//edit'.format(username_regex[1:-1]), methods=['GET', 'POST']) 82 | @only_without_sso 83 | @administrator_required 84 | def edit_user(username): 85 | own_user = username == current_user.name 86 | if not current_user.role.is_administrator and not own_user: 87 | forbidden() 88 | 89 | user = User.query.filter_by(name=username).first() 90 | if not user: 91 | return not_found() 92 | 93 | form = UserForm(edit=True) 94 | if not form.is_submitted(): 95 | form.username.data = user.name 96 | form.email.data = user.email 97 | form.role.data = user.role.name 98 | form.active.data = user.active 99 | if not form.validate_on_submit(): 100 | return render_template('admin/form/user.html', 101 | title='Edit {}'.format(username), 102 | form=form, 103 | User=User, 104 | random_password=True, 105 | password_length={'min': TRACKER_PASSWORD_LENGTH_MIN, 106 | 'max': TRACKER_PASSWORD_LENGTH_MAX}) 107 | 108 | active_admins = User.query.filter_by(active=True, role=UserRole.administrator).count() 109 | if user.id == current_user.id and 1 == active_admins and not form.active.data: 110 | return forbidden() 111 | 112 | user.name = form.username.data 113 | user.email = form.email.data 114 | user.role = UserRole.fromstring(form.role.data) 115 | if form.random_password.data: 116 | form.password.data = random_string() 117 | if form.password.data and 0 != len(form.password.data): 118 | user.salt = random_string() 119 | user.password = hash_password(form.password.data, user.salt) 120 | user.active = form.active.data 121 | user_invalidate(user) 122 | db.session.commit() 123 | 124 | flash_password = '' 125 | if form.random_password.data: 126 | flash_password = ' with password {}'.format(form.password.data) 127 | flash('Edited user {}{}'.format(user.name, flash_password)) 128 | return redirect('/user') 129 | 130 | 131 | @tracker.route('/user//delete'.format(username_regex[1:-1]), methods=['GET', 'POST']) 132 | @only_without_sso 133 | @administrator_required 134 | def delete_user(username): 135 | user = User.query.filter_by(name=username).first() 136 | if not user: 137 | return not_found() 138 | 139 | form = ConfirmForm() 140 | title = 'Delete {}'.format(username) 141 | if not form.validate_on_submit(): 142 | return render_template('admin/form/delete_user.html', 143 | title=title, 144 | heading=title, 145 | form=form, 146 | user=user) 147 | 148 | if not form.confirm.data: 149 | return redirect('/user') 150 | 151 | active_admins = User.query.filter_by(active=True, role=UserRole.administrator).count() 152 | if user.id == current_user.id and 1 >= active_admins: 153 | return forbidden() 154 | 155 | user_invalidate(user) 156 | db.session.delete(user) 157 | db.session.commit() 158 | flash('Deleted user {}'.format(user.name)) 159 | return redirect('/user') 160 | -------------------------------------------------------------------------------- /tracker/view/blueprint.py: -------------------------------------------------------------------------------- 1 | from difflib import ndiff 2 | from re import sub 3 | 4 | from flask import Blueprint 5 | from flask import request 6 | from flask import url_for 7 | from jinja2.filters import do_urlize 8 | from jinja2.filters import pass_eval_context 9 | from markupsafe import Markup 10 | from markupsafe import escape 11 | 12 | from tracker.model.cve import cve_id_regex 13 | from tracker.model.cvegroup import vulnerability_group_regex 14 | from tracker.util import issue_to_numeric 15 | from tracker.util import punctuation_re 16 | from tracker.util import word_split_re 17 | 18 | blueprint = Blueprint('filters', __name__) 19 | 20 | 21 | @blueprint.app_template_filter() 22 | def smartindent(s, width=1, indentfirst=False, indentchar=u'\t'): 23 | """Return a copy of the passed string, each line indented by 24 | 1 tab. The first line is not indented. If you want to 25 | change the number of tabs or indent the first line too 26 | you can pass additional parameters to the filter: 27 | 28 | .. sourcecode:: jinja 29 | 30 | {{ mytext|indent(2, true, 'x') }} 31 | indent by two 'x' and indent the first line too. 32 | """ 33 | indention = indentchar * width 34 | rv = (u'\n' + indention).join(s.splitlines()) 35 | if indentfirst: 36 | rv = indention + rv 37 | return rv 38 | 39 | 40 | @pass_eval_context 41 | @blueprint.app_template_filter() 42 | def urlize(ctx, text, trim_url_limit=None, rel=None, target=None): 43 | """Converts any URLs in text into clickable links. Works on http://, 44 | https:// and www. links. Links can have trailing punctuation (periods, 45 | commas, close-parens) and leading punctuation (opening parens) and 46 | it'll still do the right thing. 47 | Aditionally it will populate the input with application context related 48 | links linke issues and groups. 49 | 50 | If trim_url_limit is not None, the URLs in link text will be limited 51 | to trim_url_limit characters. 52 | 53 | If nofollow is True, the URLs in link text will get a rel="nofollow" 54 | attribute. 55 | 56 | If target is not None, a target attribute will be added to the link. 57 | """ 58 | 59 | words = word_split_re.split(escape(text)) 60 | for i, word in enumerate(words): 61 | match = punctuation_re.match(word) 62 | if match: 63 | lead, word, trail = match.groups() 64 | word = sub('({})'.format(cve_id_regex), '\\1', word) 65 | word = sub('({})'.format(vulnerability_group_regex), '\\1', word) 66 | words[i] = '{}{}{}'.format(lead, word, trail) 67 | 68 | text = ''.join(words) 69 | if ctx.autoescape: 70 | text = Markup(text) 71 | 72 | text = do_urlize(ctx, text, trim_url_limit=trim_url_limit, target=target, rel=rel) 73 | return text 74 | 75 | 76 | @blueprint.app_template_filter() 77 | def diff(previous, current): 78 | # handle None explicitly to allow diff of False against True 79 | return ndiff(str(previous).splitlines() if previous is not None else '', 80 | str(current).splitlines() if current is not None else '') 81 | 82 | 83 | @blueprint.app_template_filter() 84 | def issuesort(issues): 85 | return sorted(issues, key=issue_to_numeric) 86 | 87 | 88 | @blueprint.app_template_global() 89 | def url_for_page(page): 90 | args = request.view_args.copy() 91 | args['page'] = page 92 | return url_for(request.endpoint, **args) 93 | 94 | 95 | @blueprint.app_template_global() 96 | def diff_content(model, field): 97 | return field if model.operation_type != 2 else None 98 | -------------------------------------------------------------------------------- /tracker/view/copy.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from flask import render_template 4 | from sqlalchemy import func 5 | 6 | from tracker import db 7 | from tracker import tracker 8 | from tracker.form import CVEForm 9 | from tracker.form import GroupForm 10 | from tracker.model import CVE 11 | from tracker.model import CVEGroup 12 | from tracker.model import CVEGroupEntry 13 | from tracker.model import CVEGroupPackage 14 | from tracker.model.cve import cve_id_regex 15 | from tracker.model.cvegroup import vulnerability_group_regex 16 | from tracker.model.enum import status_to_affected 17 | from tracker.user import reporter_required 18 | from tracker.view.error import not_found 19 | 20 | 21 | @tracker.route('/issue//copy'.format(cve_id_regex[1:-1]), methods=['GET']) 22 | @tracker.route('/cve//copy'.format(cve_id_regex[1:-1]), methods=['GET']) 23 | @tracker.route('//copy'.format(cve_id_regex[1:-1]), methods=['GET']) 24 | @reporter_required 25 | def copy_issue(issue): 26 | cve = db.get(CVE, id=issue) 27 | if not cve: 28 | return not_found() 29 | 30 | form = CVEForm() 31 | form.cve.data = cve.id 32 | form.description.data = cve.description 33 | form.issue_type.data = cve.issue_type 34 | form.notes.data = cve.notes 35 | form.reference.data = cve.reference 36 | form.remote.data = cve.remote.name 37 | form.severity.data = cve.severity.name 38 | 39 | return render_template('form/cve.html', 40 | title='Add CVE', 41 | form=form, 42 | CVE=CVE, 43 | action='/cve/add') 44 | 45 | 46 | @tracker.route('/group//copy'.format(vulnerability_group_regex[1:-1]), methods=['GET']) 47 | @tracker.route('/avg//copy'.format(vulnerability_group_regex[1:-1]), methods=['GET']) 48 | @tracker.route('//copy'.format(vulnerability_group_regex[1:-1]), methods=['GET']) 49 | @reporter_required 50 | def copy_group(avg): 51 | group_id = avg.replace('AVG-', '') 52 | group_data = (db.session.query(CVEGroup, CVE, func.group_concat(CVEGroupPackage.pkgname, ' ')) 53 | .filter(CVEGroup.id == group_id) 54 | .join(CVEGroupEntry, CVEGroup.issues) 55 | .join(CVE, CVEGroupEntry.cve) 56 | .join(CVEGroupPackage, CVEGroup.packages) 57 | .group_by(CVEGroup.id).group_by(CVE.id) 58 | .order_by(CVE.id)).all() 59 | if not group_data: 60 | return not_found() 61 | 62 | group = group_data[0][0] 63 | issues = [cve for (group, cve, pkg) in group_data] 64 | issue_ids = [cve.id for cve in issues] 65 | pkgnames = set(chain.from_iterable([pkg.split(' ') for (group, cve, pkg) in group_data])) 66 | 67 | form = GroupForm() 68 | form.advisory_qualified.data = group.advisory_qualified 69 | form.affected.data = group.affected 70 | form.bug_ticket.data = group.bug_ticket 71 | form.cve.data = '\n'.join(issue_ids) 72 | form.fixed.data = group.fixed 73 | form.notes.data = group.notes 74 | form.pkgnames.data = '\n'.join(sorted(pkgnames)) 75 | form.reference.data = group.reference 76 | form.status.data = status_to_affected(group.status).name 77 | 78 | return render_template('form/group.html', 79 | title='Add AVG', 80 | form=form, 81 | CVEGroup=CVEGroup, 82 | action='/avg/add') 83 | -------------------------------------------------------------------------------- /tracker/view/error.py: -------------------------------------------------------------------------------- 1 | from binascii import hexlify 2 | from functools import wraps 3 | from logging import error 4 | from os import urandom 5 | from random import randint 6 | 7 | from flask import make_response 8 | from flask import render_template 9 | from werkzeug.exceptions import BadRequest 10 | from werkzeug.exceptions import Forbidden 11 | from werkzeug.exceptions import Gone 12 | from werkzeug.exceptions import InternalServerError 13 | from werkzeug.exceptions import MethodNotAllowed 14 | from werkzeug.exceptions import NotFound 15 | 16 | from config import get_debug_flag 17 | from tracker import tracker 18 | from tracker.symbol import smileys_sad 19 | 20 | error_handlers = [] 21 | 22 | 23 | def errorhandler(code_or_exception): 24 | def decorator(func): 25 | error_handlers.append({'func': func, 'code_or_exception': code_or_exception}) 26 | 27 | @wraps(func) 28 | def wrapped(*args, **kwargs): 29 | return func(*args, **kwargs) 30 | return wrapped 31 | return decorator 32 | 33 | 34 | def handle_error(e, code, json=False): 35 | if json: 36 | return {'message': e}, code 37 | return make_response(render_template('error.html', 38 | smiley=smileys_sad[randint(0, len(smileys_sad) - 1)], 39 | text=e, 40 | title='{}'.format(code)), code) 41 | 42 | 43 | @errorhandler(NotFound.code) 44 | def not_found(e='404: Not Found', json=False): 45 | return handle_error(e if 'check your spelling' not in '{}'.format(e) else '404: Not Found', NotFound.code, json) 46 | 47 | 48 | @errorhandler(Forbidden.code) 49 | def forbidden(e='403: Forbidden', json=False): 50 | return handle_error(e, Forbidden.code, json) 51 | 52 | 53 | @errorhandler(MethodNotAllowed.code) 54 | def method_not_allowed(e='405: Method Not Allowed', json=False): 55 | return handle_error(e, MethodNotAllowed.code, json) 56 | 57 | 58 | @errorhandler(Gone.code) 59 | def gone(e='410: Gone', json=False): 60 | return handle_error(e, Gone.code, json) 61 | 62 | 63 | @errorhandler(BadRequest.code) 64 | def bad_request(e='400: Bad Request', json=False): 65 | return handle_error(e, BadRequest.code, json) 66 | 67 | 68 | @errorhandler(Exception) 69 | @errorhandler(InternalServerError.code) 70 | def internal_error(e): 71 | if get_debug_flag(): 72 | raise e 73 | code = hexlify(urandom(4)).decode() 74 | error(Exception("Code: {}".format(code), e), exc_info=True) 75 | text = '500: Deep Shit\n{}'.format(code) 76 | return handle_error(text, InternalServerError.code) 77 | -------------------------------------------------------------------------------- /tracker/view/index.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from collections import defaultdict 3 | 4 | from flask import render_template 5 | from sqlalchemy import and_ 6 | from sqlalchemy import func 7 | 8 | from tracker import db 9 | from tracker import tracker 10 | from tracker.model import CVE 11 | from tracker.model import Advisory 12 | from tracker.model import CVEGroup 13 | from tracker.model import CVEGroupEntry 14 | from tracker.model import CVEGroupPackage 15 | from tracker.model import Package 16 | from tracker.model.enum import Publication 17 | from tracker.model.enum import Status 18 | from tracker.util import json_response 19 | 20 | 21 | def get_index_data(only_vulnerable=False, only_in_repo=True): 22 | select = (db.session.query(CVEGroup, CVE, func.group_concat(CVEGroupPackage.pkgname, ' '), 23 | func.group_concat(Advisory.id, ' ')) 24 | .join(CVEGroupEntry, CVEGroup.issues) 25 | .join(CVE, CVEGroupEntry.cve) 26 | .join(CVEGroupPackage, CVEGroup.packages) 27 | .outerjoin(Advisory, and_(Advisory.group_package_id == CVEGroupPackage.id, 28 | Advisory.publication == Publication.published))) 29 | if only_vulnerable: 30 | select = select.filter(CVEGroup.status.in_([Status.unknown, Status.vulnerable, Status.testing])) 31 | if only_in_repo: 32 | select = select.join(Package, Package.name == CVEGroupPackage.pkgname) 33 | 34 | entries = (select.group_by(CVEGroup.id).group_by(CVE.id) 35 | .order_by(CVEGroup.status.desc()) 36 | .order_by(CVEGroup.changed.desc())).all() 37 | 38 | groups = defaultdict(defaultdict) 39 | for group, cve, pkgs, advisories in entries: 40 | group_entry = groups.setdefault(group.id, {}) 41 | group_entry['group'] = group 42 | group_entry['pkgs'] = list(set(pkgs.split(' '))) 43 | group_entry['advisories'] = advisories.split(' ') if advisories else [] 44 | group_entry.setdefault('issues', []).append(cve) 45 | 46 | for key, group in groups.items(): 47 | group['issues'] = sorted(group['issues'], key=lambda item: item.id, reverse=True) 48 | 49 | groups = groups.values() 50 | groups = sorted(groups, key=lambda item: item['group'].changed, reverse=True) 51 | groups = sorted(groups, key=lambda item: item['group'].severity) 52 | groups = sorted(groups, key=lambda item: item['group'].status) 53 | return groups 54 | 55 | 56 | @tracker.route('/', defaults={'only_vulnerable': True}, methods=['GET']) 57 | def index(only_vulnerable=True): 58 | groups = get_index_data(only_vulnerable) 59 | return render_template('index.html', 60 | title='Issues' if not only_vulnerable else 'Vulnerable issues', 61 | entries=groups, 62 | only_vulnerable=only_vulnerable) 63 | 64 | 65 | @tracker.route('/issues', methods=['GET']) 66 | @tracker.route('/issues/open', methods=['GET']) 67 | @tracker.route('/issues/vulnerable', methods=['GET']) 68 | def index_vulnerable(): 69 | return index(only_vulnerable=True) 70 | 71 | 72 | @tracker.route('/all', methods=['GET']) 73 | @tracker.route('/issues/all', methods=['GET']) 74 | def index_all(): 75 | return index(only_vulnerable=False) 76 | 77 | 78 | # TODO: temporarily keep /json this way until tools adopted new endpoint 79 | @tracker.route('/json', defaults={'only_vulnerable': False}, methods=['GET']) 80 | @tracker.route('/all.json', defaults={'only_vulnerable': False}, methods=['GET']) 81 | @tracker.route('/issues.json', defaults={'only_vulnerable': False}, methods=['GET']) 82 | @tracker.route('/issues/all.json', defaults={'only_vulnerable': False}, methods=['GET']) 83 | @json_response 84 | def index_json(only_vulnerable=False): 85 | entries = get_index_data(only_vulnerable) 86 | json_data = [] 87 | for entry in entries: 88 | group = entry['group'] 89 | types = list(set([cve.issue_type for cve in entry['issues']])) 90 | 91 | json_entry = OrderedDict() 92 | json_entry['name'] = group.name 93 | json_entry['packages'] = entry['pkgs'] 94 | json_entry['status'] = group.status.label 95 | json_entry['severity'] = group.severity.label 96 | json_entry['type'] = 'multiple issues' if len(types) > 1 else types[0] 97 | json_entry['affected'] = group.affected 98 | json_entry['fixed'] = group.fixed if group.fixed else None 99 | json_entry['ticket'] = group.bug_ticket if group.bug_ticket else None 100 | json_entry['issues'] = [str(cve) for cve in entry['issues']] 101 | json_entry['advisories'] = entry['advisories'] 102 | json_data.append(json_entry) 103 | return json_data 104 | 105 | 106 | @tracker.route('/issues.json', methods=['GET']) 107 | @tracker.route('/issues/open.json', methods=['GET']) 108 | @tracker.route('/issues/vulnerable.json', methods=['GET']) 109 | def index_vulnerable_json(): 110 | return index_json(only_vulnerable=True) 111 | -------------------------------------------------------------------------------- /tracker/view/login.py: -------------------------------------------------------------------------------- 1 | from authlib.integrations.base_client.errors import AuthlibBaseError 2 | from flask import redirect 3 | from flask import render_template 4 | from flask import request 5 | from flask import url_for 6 | from flask_login import current_user 7 | from flask_login import login_user 8 | from flask_login import logout_user 9 | from werkzeug.exceptions import Unauthorized 10 | 11 | from config import SSO_ENABLED 12 | from config import TRACKER_PASSWORD_LENGTH_MAX 13 | from config import TRACKER_PASSWORD_LENGTH_MIN 14 | from tracker import db 15 | from tracker import oauth 16 | from tracker import tracker 17 | from tracker.form import LoginForm 18 | from tracker.model.user import User 19 | from tracker.user import get_user_role_from_idp_groups 20 | from tracker.user import hash_password 21 | from tracker.user import random_string 22 | from tracker.user import user_assign_new_token 23 | from tracker.user import user_invalidate 24 | from tracker.util import add_params_to_uri 25 | from tracker.view.error import bad_request 26 | from tracker.view.error import forbidden 27 | 28 | LOGIN_ERROR_EMAIL_ASSOCIATED_WITH_DIFFERENT_SUB = "Your email address is associated with a different sub" 29 | LOGIN_ERROR_USERNAME_ASSOCIATE_WITH_DIFFERENT_EMAIL = "Your username is associated with a different email address" 30 | LOGIN_ERROR_EMAIL_ASSOCIATED_WITH_DIFFERENT_USERNAME = "Your email address is associated with a different username" 31 | LOGIN_ERROR_EMAIL_ADDRESS_NOT_VERIFIED = "Current email address is not verified" 32 | LOGIN_ERROR_PERMISSION_DENIED = "Not allowed to sign in" 33 | LOGIN_ERROR_MISSING_USER_SUB_FROM_TOKEN = "Missing user sub from token" 34 | LOGIN_ERROR_MISSING_EMAIL_FROM_TOKEN = "Missing email address from token" 35 | LOGIN_ERROR_MISSING_USERNAME_FROM_TOKEN = "Missing username from token" 36 | LOGIN_ERROR_MISSING_GROUPS_FROM_TOKEN = "Missing groups from token" 37 | LOGIN_ERROR_MISSING_USERINFO_FROM_TOKEN = "Missing userinfo from token" 38 | 39 | 40 | @tracker.route('/login', methods=['GET', 'POST']) 41 | def login(): 42 | if current_user.is_authenticated: 43 | return redirect(url_for('tracker.index')) 44 | 45 | if SSO_ENABLED: 46 | # detect if we are being redirected 47 | args = request.args 48 | if args.get('state') and args.get('code'): 49 | return sso_auth() 50 | 51 | redirect_url = url_for('tracker.login', _external=True) 52 | return oauth.idp.authorize_redirect(redirect_url) 53 | 54 | form = LoginForm() 55 | if not form.validate_on_submit(): 56 | status_code = Unauthorized.code if form.is_submitted() else 200 57 | return render_template('login.html', 58 | title='Login', 59 | form=form, 60 | User=User, 61 | password_length={'min': TRACKER_PASSWORD_LENGTH_MIN, 62 | 'max': TRACKER_PASSWORD_LENGTH_MAX}), status_code 63 | 64 | user = user_assign_new_token(form.user) 65 | user.is_authenticated = True 66 | login_user(user) 67 | return redirect(url_for('tracker.index')) 68 | 69 | 70 | @tracker.route('/logout', methods=['GET', 'POST']) 71 | def logout(): 72 | if not current_user.is_authenticated: 73 | return redirect(url_for('tracker.index')) 74 | 75 | user_invalidate(current_user) 76 | logout_user() 77 | 78 | if SSO_ENABLED: 79 | metadata = oauth.idp.load_server_metadata() 80 | end_session_endpoint = metadata.get('end_session_endpoint') 81 | params = {'redirect_uri': url_for('tracker.index', _external=True)} 82 | return redirect(add_params_to_uri(end_session_endpoint, params)) 83 | 84 | return redirect(url_for('tracker.index')) 85 | 86 | 87 | def sso_auth(): 88 | try: 89 | token = oauth.idp.authorize_access_token() 90 | except AuthlibBaseError as e: 91 | return bad_request(f'{e.description}') 92 | 93 | userinfo = token.get('userinfo') 94 | if not userinfo: 95 | return bad_request(LOGIN_ERROR_MISSING_USERINFO_FROM_TOKEN) 96 | 97 | idp_user_sub = userinfo.get('sub') 98 | if not idp_user_sub: 99 | return bad_request(LOGIN_ERROR_MISSING_USER_SUB_FROM_TOKEN) 100 | 101 | idp_email_verified = userinfo.get('email_verified') 102 | if not idp_email_verified: 103 | return forbidden(LOGIN_ERROR_EMAIL_ADDRESS_NOT_VERIFIED) 104 | 105 | idp_email = userinfo.get('email') 106 | if not idp_email: 107 | return bad_request(LOGIN_ERROR_MISSING_EMAIL_FROM_TOKEN) 108 | 109 | idp_username = userinfo.get('preferred_username') 110 | if not idp_username: 111 | return bad_request(LOGIN_ERROR_MISSING_USERNAME_FROM_TOKEN) 112 | 113 | idp_groups = userinfo.get('groups') 114 | if idp_groups is None: 115 | return bad_request(LOGIN_ERROR_MISSING_GROUPS_FROM_TOKEN) 116 | 117 | user_role = get_user_role_from_idp_groups(idp_groups) 118 | if not user_role: 119 | return forbidden(LOGIN_ERROR_PERMISSION_DENIED) 120 | 121 | # get local user from current authenticated idp id 122 | user = db.get(User, idp_id=idp_user_sub) 123 | 124 | if not user: 125 | # get local user from idp email address 126 | user = db.get(User, email=idp_email) 127 | if user: 128 | # prevent impersonation by checking whether this email is associated with an idp id 129 | if user.idp_id: 130 | return forbidden(LOGIN_ERROR_EMAIL_ASSOCIATED_WITH_DIFFERENT_SUB) 131 | # email is already associated with a different username 132 | if user.name != idp_username: 133 | return forbidden(LOGIN_ERROR_EMAIL_ASSOCIATED_WITH_DIFFERENT_USERNAME) 134 | # prevent integrity error for mismatching mail between db and keycloak 135 | check_user = db.get(User, name=idp_username) 136 | if check_user and check_user.email != idp_email: 137 | return forbidden(LOGIN_ERROR_USERNAME_ASSOCIATE_WITH_DIFFERENT_EMAIL) 138 | 139 | if user: 140 | user.role = user_role 141 | user.email = idp_email 142 | else: 143 | salt = random_string() 144 | user = db.create(User, 145 | name=idp_username, 146 | email=idp_email, 147 | salt=salt, 148 | password=hash_password(random_string(TRACKER_PASSWORD_LENGTH_MAX), salt), 149 | role=user_role, 150 | active=True, 151 | idp_id=idp_user_sub) 152 | db.session.add(user) 153 | 154 | db.session.commit() 155 | user = user_assign_new_token(user) 156 | user.is_authenticated = True 157 | login_user(user) 158 | 159 | return redirect(url_for('tracker.index')) 160 | -------------------------------------------------------------------------------- /tracker/view/user.py: -------------------------------------------------------------------------------- 1 | from flask import flash 2 | from flask import redirect 3 | from flask import render_template 4 | from flask_login import current_user 5 | from flask_login import login_required 6 | from sqlalchemy_continuum import version_class 7 | from sqlalchemy_continuum import versioning_manager 8 | 9 | from config import TRACKER_PASSWORD_LENGTH_MAX 10 | from config import TRACKER_PASSWORD_LENGTH_MIN 11 | from tracker import db 12 | from tracker import tracker 13 | from tracker.form.user import UserPasswordForm 14 | from tracker.model import CVE 15 | from tracker.model import Advisory 16 | from tracker.model import CVEGroup 17 | from tracker.model import User 18 | from tracker.user import hash_password 19 | from tracker.user import only_without_sso 20 | from tracker.user import random_string 21 | 22 | 23 | @tracker.route('/profile', methods=['GET', 'POST']) 24 | @only_without_sso 25 | @login_required 26 | def edit_own_user_profile(): 27 | form = UserPasswordForm() 28 | if not form.validate_on_submit(): 29 | return render_template('form/profile.html', 30 | title='Edit profile', 31 | form=form, 32 | password_length={'min': TRACKER_PASSWORD_LENGTH_MIN, 33 | 'max': TRACKER_PASSWORD_LENGTH_MAX}) 34 | 35 | user = current_user 36 | user.salt = random_string() 37 | user.password = hash_password(form.password.data, user.salt) 38 | db.session.commit() 39 | 40 | flash('Profile saved') 41 | return redirect('/') 42 | 43 | 44 | # TODO: define permission to view this 45 | @tracker.route('/user//log', defaults={'page': 1}, methods=['GET']) 46 | @tracker.route('/user//log/page/', methods=['GET']) 47 | @login_required 48 | def show_user_log(username, page=1): 49 | MAX_ENTRIES_PER_PAGE = 10 50 | Transaction = versioning_manager.transaction_cls 51 | VersionClassCVE = version_class(CVE) 52 | VersionClassGroup = version_class(CVEGroup) 53 | VersionClassAdvisory = version_class(Advisory) 54 | 55 | pagination = (db.session.query(Transaction, VersionClassCVE, VersionClassGroup, VersionClassAdvisory) 56 | .outerjoin(VersionClassCVE, Transaction.id == VersionClassCVE.transaction_id) 57 | .outerjoin(VersionClassGroup, Transaction.id == VersionClassGroup.transaction_id) 58 | .outerjoin(VersionClassAdvisory, Transaction.id == VersionClassAdvisory.transaction_id) 59 | .join(User) 60 | .filter(User.name == username) 61 | .order_by(Transaction.issued_at.desc()) 62 | ).paginate(page, MAX_ENTRIES_PER_PAGE, True) 63 | 64 | return render_template('log/log.html', 65 | title=f'User {username} - log', 66 | username=username, 67 | pagination=pagination, 68 | CVE=CVE, 69 | CVEGroup=CVEGroup) 70 | -------------------------------------------------------------------------------- /trackerctl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from tracker.cli import cli 3 | 4 | if __name__ == '__main__': 5 | cli.main() 6 | --------------------------------------------------------------------------------