├── tests ├── __init__.py └── test_main.py ├── inspector ├── __init__.py ├── analysis │ ├── __init__.py │ ├── entropy.py │ ├── checks.py │ └── codedetails.py ├── templates │ ├── index.html │ ├── 404.html │ ├── links.html │ ├── code.html │ ├── releases.html │ ├── base.html │ └── disasm.html ├── errors.py ├── static │ ├── style.css │ └── prism.css ├── deob.py ├── utilities.py ├── distribution.py ├── legacy.py └── main.py ├── .python-version ├── .gitignore ├── Procfile ├── dev └── environment ├── requirements ├── deploy.in ├── tests.in ├── lint.in ├── main.in ├── deploy.txt ├── tests.txt ├── lint.txt └── main.txt ├── pyproject.toml ├── requirements.txt ├── bin ├── tests ├── lint └── reformat ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── docker-compose.yml ├── gunicorn.conf ├── .gitpod.yml ├── README.md ├── Makefile ├── Dockerfile └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inspector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.8 2 | -------------------------------------------------------------------------------- /inspector/analysis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .state/ 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn -c gunicorn.conf inspector.main:app 2 | -------------------------------------------------------------------------------- /inspector/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | -------------------------------------------------------------------------------- /dev/environment: -------------------------------------------------------------------------------- 1 | FLASK_APP=inspector.main:app 2 | DEVEL=yes 3 | SESSION_SECRET=an insecure development secret 4 | -------------------------------------------------------------------------------- /inspector/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block body %} 4 |

NOT FOUND

