Skip to content
This repository has been archived by the owner on Jan 12, 2021. It is now read-only.

Commit

Permalink
Merge pull request #400 from dephell/package-validate
Browse files Browse the repository at this point in the history
dephell package verify
  • Loading branch information
orsinium committed Mar 16, 2020
2 parents c1dcf65 + d9e7881 commit fa0d6fc
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 3 deletions.
1 change: 1 addition & 0 deletions dephell/commands/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
'package remove',
'package search',
'package show',
'package verify',

'project build',
'project bump',
Expand Down
113 changes: 113 additions & 0 deletions dephell/commands/package_verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# built-in
from argparse import ArgumentParser
from pathlib import Path
from tempfile import TemporaryDirectory

# app
from ..actions import get_package, make_json
from ..config import builders
from ..imports import lazy_import
from ..networking import requests_session
from .base import BaseCommand


gnupg = lazy_import('gnupg', package='python-gnupg')
DEFAULT_KEYSERVER = 'pgp.mit.edu'


class PackageVerifyCommand(BaseCommand):
"""Verify GPG signature for a release from PyPI.org.
"""
@staticmethod
def build_parser(parser) -> ArgumentParser:
builders.build_config(parser)
builders.build_output(parser)
builders.build_api(parser)
builders.build_other(parser)
parser.add_argument('name', help='package name and version to validate')
return parser

def __call__(self) -> bool:
dep = get_package(self.args.name, repo=self.config.get('repo'))
releases = dep.repo.get_releases(dep)
if not releases:
self.logger.error('cannot find releases for the package')
return False
releases = dep.constraint.filter(releases=releases)
if not releases:
self.logger.error('cannot find releases for the constraint')
return False
release = sorted(releases, reverse=True)[0]
gpg = gnupg.GPG()
return self._verify_release(release=release, gpg=gpg)

def _verify_release(self, release, gpg) -> bool:
if not release.urls:
self.logger.error('no urls found for release', extra=dict(
version=str(release.version),
))
return False

verified = False
all_valid = True
with TemporaryDirectory() as root_path:
for url in release.urls:
sign_path = Path(root_path) / 'archive.bin.asc'
with requests_session() as session:
response = session.get(url + '.asc')
if response.status_code == 404:
self.logger.debug('no signature found', extra=dict(url=url))
continue
sign_path.write_bytes(response.content)

self.logger.info('getting release file...', extra=dict(url=url))
with requests_session() as session:
response = session.get(url)
if response.status_code == 404:
self.logger.debug('no signature found', extra=dict(url=url))
continue
data = response.content

info = self._verify_data(gpg=gpg, sign_path=sign_path, data=data)
verified = True
if not info:
return False
if info['status'] != 'signature valid':
all_valid = False
info['release'] = str(release.version)
info['name'] = url.rsplit('/', maxsplit=1)[-1]
print(make_json(
data=info,
key=self.config.get('filter'),
colors=not self.config['nocolors'],
table=self.config['table'],
))
if not verified:
self.logger.error('no signed files found')
return False
return all_valid

def _verify_data(self, gpg, sign_path: Path, data: bytes, retry: bool = True):
verif = gpg.verify_data(str(sign_path), data)
result = dict(
created=verif.creation_date,
fingerprint=verif.fingerprint,
key_id=verif.key_id,
status=verif.status,
username=verif.username,
)

if verif.status == 'no public key' and retry:
# try to import keys and verify again
self.logger.debug('searching the key...', extra=dict(key_id=verif.key_id))
keys = gpg.search_keys(query=verif.key_id, keyserver=DEFAULT_KEYSERVER)
if len(keys) != 1:
self.logger.debug('cannot find the key', extra=dict(
count=len(keys),
key_id=verif.key_id,
))
return result
gpg.recv_keys(DEFAULT_KEYSERVER, keys[0]['keyid'])
return self._verify_data(gpg=gpg, sign_path=sign_path, data=data, retry=False)

return result
2 changes: 2 additions & 0 deletions dephell/models/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Release:
time = attr.ib(repr=False) # upload_time
python = attr.ib(default=None, repr=False) # requires_python
hashes = attr.ib(factory=tuple, repr=False) # digests/sha256
urls = attr.ib(factory=tuple, repr=False) # url

extra = attr.ib(type=Optional[str], default=None)

