diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b80bd683..89eddf38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: "Mark previous prerelease" continue-on-error: true run: |- - export tag="$(gh release view --json tagName -t '{{.tagName}}')" + tag="$(gh release view --json tagName -t '{{.tagName}}')" # skip if the new tag has a different minor version [[ ${GITHUB_REF_NAME%.*} == ${tag%.*} ]] || exit 0 gh release edit "$tag" --prerelease @@ -49,8 +49,8 @@ jobs: username: "GitHub", avatar_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png", embeds: [{author: {$name, $icon_url, url: $author_url}, $url, $title, $description}] - }' | curl -d@- -Ssf "$RELEASE_WEBHOOK" -H 'Content-Type: application/json' \ - -A "GitHub-Actions ($GITHUB_REPOSITORY, v0.1.1)" + }' | curl "$RELEASE_WEBHOOK" -H 'Content-Type: application/json' \ + -Ssf -d@- -A "GitHub-Actions ($GITHUB_REPOSITORY, v0.1.1)" env: RELEASE_WEBHOOK: ${{secrets.RELEASE_WEBHOOK}} diff --git a/.github/workflows/scans.yml b/.github/workflows/scans.yml index d77601d5..d28c4700 100644 --- a/.github/workflows/scans.yml +++ b/.github/workflows/scans.yml @@ -19,17 +19,30 @@ jobs: - name: "Initialize CodeQL" uses: github/codeql-action/init@v2 with: - languages: javascript, python - - name: "Perform CodeQL Analysis" + languages: javascript,python + - name: "Perform CodeQL analysis" uses: github/codeql-action/analyze@v2 + bandit: + name: "Bandit" + runs-on: ubuntu-latest + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + - name: "Install bandit" + run: pip install bandit[toml] bandit-sarif-formatter + - name: "Run bandit" + run: bandit -c pyproject.toml -f sarif -o bandit.sarif -r . + - name: "Upload bandit analysis" + uses: github/codeql-action/upload-sarif@v2 + with: + category: bandit + sarif_file: bandit.sarif + dependency-review: name: "Dependencies" runs-on: ubuntu-latest if: github.event_name == 'pull_request' - permissions: - contents: read - pull-requests: write steps: - name: "Checkout repository" uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 869b333a..de66c4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,4 @@ static/styles/_variables.scss # GitHub Stuff docs/changes.md +*.sarif diff --git a/config/management/commands/fs2import.py b/config/management/commands/fs2import.py index 48e77bd4..adc9864e 100644 --- a/config/management/commands/fs2import.py +++ b/config/management/commands/fs2import.py @@ -4,21 +4,20 @@ from io import StringIO from os.path import abspath, join -from typing import TYPE_CHECKING -from xml.etree import ElementTree as ET +from typing import TYPE_CHECKING, List from django.core.files import File from django.core.management import BaseCommand, CommandError, call_command from django.db.utils import IntegrityError +from defusedxml import ElementTree as ET + from groups.models import Group from reader.models import Chapter, Page, Series if TYPE_CHECKING: # pragma: no cover from argparse import ArgumentParser - from typing import List - Elem = ET.Element - Elems = List[ET.Element] + from xml.etree.ElementTree import Element # nosec: B405 class Command(BaseCommand): @@ -183,18 +182,18 @@ def handle(self, *args: str, **options: str): self._print_success('Successfully imported FoolSlide2 data.') @staticmethod - def _get_element(tables: Elems, name: str) -> Elems: + def _get_element(tables: List[Element], name: str) -> List[Element]: return list(filter( lambda t: t.attrib['name'].endswith(name), tables )) @staticmethod - def _get_column(table: Elem, name: str) -> str: + def _get_column(table: Element, name: str) -> str: elem = table.find(f'column[@name="{name}"]') return getattr(elem, 'text', None) or '' @staticmethod - def _sort_children(tables: Elems, name: str) -> Elems: + def _sort_children(tables: List[Element], name: str) -> List[Element]: return sorted(tables, key=lambda p: Command._get_column(p, name)) def _print(self, text: str, **kwargs): diff --git a/config/templatetags/custom_tags.py b/config/templatetags/custom_tags.py index a4939af8..13675db1 100644 --- a/config/templatetags/custom_tags.py +++ b/config/templatetags/custom_tags.py @@ -39,11 +39,12 @@ def jsonld(value: Dict, element_id: str) -> str: .. seealso:: :tag:`json_script template tag ` """ sep = (',', ':') + # Escape special HTML characters for JSON output escapes = {ord('>'): '\\u003E', ord('<'): '\\u003C', ord('&'): '\\u0026'} jstr = dumps(value, cls=DjangoJSONEncoder, indent=None, separators=sep) return format_html( '', - element_id, mark_safe(jstr.translate(escapes)) + element_id, mark_safe(jstr.translate(escapes)) # nosec: B308 ) @@ -72,7 +73,11 @@ def get_type(link: str) -> str: if (key := 'type.' + basename(link.lower())) in cache: return cache.get(key) # pragma: no cover try: - with urlopen(Request(link, method='HEAD')) as response: + # Disallow non-HTTP(S) schemes + if not link.startswith(('http://', 'https://')): + raise Exception('Invalid scheme') + request = Request(link, method='HEAD') + with urlopen(request) as response: # nosec: B310 type_ = response.info().get_content_type() cache.add(key, type_) return type_ diff --git a/config/tests/test_tags.py b/config/tests/test_tags.py index 65799258..f084e02e 100644 --- a/config/tests/test_tags.py +++ b/config/tests/test_tags.py @@ -28,8 +28,8 @@ def test_jsonld(): element_id = 'whatever' tag = jsonld(value, element_id) assert tag.startswith(f'' - body = match(pattern, tag).group(1) # lgtm[py/bad-tag-filter] + pattern = r'(.*?)' # lgtm[py/bad-tag-filter] + body = match(pattern, tag).group(1) assert '<' not in body assert '>' not in body assert '&' not in body diff --git a/pyproject.toml b/pyproject.toml index 35039236..feebd085 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ # https://github.com/PyCQA/flake8/issues/234 "flake8-pyproject>=1.2", "isort>=5.12", + "bandit~=1.7", "mypy~=1.6", "pytest~=7.4", "pytest-cov>=4.1", @@ -146,6 +147,25 @@ ignore_missing_imports = true disable_error_code = ["misc", "override"] plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] +[tool.bandit] +exclude_dirs = [ + "./.git/*", + "./.venv/*", + "./.eggs/*", + "./.mypy_cache/*", + "./__pycache__/*", + "./docs/*", + "./build/*", + "./dist/*", + "./*/tests/*" +] +skips = [ + "B104", # 0.0.0.0 is only used in debug mode + "B301", # pickled data is verified with HMAC + "B403", # pickled data is verified with HMAC + "B703", # covered by B308 (mark_safe) +] + [tool.django-stubs] django_settings_module = "MangAdventure.settings" diff --git a/reader/admin.py b/reader/admin.py index 2f608fec..4aa2ca76 100644 --- a/reader/admin.py +++ b/reader/admin.py @@ -248,7 +248,7 @@ def get_form(self, request: HttpRequest, obj: Optional[Series] '{number}: The number of the chapter.', '{date}: The chapter\'s upload date (YYYY-MM-DD).', '{series}: The title of the series.' - ))) + ))) # nosec: B308 if 'manager' in form.base_fields: form.base_fields['manager'].initial = request.user.id if request.user.is_superuser: # pragma: no cover diff --git a/reader/models.py b/reader/models.py index 8aea3ab2..f4b8d8b5 100644 --- a/reader/models.py +++ b/reader/models.py @@ -18,7 +18,7 @@ from shutil import rmtree from threading import Lock, Thread from typing import Any, List, Tuple, Union -from xml.etree import ElementTree as ET +from xml.etree import ElementTree as ET # nosec: B405 from zipfile import ZipFile from django.conf import settings