5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /inspector/errors.py: -------------------------------------------------------------------------------- 1 | class InspectorError(Exception): 2 | pass 3 | 4 | 5 | class BadFileError(InspectorError): 6 | pass 7 | -------------------------------------------------------------------------------- /requirements/deploy.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S pip-compile --allow-unsafe --generate-hashes --output-file=requirements/deploy.txt 2 | 3 | gunicorn 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = 'black' 3 | lines_between_types = 1 4 | combine_as_imports = true 5 | known_first_party = ['inspector'] 6 | -------------------------------------------------------------------------------- /requirements/tests.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S pip-compile --allow-unsafe --generate-hashes --output-file=requirements/tests.txt 2 | 3 | pretend 4 | pytest 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This is just for Dependabot 2 | -r requirements/main.txt 3 | -r requirements/deploy.txt 4 | -r requirements/lint.txt 5 | -r requirements/tests.txt 6 | -------------------------------------------------------------------------------- /requirements/lint.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S pip-compile --allow-unsafe --generate-hashes --output-file=requirements/lint.txt 2 | 3 | black 4 | curlylint 5 | flake8 6 | isort 7 | -------------------------------------------------------------------------------- /bin/tests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | COMMAND_ARGS="$@" 5 | 6 | # Print all the following commands 7 | set -x 8 | 9 | # Actually run our tests. 10 | python -m pytest $COMMAND_ARGS 11 | -------------------------------------------------------------------------------- /requirements/main.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S pip-compile --allow-unsafe --generate-hashes --output-file=requirements/main.txt 2 | 3 | Flask 4 | gunicorn 5 | requests 6 | packaging 7 | sentry-sdk[flask] 8 | -------------------------------------------------------------------------------- /inspector/templates/links.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block body %} 4 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /bin/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Print all the following commands 5 | set -x 6 | 7 | # Actually run our tests. 8 | python -m flake8 --max-line-length 88 . 9 | python -m black --check inspector/ tests/ 10 | python -m isort --check inspector/ tests/ 11 | #python -m curlylint ./inspector/templates 12 | -------------------------------------------------------------------------------- /inspector/analysis/entropy.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | from math import log2 4 | 5 | 6 | def shannon_entropy(X: bytes): 7 | result = 0.0 8 | 9 | items = collections.Counter(X).items() 10 | for b, count in items: 11 | pr = count / len(X) 12 | result -= pr * log2(pr) 13 | 14 | return result 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | #db: 3 | # image: postgres:10.1 4 | web: 5 | build: 6 | context: . 7 | args: 8 | DEVEL: "yes" 9 | command: gunicorn --reload -b 0.0.0.0:8080 inspector.main:app 10 | env_file: dev/environment 11 | volumes: 12 | - .:/app/ 13 | ports: 14 | - "80:8080" 15 | #links: 16 | # - db 17 | -------------------------------------------------------------------------------- /gunicorn.conf: -------------------------------------------------------------------------------- 1 | bind = 'unix:/var/run/cabotage/cabotage.sock' 2 | backlog = 2048 3 | 4 | worker_connections = 1000 5 | timeout = 10 6 | keepalive = 2 7 | 8 | errorlog = '-' 9 | loglevel = 'info' 10 | accesslog = '-' 11 | access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' 12 | 13 | def when_ready(server): 14 | open('/tmp/app-initialized', 'w').close() 15 | -------------------------------------------------------------------------------- /bin/reformat: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Click requires us to ensure we have a well configured environment to run 5 | # our click commands. So we'll set our environment to ensure our locale is 6 | # correct. 7 | export LC_ALL="${ENCODING:-en_US.UTF-8}" 8 | export LANG="${ENCODING:-en_US.UTF-8}" 9 | 10 | # Print all the following commands 11 | set -x 12 | 13 | python -m isort inspector/ tests/ 14 | python -m black inspector/ tests/ 15 | -------------------------------------------------------------------------------- /inspector/static/style.css: -------------------------------------------------------------------------------- 1 | 2 | .analysis-summary { 3 | border: 1px solid black; 4 | float: right; 5 | width: 40%; 6 | min-width: 630px; 7 | } 8 | 9 | .header-group { 10 | display: inline-block; 11 | } 12 | 13 | .report-anchor { 14 | display: inline; 15 | color: red; 16 | } 17 | 18 | table, th, td { 19 | border: 1px solid black; 20 | border-collapse: collapse; 21 | padding: 4px; 22 | } 23 | 24 | .no-entries { 25 | color: grey; 26 | } 27 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart 6 | 7 | tasks: 8 | - init: | 9 | pyenv install --skip-existing 10 | pip install -r requirements.txt 11 | - command: flask --app inspector.main:app run --debug 12 | 13 | ports: 14 | - port: 5000 15 | onOpen: open-preview 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inspector 2 | 3 | https://inspector.pypi.io provides a mechanism for security researchers to 4 | quickly share specific contents of a project hosted on PyPI when submitting 5 | security reports. 6 | 7 | Inspector is not currently supported for any other purpose. 8 | 9 | 10 | ## Develop 11 | 12 | You can use any old way to install & run `inspector` locally. 13 | 14 | We support `docker compose` via `make serve`. 15 | 16 | You may also launch a Gitpod Workspace, which should set up most things for you: 17 | 18 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/pypi/inspector) 19 | -------------------------------------------------------------------------------- /requirements/deploy.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile --allow-unsafe --generate-hashes --output-file=requirements/deploy.txt requirements/deploy.in 6 | # 7 | gunicorn==22.0.0 \ 8 | --hash=sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9 \ 9 | --hash=sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63 10 | # via -r deploy.in 11 | packaging==23.1 \ 12 | --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ 13 | --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f 14 | # via gunicorn 15 | -------------------------------------------------------------------------------- /inspector/templates/code.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block head %} 4 | 5 | 6 | {% endblock %} 7 | 8 | {% block above_body %} 9 |
10 | Summary 11 | 16 |
17 | {% endblock %} 18 | 19 | {% block body %} 20 | Report Malicious Package 21 |
22 | {# Indenting the below  tag will cause rendering issues! #}
23 | {{- code }}
24 | 
25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | test: 9 | strategy: 10 | matrix: 11 | include: 12 | - name: Tests 13 | command: bin/tests 14 | - name: Lint 15 | command: bin/lint 16 | runs-on: ubuntu-latest 17 | name: ${{ matrix.name }} 18 | steps: 19 | - name: Check out repository 20 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 21 | - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 22 | with: 23 | python-version-file: '.python-version' 24 | cache: 'pip' 25 | cache-dependency-path: | 26 | requirements.txt 27 | requirements/*.txt 28 | - name: Install Python dependencies 29 | run: | 30 | pip install -U pip setuptools wheel 31 | pip install -r requirements.txt --no-deps 32 | - name: Run ${{ matrix.name }} 33 | run: ${{ matrix.command }} 34 | -------------------------------------------------------------------------------- /inspector/templates/releases.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block head %} 4 | 5 | {% endblock %} 6 | 7 | {% block body %} 8 | {% if releases|length != 1 %} 9 |

Retrieved {{ releases|length }} versions.

10 | {% else %} 11 |

Retrieved 1 version.

12 | {% endif %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% for key, value in releases.items() %} 28 | 29 | {% if value|length > 0 %} 30 | 31 | 32 | 33 | {% else %} 34 | 35 | 36 | 37 | {% endif %} 38 | 39 | {% endfor %} 40 | 41 | 42 | 43 |
VersionUpload TimestampArtifacts
{{ key }}{{ value[0]['upload_time'] }}{{ value|length }}{{ key }}Not AvailableNot Available
44 | 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .state/docker-build: Dockerfile requirements/main.txt requirements/deploy.txt 2 | # Build our docker containers for this project. 3 | docker compose build web 4 | 5 | # Mark the state so we don't rebuild this needlessly. 6 | mkdir -p .state 7 | touch .state/docker-build 8 | 9 | serve: .state/docker-build 10 | docker compose up --remove-orphans 11 | 12 | #wipedb: 13 | # docker compose run --rm web psql -h db -d postgres -U postgres -c "DROP DATABASE IF EXISTS inspector" 14 | # docker compose run --rm web psql -h db -d postgres -U postgres -c "CREATE DATABASE inspector ENCODING 'UTF8'" 15 | # 16 | #initdb: wipedb upgradedb 17 | # 18 | #migratedb: 19 | # docker compose run --rm web flask db migrate --message "$(MESSAGE)" 20 | # 21 | #upgradedb: 22 | # docker compose run --rm web flask db upgrade 23 | 24 | reformat: 25 | docker compose run --rm web bin/reformat $(T) $(TESTARGS) 26 | 27 | tests: .state/docker-build 28 | docker compose run --rm web bin/tests $(T) $(TESTARGS) 29 | 30 | 31 | lint: .state/docker-build 32 | docker compose run --rm web bin/lint $(T) $(TESTARGS) 33 | 34 | stop: 35 | docker compose down -v 36 | 37 | .PHONY: default serve tests lint #initdb 38 | -------------------------------------------------------------------------------- /inspector/analysis/checks.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | from typing import Any, Generator 3 | 4 | from inspector.analysis.codedetails import Detail, DetailSeverity 5 | from inspector.analysis.entropy import shannon_entropy 6 | from inspector.distribution import TarGzDistribution, ZipDistribution 7 | 8 | 9 | def __is_compiled(filepath: str) -> bool: 10 | return filepath.endswith(".pyc") or filepath.endswith(".pyo") 11 | 12 | 13 | def basic_details( 14 | distribution: TarGzDistribution | ZipDistribution, filepath: str 15 | ) -> Generator[Detail, Any, None]: 16 | contents = distribution.contents(filepath) 17 | 18 | yield Detail( 19 | severity=DetailSeverity.NORMAL, 20 | prop_name="SHA-256", 21 | value=sha256(contents).hexdigest(), 22 | ) 23 | 24 | entropy = shannon_entropy(contents) 25 | ent_suspicious = entropy > 6.0 26 | yield Detail( 27 | severity=DetailSeverity.HIGH if ent_suspicious else DetailSeverity.NORMAL, 28 | prop_name="Entropy", 29 | value=str(entropy) + " (HIGH)" if ent_suspicious else str(entropy), 30 | ) 31 | 32 | if __is_compiled(filepath): 33 | yield Detail( 34 | severity=DetailSeverity.MEDIUM, prop_name="Compiled Python Bytecode" 35 | ) 36 | -------------------------------------------------------------------------------- /inspector/deob.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains functions for decompiling and disassembling files. 3 | """ 4 | 5 | import subprocess 6 | import tempfile 7 | 8 | DISASM_HEADER = "This file was disassembled from bytecode by Inspector using pycdas." 9 | DECOMPILE_HEADER = ( 10 | '"""\n' 11 | "This file was decompiled from bytecode by Inspector using pycdc.\n" 12 | "The code below may be incomplete or syntactically incorrect.\n" 13 | '"""\n\n' 14 | ) 15 | 16 | 17 | def decompile(code: bytes) -> str: 18 | """ 19 | Decompile bytecode using pycdc. 20 | """ 21 | 22 | with tempfile.NamedTemporaryFile() as file: 23 | file.write(code) 24 | output = subprocess.Popen( 25 | ["pycdc", file.name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT 26 | ) 27 | 28 | output = b"".join([line for line in output.stdout.readlines()]).decode() 29 | 30 | return DECOMPILE_HEADER + output 31 | 32 | 33 | def disassemble(code: bytes) -> str: 34 | with tempfile.NamedTemporaryFile() as file: 35 | file.write(code) 36 | output = subprocess.Popen( 37 | ["pycdas", file.name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT 38 | ) 39 | 40 | disassembly = b"".join([line for line in output.stdout.readlines()]).decode() 41 | 42 | return DISASM_HEADER + "\n\n" + disassembly 43 | -------------------------------------------------------------------------------- /inspector/analysis/codedetails.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | 4 | import jinja2.filters 5 | 6 | 7 | class DetailSeverity(Enum): 8 | NORMAL = 1 9 | MEDIUM = 2 10 | HIGH = 3 11 | 12 | 13 | @dataclass 14 | class Detail: 15 | severity: DetailSeverity 16 | prop_name: str 17 | value: str | None = None 18 | unsafe: bool = False 19 | 20 | def html(self): 21 | match self.severity: 22 | case DetailSeverity.MEDIUM: 23 | color = "orange" 24 | case DetailSeverity.HIGH: 25 | color = "red" 26 | case _: 27 | color = "#000000" 28 | 29 | # just to be safe here, sanitize the property name and value... 30 | propname_sanitized = ( 31 | self.prop_name if self.unsafe else jinja2.filters.escape(self.prop_name) 32 | ) 33 | 34 | if self.value: 35 | value_sanitized = ( 36 | self.value if self.unsafe else jinja2.filters.escape(self.value) 37 | ) 38 | return ( 39 | f"{propname_sanitized}: " 40 | f"{value_sanitized}" 41 | ) 42 | 43 | return ( 44 | f"" 45 | f"{propname_sanitized}" 46 | f"" 47 | ) 48 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile --allow-unsafe --generate-hashes --output-file=requirements/tests.txt ./requirements/tests.in 6 | # 7 | exceptiongroup==1.1.0 \ 8 | --hash=sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e \ 9 | --hash=sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23 10 | # via pytest 11 | iniconfig==1.1.1 \ 12 | --hash=sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3 \ 13 | --hash=sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32 14 | # via pytest 15 | packaging==23.1 \ 16 | --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ 17 | --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f 18 | # via pytest 19 | pluggy==1.5.0 \ 20 | --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ 21 | --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 22 | # via pytest 23 | pretend==1.0.9 \ 24 | --hash=sha256:c90eb810cde8ebb06dafcb8796f9a95228ce796531bc806e794c2f4649aa1b10 \ 25 | --hash=sha256:e389b12b7073604be67845dbe32bf8297360ad9a609b24846fe15d86e0b7dc01 26 | # via -r requirements/tests.in 27 | pytest==8.3.4 \ 28 | --hash=sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6 \ 29 | --hash=sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761 30 | # via -r requirements/tests.in 31 | tomli==2.0.1 \ 32 | --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ 33 | --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f 34 | # via pytest 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official lightweight Python image. 2 | # https://hub.docker.com/_/python 3 | FROM python:3.11.8-slim-bullseye 4 | 5 | # Allow statements and log messages to immediately appear in the logs 6 | ENV PYTHONUNBUFFERED True 7 | 8 | # Don't create .pyc files 9 | ENV PYTHONDONTWRITEBYTECODE True 10 | 11 | # Put our application on the PYTHONPATH 12 | ENV PYTHONPATH /app 13 | 14 | # Define whether we're building a production or a development image. This will 15 | # generally be used to control whether or not we install our development and 16 | # test dependencies. 17 | ARG DEVEL=no 18 | 19 | # Install System level requirements, this is done before everything else 20 | # because these are rarely ever going to change. 21 | RUN set -x \ 22 | && apt-get update \ 23 | && apt-get install -y \ 24 | git cmake g++ \ 25 | # $(if [ "$DEVEL" = "yes" ]; then echo 'bash postgresql-client'; fi) \ 26 | && apt-get clean \ 27 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 28 | 29 | # Install pycdc and pycdas... 30 | WORKDIR /tmp 31 | RUN git clone "https://github.com/zrax/pycdc.git" && \ 32 | cd pycdc && \ 33 | cmake . && \ 34 | cmake --build . --config release && \ 35 | mv ./pycdc /usr/local/bin && \ 36 | mv ./pycdas /usr/local/bin && \ 37 | cd .. && rm -rf ./pycdc 38 | 39 | # Copy local code to the container image. 40 | WORKDIR /app 41 | # Copy in requirements files 42 | COPY ./requirements ./requirements 43 | 44 | # Install production dependencies. 45 | RUN pip install \ 46 | -r requirements/main.txt \ 47 | -r requirements/deploy.txt 48 | 49 | # Install development dependencies 50 | RUN if [ "$DEVEL" = "yes" ]; then pip install -r requirements/lint.txt; fi 51 | RUN if [ "$DEVEL" = "yes" ]; then pip install -r requirements/tests.txt; fi 52 | 53 | # Copy in everything else 54 | COPY . . 55 | -------------------------------------------------------------------------------- /inspector/utilities.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import requests 4 | 5 | 6 | def mailto_report_link(project_name, version, file_path, request_url): 7 | """ 8 | Generate a mailto report link for malicious code. 9 | """ 10 | message_body = ( 11 | "PyPI Malicious Package Report\n" 12 | "--\n" 13 | f"Package Name: {project_name}\n" 14 | f"Version: {version}\n" 15 | f"File Path: {file_path}\n" 16 | f"Inspector URL: {request_url}\n\n" 17 | "Additional Information:\n\n" 18 | ) 19 | 20 | subject = f"Malicious Package Report: {project_name}" 21 | 22 | return ( 23 | f"mailto:security@pypi.org?" 24 | f"subject={urllib.parse.quote(subject)}" 25 | f"&body={urllib.parse.quote(message_body)}" 26 | ) 27 | 28 | 29 | def pypi_report_form(project_name, version, file_path, request_url): 30 | """ 31 | Generate a URL to PyPI malware report for malicious code. 32 | """ 33 | summary = ( 34 | f"Version: {version}\n" 35 | f"File Path: {file_path}\n" 36 | "Additional Information:\n\n" 37 | ) 38 | 39 | return ( 40 | f"https://pypi.org/project/{project_name}/submit-malware-report/" 41 | f"?inspector_link={request_url}" 42 | f"&summary={urllib.parse.quote(summary)}" 43 | ) 44 | 45 | 46 | def requests_session(custom_user_agent: str = "inspector.pypi.io") -> requests.Session: 47 | """ 48 | Custom `requests` session with default headers applied. 49 | 50 | Usage: 51 | 52 | >>> from inspector.utilities import requests_session 53 | >>> response = requests_session().get() 54 | """ 55 | session = requests.Session() 56 | session.headers.update( 57 | { 58 | "User-Agent": custom_user_agent, 59 | } 60 | ) 61 | 62 | return session 63 | -------------------------------------------------------------------------------- /inspector/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block head %}{% endblock %} 4 | 5 | 6 | 7 | 8 |
9 |

Inspector

10 |
11 | {% if h2 %} 12 | 13 | {% else %} 14 | 15 | {% endif %} 16 | 17 |
18 |
19 | {% block above_body %}{% endblock %} 20 | {% if h2 %} 21 |

22 | {% if h2_link %} 23 | {{ h2 }} 24 | {% else %} 25 | {{ h2 }} 26 | {% endif %} 27 | {% if h2_paren_link %} 28 | ({{ h2_paren }}) 29 | {% endif %} 30 |

31 | {% endif %} 32 | {% if h3 %} 33 |

34 | {% if h3_link %} 35 | {{ h3 }} 36 | {% else %} 37 | {{ h3 }} 38 | {% endif %} 39 | {% if h3_paren_link %} 40 | ({{ h3_paren }}) 41 | {% endif %} 42 |

43 | {% endif %} 44 | {% if h4 %} 45 |

46 | {% if h4_link %} 47 | {{ h4 }} 48 | {% else %} 49 | {{ h4 }} 50 | {% endif %} 51 |

52 | {% endif %} 53 | {% if h5 %} 54 |
55 | {% if h5_link %} 56 | {{ h5 }} 57 | {% else %} 58 | {{ h5 }} 59 | {% endif %} 60 |
61 | {% endif %} 62 | {% block body %}{% endblock %} 63 |
64 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /inspector/templates/disasm.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block head %} 4 | 5 | 6 | {% endblock %} 7 | 8 | {% block above_body %} 9 |
10 | Summary 11 |
    12 | {% for detail in file_details %} 13 |
  • {{ detail|safe }}
  • 14 | {% endfor %} 15 |
16 |
17 | {% endblock %} 18 | 19 | {% block body %} 20 | Report Malicious Package 21 |
22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | {# Indenting the below  tags will cause rendering issues! #}
33 | {{- disassembly }}
34 | 
35 | 39 | 40 | 59 | 60 | 61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /inspector/distribution.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import tarfile 3 | import zipfile 4 | import zlib 5 | 6 | from io import BytesIO 7 | 8 | import requests 9 | 10 | from flask import abort 11 | 12 | from .errors import BadFileError 13 | from .utilities import requests_session 14 | 15 | # Lightweight datastore ;) 16 | dists = {} 17 | 18 | 19 | class Distribution: 20 | def namelist(self): 21 | raise NotImplementedError 22 | 23 | def read(self): 24 | raise NotImplementedError 25 | 26 | 27 | class ZipDistribution(Distribution): 28 | def __init__(self, f): 29 | f.seek(0) 30 | try: 31 | self.zipfile = zipfile.ZipFile(f) 32 | except zipfile.BadZipFile: 33 | raise BadFileError("Bad zipfile") 34 | 35 | def namelist(self): 36 | return [i.filename for i in self.zipfile.infolist() if not i.is_dir()] 37 | 38 | def contents(self, filepath) -> bytes: 39 | try: 40 | return self.zipfile.read(filepath) 41 | except KeyError: 42 | raise FileNotFoundError 43 | 44 | 45 | class TarGzDistribution(Distribution): 46 | def __init__(self, f): 47 | f.seek(0) 48 | try: 49 | self.tarfile = tarfile.open(fileobj=f, mode="r:gz") 50 | except gzip.BadGzipFile: 51 | raise BadFileError("Bad gzip file") 52 | 53 | def namelist(self): 54 | return [i.name for i in self.tarfile.getmembers() if not i.isdir()] 55 | 56 | def contents(self, filepath): 57 | try: 58 | file_ = self.tarfile.extractfile(filepath) 59 | if file_: 60 | return file_.read() 61 | else: 62 | raise FileNotFoundError 63 | except (KeyError, EOFError): 64 | raise FileNotFoundError 65 | except (tarfile.TarError, zlib.error): 66 | raise BadFileError("Bad tarfile") 67 | 68 | 69 | def _get_dist(first, second, rest, distname): 70 | if distname in dists: 71 | return dists[distname] 72 | 73 | url = f"https://files.pythonhosted.org/packages/{first}/{second}/{rest}/{distname}" 74 | try: 75 | resp = requests_session().get(url, stream=True) 76 | resp.raise_for_status() 77 | except requests.HTTPError as exc: 78 | abort(exc.response.status_code) 79 | 80 | f = BytesIO(resp.content) 81 | 82 | if ( 83 | distname.endswith(".whl") 84 | or distname.endswith(".zip") 85 | or distname.endswith(".egg") 86 | ): 87 | distfile = ZipDistribution(f) 88 | dists[distname] = distfile 89 | return distfile 90 | 91 | elif distname.endswith(".tar.gz"): 92 | distfile = TarGzDistribution(f) 93 | dists[distname] = distfile 94 | return distfile 95 | 96 | else: 97 | # Not supported 98 | return None 99 | -------------------------------------------------------------------------------- /inspector/legacy.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from typing import Iterator, List, Tuple 4 | 5 | from packaging.version import InvalidVersion, Version, _BaseVersion 6 | 7 | 8 | def parse(version: str) -> "Version": 9 | """ 10 | Parse the given version string and return either a :class:`Version` object 11 | or a :class:`LegacyVersion` object depending on if the given version is 12 | a valid PEP 440 version or a legacy version. 13 | Parse the given version string. 14 | Returns a :class:`Version` object, if the given version is a valid PEP 440 version. 15 | Raises :class:`InvalidVersion` otherwise. 16 | """ 17 | try: 18 | return Version(version) 19 | except InvalidVersion: 20 | return LegacyVersion(version) 21 | return Version(version) 22 | 23 | 24 | LegacyCmpKey = Tuple[int, Tuple[str, ...]] 25 | 26 | 27 | class LegacyVersion(_BaseVersion): 28 | def __init__(self, version: str) -> None: 29 | self._version = str(version) 30 | self._key = _legacy_cmpkey(self._version) 31 | 32 | def __str__(self) -> str: 33 | return self._version 34 | 35 | def __repr__(self) -> str: 36 | return f"" 37 | 38 | @property 39 | def public(self) -> str: 40 | return self._version 41 | 42 | @property 43 | def base_version(self) -> str: 44 | return self._version 45 | 46 | @property 47 | def epoch(self) -> int: 48 | return -1 49 | 50 | @property 51 | def release(self) -> None: 52 | return None 53 | 54 | @property 55 | def pre(self) -> None: 56 | return None 57 | 58 | @property 59 | def post(self) -> None: 60 | return None 61 | 62 | @property 63 | def dev(self) -> None: 64 | return None 65 | 66 | @property 67 | def local(self) -> None: 68 | return None 69 | 70 | @property 71 | def is_prerelease(self) -> bool: 72 | return False 73 | 74 | @property 75 | def is_postrelease(self) -> bool: 76 | return False 77 | 78 | @property 79 | def is_devrelease(self) -> bool: 80 | return False 81 | 82 | 83 | _legacy_version_component_re = re.compile(r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE) 84 | 85 | _legacy_version_replacement_map = { 86 | "pre": "c", 87 | "preview": "c", 88 | "-": "final-", 89 | "rc": "c", 90 | "dev": "@", 91 | } 92 | 93 | 94 | def _parse_version_parts(s: str) -> Iterator[str]: 95 | for part in _legacy_version_component_re.split(s): 96 | part = _legacy_version_replacement_map.get(part, part) 97 | 98 | if not part or part == ".": 99 | continue 100 | 101 | if part[:1] in "0123456789": 102 | # pad for numeric comparison 103 | yield part.zfill(8) 104 | else: 105 | yield "*" + part 106 | 107 | # ensure that alpha/beta/candidate are before final 108 | yield "*final" 109 | 110 | 111 | def _legacy_cmpkey(version: str) -> LegacyCmpKey: 112 | # We hardcode an epoch of -1 here. A PEP 440 version can only have a epoch 113 | # greater than or equal to 0. This will effectively put the LegacyVersion, 114 | # which uses the defacto standard originally implemented by setuptools, 115 | # as before all PEP 440 versions. 116 | epoch = -1 117 | 118 | # This scheme is taken from pkg_resources.parse_version setuptools prior to 119 | # it's adoption of the packaging library. 120 | parts: List[str] = [] 121 | for part in _parse_version_parts(version.lower()): 122 | if part.startswith("*"): 123 | # remove "-" before a prerelease tag 124 | if part < "*final": 125 | while parts and parts[-1] == "*final-": 126 | parts.pop() 127 | 128 | # remove trailing zeros from each series of numeric parts 129 | while parts and parts[-1] == "00000000": 130 | parts.pop() 131 | 132 | parts.append(part) 133 | 134 | return epoch, tuple(parts) 135 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import pretend 2 | import pytest 3 | 4 | import inspector.main 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "text,encoding", 9 | [ 10 | # UTF-8 (most common) 11 | ("Hello, World!", "utf-8"), 12 | # Windows CP1252 with trademark symbol 13 | ("Windows™ text", "cp1252"), 14 | # Shift_JIS - Japanese 15 | ("こんにちは世界", "shift_jis"), 16 | # EUC-KR - Korean 17 | ("안녕하세요", "euc-kr"), 18 | # Big5 - Traditional Chinese 19 | ("繁體中文", "big5"), 20 | # CP1251 - Russian/Cyrillic 21 | ("Привет мир", "cp1251"), 22 | ], 23 | ) 24 | def test_decode_with_fallback_various_encodings(text, encoding): 25 | """Test decoding bytes with various text encodings that work correctly. 26 | 27 | These 6 encodings decode correctly with the current ordering and heuristics. 28 | """ 29 | content = text.encode(encoding) 30 | result = inspector.main.decode_with_fallback(content) 31 | assert result == text 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "text,encoding,decoded_by", 36 | [ 37 | ("你好世界", "gbk", "big5 or euc-kr"), 38 | ("中文测试", "gb2312", "shift_jis (rejected) then euc-kr"), 39 | ("Héllo Wörld", "iso-8859-1", "big5 (rejected) then cp1251"), 40 | ("Cześć świat", "iso-8859-2", "big5 (rejected) then cp1251"), 41 | ], 42 | ) 43 | def test_decode_with_fallback_misdetected_encodings(text, encoding, decoded_by): 44 | """Test encodings that still get misdetected despite improved heuristics. 45 | 46 | These encodings are misdetected by earlier encodings in the `common_encodings` list. 47 | Improved heuristics help but can't solve all cases without breaking others. 48 | 49 | Tried cross-Asian heuristics that reject some misdetections (e.g., shift_jis 50 | with excessive half-width katakana, Asian encodings with ASCII+CJK mix), 51 | but ordering remains a fundamental trade-off: 52 | no order works perfectly for all encodings. 53 | """ 54 | content = text.encode(encoding) 55 | result = inspector.main.decode_with_fallback(content) 56 | # Should decode to something (not None), but won't match original 57 | assert result is not None 58 | assert isinstance(result, str) 59 | assert len(result) > 0 60 | # Verify it's actually different (misdetected) 61 | assert result != text 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "description,binary_data", 66 | [ 67 | ( 68 | "Random binary with null bytes", 69 | bytes([0xFF, 0xFE, 0x00, 0x00, 0x01, 0x02, 0x03]), 70 | ), 71 | ("Null bytes only", bytes([0x00] * 10)), 72 | ("Low control characters", bytes([0x01, 0x02, 0x03, 0x04, 0x05])), 73 | ("JPEG header", bytes([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10])), 74 | ], 75 | ) 76 | def test_decode_with_fallback_binary(description, binary_data): 77 | """Test that binary data with many control characters returns None. 78 | 79 | Binary data should be rejected by our heuristics even though some 80 | encodings (like UTF-8 for ASCII control chars, or cp1251 for high bytes) 81 | can technically decode them. 82 | """ 83 | result = inspector.main.decode_with_fallback(binary_data) 84 | assert result is None 85 | 86 | 87 | def test_versions(monkeypatch): 88 | stub_json = {"releases": {"0.5.1e": None}} 89 | stub_response = pretend.stub( 90 | status_code=200, 91 | json=lambda: stub_json, 92 | ) 93 | get = pretend.call_recorder(lambda a: stub_response) 94 | monkeypatch.setattr( 95 | inspector.main, "requests_session", lambda: pretend.stub(get=get) 96 | ) 97 | 98 | render_template = pretend.call_recorder(lambda *a, **kw: None) 99 | monkeypatch.setattr(inspector.main, "render_template", render_template) 100 | 101 | inspector.main.versions("foo") 102 | 103 | assert get.calls == [pretend.call("https://pypi.org/pypi/foo/json")] 104 | assert render_template.calls == [ 105 | pretend.call( 106 | "releases.html", 107 | releases={"0.5.1e": None}, 108 | h2="foo", 109 | h2_link="/project/foo", 110 | h2_paren="View this project on PyPI", 111 | h2_paren_link="https://pypi.org/project/foo", 112 | ) 113 | ] 114 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile --allow-unsafe --generate-hashes --output-file=requirements/lint.txt ./requirements/lint.in 6 | # 7 | attrs==21.4.0 \ 8 | --hash=sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4 \ 9 | --hash=sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd 10 | # via curlylint 11 | black==24.3.0 \ 12 | --hash=sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f \ 13 | --hash=sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93 \ 14 | --hash=sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11 \ 15 | --hash=sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0 \ 16 | --hash=sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9 \ 17 | --hash=sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5 \ 18 | --hash=sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213 \ 19 | --hash=sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d \ 20 | --hash=sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7 \ 21 | --hash=sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837 \ 22 | --hash=sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f \ 23 | --hash=sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395 \ 24 | --hash=sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995 \ 25 | --hash=sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f \ 26 | --hash=sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597 \ 27 | --hash=sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959 \ 28 | --hash=sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5 \ 29 | --hash=sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb \ 30 | --hash=sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4 \ 31 | --hash=sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7 \ 32 | --hash=sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd \ 33 | --hash=sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7 34 | # via -r requirements/lint.in 35 | click==8.1.3 \ 36 | --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \ 37 | --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48 38 | # via 39 | # black 40 | # curlylint 41 | curlylint==0.13.1 \ 42 | --hash=sha256:008b9d160f3920404ac12efb05c0a39e209cb972f9aafd956b79c5f4e2162752 \ 43 | --hash=sha256:9546ea82cdfc9292fd6fe49dca28587164bd315782a209c0a46e013d7f38d2fa 44 | # via -r requirements/lint.in 45 | flake8==7.1.1 \ 46 | --hash=sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38 \ 47 | --hash=sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213 48 | # via -r requirements/lint.in 49 | isort==6.0.0 \ 50 | --hash=sha256:567954102bb47bb12e0fae62606570faacddd441e45683968c8d1734fb1af892 \ 51 | --hash=sha256:75d9d8a1438a9432a7d7b54f2d3b45cad9a4a0fdba43617d9873379704a8bdf1 52 | # via -r requirements/lint.in 53 | mccabe==0.7.0 \ 54 | --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ 55 | --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e 56 | # via flake8 57 | mypy-extensions==0.4.3 \ 58 | --hash=sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d \ 59 | --hash=sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8 60 | # via black 61 | packaging==23.1 \ 62 | --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ 63 | --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f 64 | # via black 65 | parsy==1.1.0 \ 66 | --hash=sha256:25bd5cea2954950ebbfdf71f8bdaf7fd45a5df5325fd36a1064be2204d9d4c94 \ 67 | --hash=sha256:36173ba01a5372c7a1b32352cc73a279a49198f52252adf1c8c1ed41d1f94e8d 68 | # via curlylint 69 | pathspec==0.9.0 \ 70 | --hash=sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a \ 71 | --hash=sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1 72 | # via 73 | # black 74 | # curlylint 75 | platformdirs==2.5.2 \ 76 | --hash=sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788 \ 77 | --hash=sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19 78 | # via black 79 | pycodestyle==2.12.1 \ 80 | --hash=sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3 \ 81 | --hash=sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521 82 | # via flake8 83 | pyflakes==3.2.0 \ 84 | --hash=sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f \ 85 | --hash=sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a 86 | # via flake8 87 | toml==0.10.2 \ 88 | --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \ 89 | --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f 90 | # via curlylint 91 | tomli==2.0.1 \ 92 | --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ 93 | --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f 94 | # via black 95 | typing-extensions==4.12.2 \ 96 | --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ 97 | --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 98 | # via black 99 | -------------------------------------------------------------------------------- /requirements/main.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile --allow-unsafe --generate-hashes --output-file=requirements/main.txt requirements/main.in 6 | # 7 | blinker==1.9.0 \ 8 | --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ 9 | --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc 10 | # via 11 | # flask 12 | # sentry-sdk 13 | certifi==2024.7.4 \ 14 | --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \ 15 | --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90 16 | # via 17 | # requests 18 | # sentry-sdk 19 | charset-normalizer==2.0.12 \ 20 | --hash=sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597 \ 21 | --hash=sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df 22 | # via requests 23 | click==8.1.3 \ 24 | --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \ 25 | --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48 26 | # via flask 27 | flask==3.1.0 \ 28 | --hash=sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac \ 29 | --hash=sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136 30 | # via 31 | # -r requirements/main.in 32 | # sentry-sdk 33 | gunicorn==22.0.0 \ 34 | --hash=sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9 \ 35 | --hash=sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63 36 | # via -r requirements/main.in 37 | idna==3.7 \ 38 | --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ 39 | --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 40 | # via requests 41 | itsdangerous==2.2.0 \ 42 | --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ 43 | --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 44 | # via flask 45 | jinja2==3.1.5 \ 46 | --hash=sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb \ 47 | --hash=sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb 48 | # via flask 49 | markupsafe==2.1.1 \ 50 | --hash=sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003 \ 51 | --hash=sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88 \ 52 | --hash=sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5 \ 53 | --hash=sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7 \ 54 | --hash=sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a \ 55 | --hash=sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603 \ 56 | --hash=sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1 \ 57 | --hash=sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135 \ 58 | --hash=sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247 \ 59 | --hash=sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6 \ 60 | --hash=sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601 \ 61 | --hash=sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77 \ 62 | --hash=sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02 \ 63 | --hash=sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e \ 64 | --hash=sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63 \ 65 | --hash=sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f \ 66 | --hash=sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980 \ 67 | --hash=sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b \ 68 | --hash=sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812 \ 69 | --hash=sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff \ 70 | --hash=sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96 \ 71 | --hash=sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1 \ 72 | --hash=sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925 \ 73 | --hash=sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a \ 74 | --hash=sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6 \ 75 | --hash=sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e \ 76 | --hash=sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f \ 77 | --hash=sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4 \ 78 | --hash=sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f \ 79 | --hash=sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3 \ 80 | --hash=sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c \ 81 | --hash=sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a \ 82 | --hash=sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417 \ 83 | --hash=sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a \ 84 | --hash=sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a \ 85 | --hash=sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37 \ 86 | --hash=sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452 \ 87 | --hash=sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933 \ 88 | --hash=sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a \ 89 | --hash=sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7 90 | # via 91 | # jinja2 92 | # sentry-sdk 93 | # werkzeug 94 | packaging==23.1 \ 95 | --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ 96 | --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f 97 | # via 98 | # -r requirements/main.in 99 | # gunicorn 100 | requests==2.32.3 \ 101 | --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ 102 | --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 103 | # via -r requirements/main.in 104 | sentry-sdk[flask]==2.20.0 \ 105 | --hash=sha256:afa82713a92facf847df3c6f63cec71eb488d826a50965def3d7722aa6f0fdab \ 106 | --hash=sha256:c359a1edf950eb5e80cffd7d9111f3dbeef57994cb4415df37d39fda2cf22364 107 | # via -r requirements/main.in 108 | urllib3==1.26.19 \ 109 | --hash=sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3 \ 110 | --hash=sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429 111 | # via 112 | # requests 113 | # sentry-sdk 114 | werkzeug==3.1.3 \ 115 | --hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \ 116 | --hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746 117 | # via flask 118 | -------------------------------------------------------------------------------- /inspector/static/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.29.0 2 | https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+armasm+arturo+asciidoc+aspnet+asm6502+asmatmel+autohotkey+autoit+avisynth+avro-idl+awk+bash+basic+batch+bbcode+bbj+bicep+birb+bison+bnf+bqn+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+cilkc+cilkcpp+clojure+cmake+cobol+coffeescript+concurnas+csp+cooklang+coq+crystal+css-extras+csv+cue+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gap+gcode+gdscript+gedcom+gettext+gherkin+git+glsl+gn+linker-script+go+go-module+gradle+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keepalived+keyman+kotlin+kumir+kusto+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+magma+makefile+markdown+markup-templating+mata+matlab+maxscript+mel+mermaid+metafont+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+odin+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plant-uml+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+cshtml+jsx+tsx+reason+regex+rego+renpy+rescript+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+stata+iecst+stylus+supercollider+swift+systemd+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+tremor+turtle+twig+typescript+typoscript+unrealscript+uorazor+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+web-idl+wgsl+wiki+wolfram+wren+xeora+xml-doc+xojo+xquery+yaml+yang+zig&plugins=line-highlight+line-numbers */ 3 | code[class*=language-], 4 | pre[class*=language-] { 5 | color: #000; 6 | background: 0 0; 7 | text-shadow: 0 1px #fff; 8 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 9 | font-size: 1em; 10 | text-align: left; 11 | white-space: pre-wrap; 12 | word-spacing: normal; 13 | word-break: break-word; 14 | overflow: auto; 15 | max-width: 100%; 16 | word-wrap: normal; 17 | line-height: 1.5; 18 | -moz-tab-size: 4; 19 | -o-tab-size: 4; 20 | tab-size: 4; 21 | -webkit-hyphens: none; 22 | -moz-hyphens: none; 23 | -ms-hyphens: none; 24 | hyphens: none 25 | } 26 | 27 | code[class*=language-] ::-moz-selection, 28 | code[class*=language-]::-moz-selection, 29 | pre[class*=language-] ::-moz-selection, 30 | pre[class*=language-]::-moz-selection { 31 | text-shadow: none; 32 | background: #b3d4fc 33 | } 34 | 35 | code[class*=language-] ::selection, 36 | code[class*=language-]::selection, 37 | pre[class*=language-] ::selection, 38 | pre[class*=language-]::selection { 39 | text-shadow: none; 40 | background: #b3d4fc 41 | } 42 | 43 | @media print { 44 | 45 | code[class*=language-], 46 | pre[class*=language-] { 47 | text-shadow: none 48 | } 49 | } 50 | 51 | pre[class*=language-] { 52 | padding: 1em; 53 | margin: .5em 0; 54 | overflow: auto; 55 | } 56 | 57 | :not(pre)>code[class*=language-], 58 | pre[class*=language-] { 59 | background: #f5f2f0 60 | } 61 | 62 | :not(pre)>code[class*=language-] { 63 | padding: .1em; 64 | border-radius: .3em; 65 | white-space: normal 66 | } 67 | 68 | .token.cdata, 69 | .token.comment, 70 | .token.doctype, 71 | .token.prolog { 72 | color: #708090 73 | } 74 | 75 | .token.punctuation { 76 | color: #999 77 | } 78 | 79 | .token.namespace { 80 | opacity: .7 81 | } 82 | 83 | .token.boolean, 84 | .token.constant, 85 | .token.deleted, 86 | .token.number, 87 | .token.property, 88 | .token.symbol, 89 | .token.tag { 90 | color: #905 91 | } 92 | 93 | .token.attr-name, 94 | .token.builtin, 95 | .token.char, 96 | .token.inserted, 97 | .token.selector, 98 | .token.string { 99 | color: #690 100 | } 101 | 102 | .language-css .token.string, 103 | .style .token.string, 104 | .token.entity, 105 | .token.operator, 106 | .token.url { 107 | color: #9a6e3a; 108 | background: hsla(0, 0%, 100%, .5) 109 | } 110 | 111 | .token.atrule, 112 | .token.attr-value, 113 | .token.keyword { 114 | color: #07a 115 | } 116 | 117 | .token.class-name, 118 | .token.function { 119 | color: #dd4a68 120 | } 121 | 122 | .token.important, 123 | .token.regex, 124 | .token.variable { 125 | color: #e90 126 | } 127 | 128 | .token.bold, 129 | .token.important { 130 | font-weight: 700 131 | } 132 | 133 | .token.italic { 134 | font-style: italic 135 | } 136 | 137 | .token.entity { 138 | cursor: help 139 | } 140 | 141 | pre[data-line] { 142 | position: relative; 143 | padding: 1em 0 1em 3em 144 | } 145 | 146 | .line-highlight { 147 | position: absolute; 148 | left: 0; 149 | right: 0; 150 | padding: inherit 0; 151 | margin-top: 1em; 152 | background: hsla(24, 20%, 50%, .08); 153 | background: linear-gradient(to right, hsla(24, 20%, 50%, .1) 70%, hsla(24, 20%, 50%, 0)); 154 | pointer-events: none; 155 | line-height: inherit; 156 | white-space: pre 157 | } 158 | 159 | @media print { 160 | .line-highlight { 161 | -webkit-print-color-adjust: exact; 162 | color-adjust: exact 163 | } 164 | } 165 | 166 | .line-highlight:before, 167 | .line-highlight[data-end]:after { 168 | content: attr(data-start); 169 | position: absolute; 170 | top: .4em; 171 | left: .6em; 172 | min-width: 1em; 173 | padding: 0 .5em; 174 | background-color: hsla(24, 20%, 50%, .4); 175 | color: #f4f1ef; 176 | font: bold 65%/1.5 sans-serif; 177 | text-align: center; 178 | vertical-align: .3em; 179 | border-radius: 999px; 180 | text-shadow: none; 181 | box-shadow: 0 1px #fff 182 | } 183 | 184 | .line-highlight[data-end]:after { 185 | content: attr(data-end); 186 | top: auto; 187 | bottom: .4em 188 | } 189 | 190 | .line-numbers .line-highlight:after, 191 | .line-numbers .line-highlight:before { 192 | content: none 193 | } 194 | 195 | pre[id].linkable-line-numbers span.line-numbers-rows { 196 | pointer-events: all 197 | } 198 | 199 | pre[id].linkable-line-numbers span.line-numbers-rows>span:before { 200 | cursor: pointer 201 | } 202 | 203 | pre[id].linkable-line-numbers span.line-numbers-rows>span:hover:before { 204 | background-color: rgba(128, 128, 128, .2) 205 | } 206 | 207 | pre[class*=language-].line-numbers { 208 | position: relative; 209 | padding-left: 3.8em; 210 | counter-reset: linenumber 211 | } 212 | 213 | pre[class*=language-].line-numbers>code { 214 | position: relative; 215 | white-space: inherit 216 | } 217 | 218 | .line-numbers .line-numbers-rows { 219 | word-wrap: normal; 220 | word-break: keep-all; 221 | position: absolute; 222 | pointer-events: none; 223 | top: 0; 224 | font-size: 100%; 225 | left: -3.8em; 226 | width: 3em; 227 | letter-spacing: -1px; 228 | border-right: 1px solid #999; 229 | -webkit-user-select: none; 230 | -moz-user-select: none; 231 | -ms-user-select: none; 232 | user-select: none 233 | } 234 | 235 | .line-numbers-rows>span { 236 | display: block; 237 | counter-increment: linenumber 238 | } 239 | 240 | .line-numbers-rows>span:before { 241 | content: counter(linenumber); 242 | color: #999; 243 | display: block; 244 | padding-right: .8em; 245 | text-align: right 246 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /inspector/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.parse 3 | 4 | import gunicorn.http.errors 5 | import sentry_sdk 6 | 7 | from flask import Flask, Response, abort, redirect, render_template, request, url_for 8 | from packaging.utils import canonicalize_name 9 | from sentry_sdk.integrations.flask import FlaskIntegration 10 | 11 | from .analysis.checks import basic_details 12 | from .deob import decompile, disassemble 13 | from .distribution import _get_dist 14 | from .errors import InspectorError 15 | from .legacy import parse 16 | from .utilities import pypi_report_form, requests_session 17 | 18 | 19 | def _is_likely_text(decoded_str): 20 | """Check if decoded string looks like valid text (not corrupted).""" 21 | if not decoded_str: 22 | return True 23 | 24 | # Too many control characters suggests wrong encoding 25 | control_chars = sum(1 for c in decoded_str if ord(c) < 32 and c not in "\t\n\r") 26 | return control_chars / len(decoded_str) <= 0.3 27 | 28 | 29 | def _is_likely_misencoded_asian_text(decoded_str, encoding): 30 | """ 31 | Detect when Western encodings decode Asian text as Latin Extended garbage. 32 | 33 | When cp1252/latin-1 decode multi-byte Asian text, they produce strings 34 | with many Latin Extended/Supplement characters and few/no spaces. 35 | """ 36 | if encoding not in ("cp1252", "latin-1") or len(decoded_str) <= 3: 37 | return False 38 | 39 | # Count Latin Extended-A/B (Ā-ʯ) and Latin-1 Supplement (À-ÿ) 40 | high_latin = sum(1 for c in decoded_str if 0x0080 <= ord(c) <= 0x024F) 41 | spaces = decoded_str.count(" ") 42 | 43 | # If >50% high Latin chars and <10% spaces, likely misencoded 44 | return high_latin / len(decoded_str) > 0.5 and spaces < len(decoded_str) * 0.1 45 | 46 | 47 | def _is_likely_misencoded_cross_asian(decoded_str, encoding): 48 | """ 49 | Detect when Asian encodings misinterpret other Asian encodings. 50 | 51 | Patterns: 52 | - shift_jis decoding GB2312 produces excessive half-width katakana 53 | - Asian encodings decoding Western text produce ASCII+CJK mix (unlikely) 54 | """ 55 | if len(decoded_str) <= 3: 56 | return False 57 | 58 | # Pattern 1: Excessive half-width katakana (shift_jis misinterpreting GB2312) 59 | # Half-width katakana range: U+FF61-FF9F 60 | if encoding == "shift_jis": 61 | half_width_katakana = sum(1 for c in decoded_str if 0xFF61 <= ord(c) <= 0xFF9F) 62 | # If >30% is half-width katakana, likely wrong encoding 63 | # (Real Japanese text uses mostly full-width kana and kanji) 64 | if half_width_katakana / len(decoded_str) > 0.3: 65 | return True 66 | 67 | # Pattern 2: ASCII mixed with CJK (Asian encoding misinterpreting Western) 68 | # CJK Unified Ideographs: U+4E00-U+9FFF 69 | if encoding in ("big5", "gbk", "gb2312", "shift_jis", "euc-kr"): 70 | ascii_chars = sum(1 for c in decoded_str if ord(c) < 128) 71 | cjk_chars = sum(1 for c in decoded_str if 0x4E00 <= ord(c) <= 0x9FFF) 72 | 73 | # If we have ASCII letters and scattered CJK chars, likely misencoded 74 | # Real CJK text is mostly CJK with occasional ASCII punctuation 75 | if ascii_chars > 0 and cjk_chars > 0: 76 | # Check if there are ASCII letters (not just punctuation) 77 | ascii_letters = sum(1 for c in decoded_str if c.isalpha() and ord(c) < 128) 78 | # If we have ASCII letters AND CJK, and CJK is <50%, likely wrong 79 | if ascii_letters >= 2 and cjk_chars / len(decoded_str) < 0.5: 80 | return True 81 | 82 | return False 83 | 84 | 85 | def decode_with_fallback(content_bytes): 86 | """ 87 | Decode bytes to string, trying multiple encodings. 88 | 89 | Strategy: 90 | 1. Try UTF-8 (most common) 91 | 2. Try common encodings with sanity checks 92 | 3. Fall back to latin-1 (decodes anything, but may produce garbage) 93 | 94 | Returns decoded string or None if all attempts fail (only if truly binary). 95 | """ 96 | # Try UTF-8 first (most common) 97 | try: 98 | decoded = content_bytes.decode("utf-8") 99 | # Apply same heuristics as other encodings 100 | if _is_likely_text(decoded): 101 | return decoded 102 | except (UnicodeDecodeError, AttributeError): 103 | pass 104 | 105 | # Try encodings from most to least restrictive. Even with improved heuristics, 106 | # putting GBK/GB2312 early breaks too many other encodings. The order below 107 | # maximizes correct detections while minimizing misdetections. 108 | common_encodings = [ 109 | "shift_jis", # Japanese (restrictive multi-byte) 110 | "euc-kr", # Korean (restrictive multi-byte) 111 | "big5", # Chinese Traditional (restrictive multi-byte) 112 | "gbk", # Chinese Simplified 113 | "gb2312", # Chinese Simplified, older 114 | "cp1251", # Cyrillic 115 | "iso-8859-2", # Central/Eastern European 116 | "cp1252", # Windows Western European (very permissive) 117 | "latin-1", # ISO-8859-1 fallback (never fails) 118 | ] 119 | 120 | for encoding in common_encodings: 121 | try: 122 | decoded = content_bytes.decode(encoding) 123 | 124 | # Skip if decoded text looks corrupted 125 | if not _is_likely_text(decoded): 126 | continue 127 | 128 | # Skip if Western encoding produced Asian-text-as-garbage pattern 129 | if _is_likely_misencoded_asian_text(decoded, encoding): 130 | continue 131 | 132 | # Skip if Asian encoding misinterpreted other Asian/Western text 133 | if _is_likely_misencoded_cross_asian(decoded, encoding): 134 | continue 135 | 136 | return decoded 137 | 138 | except (UnicodeDecodeError, LookupError): 139 | continue 140 | 141 | # If we get here, all encodings failed sanity checks (truly binary data) 142 | return None 143 | 144 | 145 | def traces_sampler(sampling_context): 146 | """ 147 | Filter out noisy transactions. 148 | See https://github.com/getsentry/sentry-python/discussions/1569 149 | """ 150 | path = sampling_context.get("wsgi_environ", {}).get("PATH_INFO", None) 151 | if path and path == "/_health/": 152 | return 0 153 | return 1 154 | 155 | 156 | if SENTRY_DSN := os.environ.get("SENTRY_DSN"): 157 | sentry_sdk.init( 158 | dsn=SENTRY_DSN, 159 | integrations=[FlaskIntegration()], 160 | traces_sample_rate=1.0, 161 | traces_sampler=traces_sampler, 162 | ) 163 | 164 | app = Flask(__name__) 165 | 166 | app.jinja_env.filters["unquote"] = lambda u: urllib.parse.unquote(u) 167 | app.jinja_env.trim_blocks = True 168 | app.jinja_env.lstrip_blocks = True 169 | 170 | 171 | @app.errorhandler(gunicorn.http.errors.ParseException) 172 | def handle_bad_request(e): 173 | return abort(400) 174 | 175 | 176 | @app.route("/") 177 | def index(): 178 | if project := request.args.get("project"): 179 | project = project.strip() 180 | return redirect(f"/project/{project}") 181 | return render_template("index.html") 182 | 183 | 184 | @app.route("/project//") 185 | def versions(project_name): 186 | if project_name != canonicalize_name(project_name): 187 | return redirect( 188 | url_for("versions", project_name=canonicalize_name(project_name)), 301 189 | ) 190 | 191 | resp = requests_session().get(f"https://pypi.org/pypi/{project_name}/json") 192 | pypi_project_url = f"https://pypi.org/project/{project_name}" 193 | 194 | # Self-host 404 page to mitigate iframe embeds 195 | if resp.status_code == 404: 196 | return render_template("404.html") 197 | if resp.status_code != 200: 198 | return redirect(pypi_project_url, 307) 199 | 200 | releases = resp.json()["releases"] 201 | sorted_releases = { 202 | version: releases[version] 203 | for version in sorted(releases.keys(), key=parse, reverse=True) 204 | } 205 | 206 | return render_template( 207 | "releases.html", 208 | releases=sorted_releases, 209 | h2=project_name, 210 | h2_link=f"/project/{project_name}", 211 | h2_paren="View this project on PyPI", 212 | h2_paren_link=pypi_project_url, 213 | ) 214 | 215 | 216 | @app.route("/project///") 217 | def distributions(project_name, version): 218 | if project_name != canonicalize_name(project_name): 219 | return redirect( 220 | url_for( 221 | "distributions", 222 | project_name=canonicalize_name(project_name), 223 | version=version, 224 | ), 225 | 301, 226 | ) 227 | 228 | resp = requests_session().get( 229 | f"https://pypi.org/pypi/{project_name}/{version}/json" 230 | ) 231 | if resp.status_code != 200: 232 | return redirect(f"/project/{project_name}/") 233 | 234 | dist_urls = [ 235 | "." + urllib.parse.urlparse(url["url"]).path + "/" 236 | for url in resp.json()["urls"] 237 | ] 238 | return render_template( 239 | "links.html", 240 | links=dist_urls, 241 | h2=f"{project_name}", 242 | h2_link=f"/project/{project_name}", 243 | h2_paren="View this project on PyPI", 244 | h2_paren_link=f"https://pypi.org/project/{project_name}", 245 | h3=f"{project_name}=={version}", 246 | h3_link=f"/project/{project_name}/{version}", 247 | h3_paren="View this release on PyPI", 248 | h3_paren_link=f"https://pypi.org/project/{project_name}/{version}", 249 | ) 250 | 251 | 252 | @app.route( 253 | "/project///packages/////" 254 | ) 255 | def distribution(project_name, version, first, second, rest, distname): 256 | if project_name != canonicalize_name(project_name): 257 | return redirect( 258 | url_for( 259 | "distribution", 260 | project_name=canonicalize_name(project_name), 261 | version=version, 262 | first=first, 263 | second=second, 264 | rest=rest, 265 | distname=distname, 266 | ), 267 | 301, 268 | ) 269 | 270 | try: 271 | dist = _get_dist(first, second, rest, distname) 272 | except InspectorError: 273 | return abort(400) 274 | 275 | h2_paren = "View this project on PyPI" 276 | resp = requests_session().get(f"https://pypi.org/pypi/{project_name}/json") 277 | if resp.status_code == 404: 278 | h2_paren = "❌ Project no longer on PyPI" 279 | 280 | h3_paren = "View this release on PyPI" 281 | resp = requests_session().get( 282 | f"https://pypi.org/pypi/{project_name}/{version}/json" 283 | ) 284 | if resp.status_code == 404: 285 | h3_paren = "❌ Release no longer on PyPI" 286 | 287 | if dist: 288 | file_urls = [ 289 | "./" + urllib.parse.quote(filename) for filename in dist.namelist() 290 | ] 291 | return render_template( 292 | "links.html", 293 | links=file_urls, 294 | h2=f"{project_name}", 295 | h2_link=f"/project/{project_name}", 296 | h2_paren=h2_paren, 297 | h2_paren_link=f"https://pypi.org/project/{project_name}", 298 | h3=f"{project_name}=={version}", 299 | h3_link=f"/project/{project_name}/{version}", 300 | h3_paren=h3_paren, 301 | h3_paren_link=f"https://pypi.org/project/{project_name}/{version}", 302 | h4=distname, 303 | h4_link=f"/project/{project_name}/{version}/packages/{first}/{second}/{rest}/{distname}/", # noqa 304 | ) 305 | else: 306 | return "Distribution type not supported" 307 | 308 | 309 | @app.route( 310 | "/project///packages/////" # noqa 311 | ) 312 | def file(project_name, version, first, second, rest, distname, filepath): 313 | if project_name != canonicalize_name(project_name): 314 | return redirect( 315 | url_for( 316 | "file", 317 | project_name=canonicalize_name(project_name), 318 | version=version, 319 | first=first, 320 | second=second, 321 | rest=rest, 322 | distname=distname, 323 | filepath=filepath, 324 | ), 325 | 301, 326 | ) 327 | 328 | h2_paren = "View this project on PyPI" 329 | resp = requests_session().get(f"https://pypi.org/pypi/{project_name}/json") 330 | if resp.status_code == 404: 331 | h2_paren = "❌ Project no longer on PyPI" 332 | 333 | h3_paren = "View this release on PyPI" 334 | resp = requests_session().get( 335 | f"https://pypi.org/pypi/{project_name}/{version}/json" 336 | ) 337 | if resp.status_code == 404: 338 | h3_paren = "❌ Release no longer on PyPI" 339 | 340 | dist = _get_dist(first, second, rest, distname) 341 | if dist: 342 | try: 343 | contents = dist.contents(filepath) 344 | except FileNotFoundError: 345 | return abort(404) 346 | except InspectorError: 347 | return abort(400) 348 | file_extension = filepath.split(".")[-1] 349 | report_link = pypi_report_form(project_name, version, filepath, request.url) 350 | 351 | details = [detail.html() for detail in basic_details(dist, filepath)] 352 | common_params = { 353 | "file_details": details, 354 | "mailto_report_link": report_link, 355 | "h2": f"{project_name}", 356 | "h2_link": f"/project/{project_name}", 357 | "h2_paren": h2_paren, 358 | "h2_paren_link": f"https://pypi.org/project/{project_name}", 359 | "h3": f"{project_name}=={version}", 360 | "h3_link": f"/project/{project_name}/{version}", 361 | "h3_paren": h3_paren, 362 | "h3_paren_link": f"https://pypi.org/project/{project_name}/{version}", 363 | "h4": distname, 364 | "h4_link": f"/project/{project_name}/{version}/packages/{first}/{second}/{rest}/{distname}/", # noqa 365 | "h5": filepath, 366 | "h5_link": f"/project/{project_name}/{version}/packages/{first}/{second}/{rest}/{distname}/{filepath}", # noqa 367 | } 368 | 369 | if file_extension in ["pyc", "pyo"]: 370 | disassembly = disassemble(contents) 371 | decompilation = decompile(contents) 372 | return render_template( 373 | "disasm.html", 374 | disassembly=disassembly, 375 | decompilation=decompilation, 376 | **common_params, 377 | ) 378 | 379 | if isinstance(contents, bytes): 380 | decoded_contents = decode_with_fallback(contents) 381 | if decoded_contents is None: 382 | return "Binary files are not supported." 383 | contents = decoded_contents 384 | 385 | return render_template( 386 | "code.html", code=contents, name=file_extension, **common_params 387 | ) # noqa 388 | else: 389 | return "Distribution type not supported" 390 | 391 | 392 | @app.route("/_health/") 393 | def health(): 394 | return "OK" 395 | 396 | 397 | @app.route("/robots.txt") 398 | def robots(): 399 | return Response("User-agent: *\nDisallow: /", mimetype="text/plain") 400 | --------------------------------------------------------------------------------