Expand All @@ -40,6 +41,7 @@ def from_response(cls, name, version, info, extra=None):
time=datetime.strptime(latest['upload_time'], '%Y-%m-%dT%H:%M:%S'),
python=python,
hashes=tuple(rel['digests']['sha256'] for rel in info),
urls=tuple(rel['url'] for rel in info),
extra=extra,
)

Expand Down
2 changes: 1 addition & 1 deletion docs/cmd-package-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,6 @@ dephell package search --repo=conda --filter=":5" keras type:ipynb
## See also

1. [How to filter commands JSON output](filters).
1. [dephell package show](cmd-package-search) to show information about single package.
1. [dephell package show](cmd-package-show) to show information about single package.
1. [dephell package list](cmd-package-list) to show information about installed packages.
1. [dephell package install](cmd-package-install) to install package.
49 changes: 49 additions & 0 deletions docs/cmd-package-verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# dephell package verify

Verify GPG signature for a release from [PyPI](https://pypi.org/).

Verify files for the latest release:

```bash
$ dephell package verify flask
INFO getting release file... (url=https://files.pythonhosted.org/packages/.../Flask-1.1.1-py2.py3-none-any.whl)
{
"created": "2019-07-08",
"fingerprint": "AD253D8661D175D001F462D77A1C87E3F5BC42A8",
"key_id": "7A1C87E3F5BC42A8",
"name": "Flask-1.1.1-py2.py3-none-any.whl",
"release": "1.1.1",
"status": "signature valid",
"username": "David Lord <davidism@gmail.com>"
}

INFO getting release file... (url=https://files.pythonhosted.org/packages/.../Flask-1.1.1.tar.gz)
{
"created": "2019-07-08",
"fingerprint": "AD253D8661D175D001F462D77A1C87E3F5BC42A8",
"key_id": "7A1C87E3F5BC42A8",
"name": "Flask-1.1.1.tar.gz",
"release": "1.1.1",
"status": "signature valid",
"username": "David Lord <davidism@gmail.com>"
}
```

Verify files for the given release:

```bash
dephell package verify django==2.0.1
```

Note that packages signing isn't popular in Python world. Most of packages have no signature:

```bash
$ dephell package verify pip
ERROR no signed files found
```

## See also

1. [How to filter commands JSON output](filters).
1. [dephell package show](cmd-package-show) to show information about single package.
1. [dephell deps audit](cmd-deps-audit) to find known vulnerabilities in the project dependencies.
3 changes: 2 additions & 1 deletion docs/index-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Commands to work with single packages.

Get information: [download statistics](cmd-package-downloads), [installed packages](cmd-package-list), [available releases](cmd-package-releases), [package metainfo](cmd-package-show), [search packages](cmd-package-search).

Manage: [install](cmd-package-install), [remove](cmd-package-remove), [remove with dependencies](cmd-package-purge), [report bug](cmd-package-bug).
Manage: [install](cmd-package-install), [remove](cmd-package-remove), [remove with dependencies](cmd-package-purge), [report bug](cmd-package-bug), [verify GPG signature](cmd-package-verify).

```eval_rst
.. toctree::
Expand All @@ -20,4 +20,5 @@ Manage: [install](cmd-package-install), [remove](cmd-package-remove), [remove wi
cmd-package-remove
cmd-package-search
cmd-package-show
cmd-package-verify
```
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ dockerpty = {optional = true, version = "*"}
fissix = {optional = true, allows-prereleases = true, version = "*"}
graphviz = {optional = true, version = "*"}
html5lib = {optional = true, version = "*"}
python-gnupg = {optional = true, version = "*"}
pygments = {optional = true, version = "*"}
"ruamel.yaml" = {optional = true, version = "*"}
tabulate = {optional = true, version = "*"}
Expand Down Expand Up @@ -198,4 +199,8 @@ isort = {extras = ["pyproject"], version = "*"}
[tool.poetry.extras]
docs = ["alabaster", "pygments-github-lexers", "recommonmark", "sphinx"]
tests = ["aioresponses", "pytest", "requests-mock"]
full = ["aiofiles", "appdirs", "autopep8", "bowler", "colorama", "docker", "dockerpty", "fissix", "graphviz", "html5lib", "pygments", "ruamel-yaml", "tabulate", "yapf"]
full = [
"aiofiles", "appdirs", "autopep8", "bowler", "colorama", "docker",
"dockerpty", "fissix", "graphviz", "html5lib", "python-gnupg", "pygments",
"ruamel-yaml", "tabulate", "yapf",
]

0 comments on commit fa0d6fc

Please sign in to comment.