├── .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 [](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 |
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 | {{ line[:1] }} |
114 | {{ line[2:] }} |
115 |
116 | {%- endif %}
117 | {%- endfor %}
118 |
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 |
34 | {%- endblock %}
35 |
--------------------------------------------------------------------------------
/tracker/templates/admin/form/user.html:
--------------------------------------------------------------------------------
1 | {%- extends "base.html" -%}
2 | {% block content %}
3 | {{ title }}
4 |