├── 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 | [](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 |
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 | | Version |
23 | Upload Timestamp |
24 | Artifacts |
25 |
26 |
27 | {% for key, value in releases.items() %}
28 |
29 | {% if value|length > 0 %}
30 | | {{ key }} |
31 | {{ value[0]['upload_time'] }} |
32 | {{ value|length }} |
33 | {% else %}
34 | {{ key }} |
35 | Not Available |
36 | Not Available |
37 | {% endif %}
38 |
39 | {% endfor %}
40 |
41 |
42 |
43 |
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 |
10 |
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 |
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 |
36 | {# Indenting the below tags will cause rendering issues! #}
37 | {{- decompilation }}
38 |
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 |
--------------------------------------------------------------------------------