Repository: twu/skjold

Issue:
Pre-commit hook fails if multiple lock or requirements files are modified at same time
If you in your repo have multiple files that should be analyzed by skjold and use it as a pre-commit hook, the hook fails if you modify multiple lock/requirements files in the same commit.

The reason to the problem is that pre-commit tries to invoke `skjold audit` with multiple files as arguments at once and the `skjold audit` command only accepts one file at a time.

A minimal POC can be obtained by just creating an empty git repo (`mkdir skjold-poc; cd skjold-poc; git init`), enabling pre-commit (`pre-commit install`), adding a `.pre-commit-config.yaml` containing 
```
repos:
  - repo: https://github.com/twu/skjold
    rev: v0.4.1
    hooks:
    - id: skjold
      verbose: true
```
and then creating two (or more) requirements files (`touch requirements.txt requirements-dev.txt`) and then running `git add .`

If you then try to commit with `git commit -m "Adding requirements files"` you will be greeted with an error saying:
`Error: Got unexpected extra argument (requirements.txt)`. If you add even more different lock files or requirements-something.txt  files the message will be expanded with more extra arguments.

It's probably not that common people would have both poetry.lock and Pipfile.lock files, but possible people want to have multiple requirements.txt files (one for dev and one for prod) or multiple lock files in different directories of the same repo and get them all analyzed. It's of course possible to somewhat well avoid the problem by modifying only one lock file at a time, but the same issue also affects `pre-commit run --all-files` that will scan all the matched files regardless of if they've been changed or not.


Discussion:

Patch:
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index 59f0d2b..e9485bd 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -6,4 +6,4 @@
   language: python
   language_version: python3
   require_serial: true
-  files: ^(poetry\.lock|Pipfile\.lock|requirements.*\.txt)$
+  files: (poetry\.lock|Pipfile\.lock|requirements.*\.txt)$
diff --git a/README.md b/README.md
index 9397f20..84aa553 100644
--- a/README.md
+++ b/README.md
@@ -20,14 +20,13 @@ It currently supports fetching advisories from the following sources:
 
 | Source | Name | Notes |
 | ------:|:----:|:------|
-| [GitHub Advisory Database](https://github.com/advisories) | `github` | |
+| [GitHub Advisory Database](https://github.com/advisories) | `github` | Requires Access Token (See [Github](#github)). |
 | [PyUP.io safety-db](https://github.com/pyupio/safety-db) | `pyup` | |
 | [GitLab gemnasium-db](https://gitlab.com/gitlab-org/security-products/gemnasium-db) | `gemnasium` | |
-| [PYPA Advisory Database](https://github.com/pypa/advisory-db) | `pypa` | **Experimental!** Only supports `ECOSYSTEM` and `SEMVER`! |
-| [OSV.dev Database](https://osv.dev) | `osv` | **Experimental!** Only supports `ECOSYSTEM` and `SEMVER`!<br/> Sends package information to [OSV.dev](https://osv.dev) API. |
+| [PYPA Advisory Database](https://github.com/pypa/advisory-db) | `pypa` | Only supports `ECOSYSTEM`! |
+| [OSV.dev Database](https://osv.dev) | `osv` | Only supports `ECOSYSTEM`!<br/> Sends package information to [OSV.dev](https://osv.dev) API. |
 
-
-No source is enabled by default! Individual sources can be enabled by setting `sources` list (see [Configuration](#configuration)). There is (currently) no de-duplication meaning that using all of them could result in _a lot_ of duplicates.
+No source is enabled by default! Sources can be enabled by setting `sources` list (see [Configuration](#configuration)). There is (currently) no de-duplication meaning that using too many sources at once will result in _a lot_ of duplicates. `skjold` also requires _all_ dependencies to be passed as it *will not* resolve any dependencies at runtime!
 
 ## Motivation
 Skjold was initially created for myself to replace `safety`. ~Which appears to no longer receive monthly updates (see [pyupio/safety-db #2282](https://github.com/pyupio/safety-db/issues/2282))~. I wanted something I can run locally and use for my local or private projects/scripts.
@@ -68,6 +67,9 @@ $ skjold -v audit requirements.txt
 $ skjold -v audit poetry.lock
 $ skjold -v audit Pipenv.lock
 
+# Specify specify multiple inputs at once.
+$ skjold -v audit Pipenv.lock poetry.lock requirements.txt
+
 # Using poetry.
 $ poetry export -f requirements.txt | skjold audit -s github -s gemnasium -s pyup -
 
diff --git a/src/skjold/cli.py b/src/skjold/cli.py
index dd63be8..bdb15ab 100644
--- a/src/skjold/cli.py
+++ b/src/skjold/cli.py
@@ -9,7 +9,7 @@ from typing import List, TextIO
 import click
 import skjold.sources
 
-from skjold.formats import extract_package_list_from, Format
+from skjold.formats import extract_dependencies_from_files, Format
 from skjold.ignore import SkjoldIgnore
 from skjold.tasks import (
     Configuration,
@@ -138,7 +138,7 @@ def config_(config: Configuration) -> None:
     show_default=False,
     multiple=True,
 )
-@click.argument("file", type=click.File(), default="./requirements.txt", required=False)
+@click.argument("files", nargs=-1, type=click.File())
 @configuration
 def audit_(
     config: Configuration,
@@ -147,7 +147,7 @@ def audit_(
     file_format: str,
     ignore_file: str,
     sources: List[str],
-    file: TextIO,
+    files: List[TextIO],
 ) -> None:
     """
     Checks a given dependency file against advisory databases.
@@ -168,7 +168,7 @@ def audit_(
             "Please specify or configure at least one advisory source."
         )
 
-    packages = extract_package_list_from(config, file, file_format)
+    packages = list(extract_dependencies_from_files(config, files, file_format))
 
     if config.verbose:
         click.secho("Checking ", nl=False, err=True)
diff --git a/src/skjold/models.py b/src/skjold/core.py
similarity index 79%
rename from src/skjold/models.py
rename to src/skjold/core.py
index c142bc1..01a61fa 100644
--- a/src/skjold/models.py
+++ b/src/skjold/core.py
@@ -1,16 +1,29 @@
 import abc
+from dataclasses import dataclass
 import os
 import time
 from abc import ABCMeta, abstractmethod
 from typing import List, Tuple, MutableMapping, Sequence, Optional
 
+from packaging.utils import NormalizedName, canonicalize_name
+
 
 class SkjoldException(Exception):
     pass
 
 
-Package = Tuple[str, str]
-PackageList = Sequence[Package]
+@dataclass(frozen=True)
+class Dependency:
+    name: str
+    version: str
+    source: Tuple[str, Optional[int]] = ("<unknown>", None)
+
+    @property
+    def canonical_name(self) -> NormalizedName:
+        return canonicalize_name(self.name)
+
+
+DependencyList = Sequence[Dependency]
 
 
 class SecurityAdvisory(metaclass=abc.ABCMeta):
@@ -32,6 +45,12 @@ class SecurityAdvisory(metaclass=abc.ABCMeta):
         """Return package name of the affected package."""
         raise NotImplementedError
 
+    @property
+    @abstractmethod
+    def canonical_name(self) -> str:
+        """Return package name of the affected package."""
+        raise NotImplementedError
+
     @property
     @abstractmethod
     def url(self) -> str:
@@ -79,7 +98,7 @@ def is_outdated(path: str, max_age: int = 3600) -> bool:
 
 
 class SecurityAdvisorySource(metaclass=ABCMeta):
-    _advisories: MutableMapping[str, SecurityAdvisoryList] = {}
+    _advisories: MutableMapping[NormalizedName, SecurityAdvisoryList] = {}
     _cache_dir: str
     _cache_expires: int
     _name: str
@@ -95,7 +114,7 @@ class SecurityAdvisorySource(metaclass=ABCMeta):
         raise NotImplementedError
 
     @property
-    def advisories(self) -> MutableMapping[str, SecurityAdvisoryList]:
+    def advisories(self) -> MutableMapping[NormalizedName, SecurityAdvisoryList]:
         """Return list of SecurityAdvisories from the given source."""
         if self.requires_update:
             self.update()
@@ -107,7 +126,7 @@ class SecurityAdvisorySource(metaclass=ABCMeta):
 
     @property
     def requires_update(self) -> bool:
-        """ Return True if the source should be updated. False otherwise. """
+        """Return True if the source should be updated. False otherwise."""
         if self.path is None:
             return True
 
@@ -138,15 +157,15 @@ class SecurityAdvisorySource(metaclass=ABCMeta):
 
     @abstractmethod
     def is_vulnerable_package(
-        self, package_name: str, package_version: str
+        self, dependency: Dependency
     ) -> Tuple[bool, Sequence[SecurityAdvisory]]:
         raise NotImplementedError
 
     @abstractmethod
-    def has_security_advisory_for(self, package_name: str) -> bool:
+    def has_security_advisory_for(self, dependency: Dependency) -> bool:
         raise NotImplementedError
 
     def get_security_advisories(
         self,
-    ) -> MutableMapping[str, SecurityAdvisoryList]:
+    ) -> MutableMapping[NormalizedName, SecurityAdvisoryList]:
         return self.advisories
diff --git a/src/skjold/formats.py b/src/skjold/formats.py
index cba92f6..8d6f22f 100644
--- a/src/skjold/formats.py
+++ b/src/skjold/formats.py
@@ -1,22 +1,24 @@
 import json
 import os
-from typing import TextIO, MutableMapping, Callable, Optional, Iterator
+from typing import TextIO, MutableMapping, Callable, Optional, Iterator, Sequence
 
 import click
 import toml
 
-from skjold.models import PackageList, Package, SkjoldException
+from skjold.core import Dependency, SkjoldException
 from skjold.tasks import Configuration
 
 
-def read_poetry_lock_from(file: TextIO) -> Iterator[Package]:
+def read_poetry_lock_from(file: TextIO) -> Iterator[Dependency]:
     """Reads a poetry.lock given by path and yields 'package==version' items."""
     doc = toml.loads(file.read())
     for package in doc.get("package", []):
-        yield package["name"], package["version"]
+        yield Dependency(
+            name=package["name"], version=package["version"], source=(file.name, None)
+        )
 
 
-def read_pipfile_lock_from(file: TextIO) -> Iterator[Package]:
+def read_pipfile_lock_from(file: TextIO) -> Iterator[Dependency]:
     """Reads a Pipfile.lock given by path and yields 'package==version' items."""
     json_ = json.load(file)
     for namespace in ["develop", "default"]:
@@ -33,12 +35,14 @@ def read_pipfile_lock_from(file: TextIO) -> Iterator[Package]:
                 )
 
             package_version = pinned_package_version.replace("==", "")
-            yield package_name, package_version
+            yield Dependency(
+                name=package_name, version=package_version, source=(file.name, None)
+            )
 
 
-def read_requirements_txt_from(file: TextIO) -> Iterator[Package]:
+def read_requirements_txt_from(file: TextIO) -> Iterator[Dependency]:
     """Reads a requirements.txt given by path and yields 'package==version' items."""
-    for line in file.readlines():
+    for idx, line in enumerate(file.readlines()):
         # Skip empty lines or lines only containing a hash.
         if line.strip().startswith("--hash") or not len(line.strip()):
             continue
@@ -52,7 +56,9 @@ def read_requirements_txt_from(file: TextIO) -> Iterator[Package]:
         try:
             line = line.split(";")[0]
             package_name, package_version = line.strip().split(" ")[0].split("==")
-            yield package_name, package_version
+            yield Dependency(
+                name=package_name, version=package_version, source=(file.name, idx + 1)
+            )
         except (ValueError, KeyError):
             click.secho("Warning! ", err=True, nl=False, fg="yellow")
             click.secho(
@@ -65,18 +71,27 @@ def read_requirements_txt_from(file: TextIO) -> Iterator[Package]:
 class Format:  # pragma: no cover
     POETRY: str = "poetry.lock"
     REQUIREMENTS: str = "requirements.txt"
+    REQUIREMENTS_DEV: str = "requirements-dev.txt"
     PIPENV: str = "Pipfile.lock"
 
     SUPPORTED_FORMATS: MutableMapping[str, Callable] = {
         POETRY: read_poetry_lock_from,
         REQUIREMENTS: read_requirements_txt_from,
+        REQUIREMENTS_DEV: read_requirements_txt_from,
         PIPENV: read_pipfile_lock_from,
     }
 
 
-def extract_package_list_from(
+def extract_dependencies_from_files(
+    configuration: Configuration, files: Sequence[TextIO], format_: Optional[str] = None
+) -> Iterator[Dependency]:
+    for file in files:
+        yield from _extract_package_list_from(configuration, file, format_)
+
+
+def _extract_package_list_from(
     configuration: Configuration, file: TextIO, format_: Optional[str] = None
-) -> PackageList:
+) -> Iterator[Dependency]:
     """Extracts the list of tuples containing package name and version."""
     filename = os.path.basename(file.name)
 
@@ -89,8 +104,4 @@ def extract_package_list_from(
     if not reader_func:
         raise SkjoldException(f"Unsupported file or format '{format_}'!")
 
-    _packages = []
-    for package in reader_func(file):
-        _packages.append(package)
-
-    return _packages
+    yield from reader_func(file)
diff --git a/src/skjold/ignore.py b/src/skjold/ignore.py
index bbea3a6..15f4071 100644
--- a/src/skjold/ignore.py
+++ b/src/skjold/ignore.py
@@ -2,6 +2,8 @@ import datetime
 import os
 from typing import Dict, Tuple
 
+from packaging.utils import canonicalize_name
+
 import yaml
 
 
@@ -15,7 +17,7 @@ class SkjoldIgnore:
 
     def __init__(self, path: str):
         self._path = path
-        self._doc = {"version": "1.0", "ignore": {}}
+        self._doc = {"version": "1.1", "ignore": {}}
 
     @classmethod
     def using(cls, path: str) -> "SkjoldIgnore":
@@ -52,7 +54,7 @@ class SkjoldIgnore:
 
         self._doc["ignore"][identifier].append(
             {
-                "package": package_name,
+                "package": canonicalize_name(package_name),
                 "reason": reason,
                 "expires": expires.strftime(SkjoldIgnore.EXPIRES_FMT),
             }
@@ -69,7 +71,7 @@ class SkjoldIgnore:
             return False, {}
 
         for entry in self.entries.get(identifier, {}):
-            if entry["package"] == package_name:
+            if entry["package"] in {package_name, canonicalize_name(package_name)}:
                 dt = datetime.datetime.strptime(
                     f"{entry['expires']}", SkjoldIgnore.EXPIRES_FMT
                 )
diff --git a/src/skjold/sources/gemnasium.py b/src/skjold/sources/gemnasium.py
index 3029d73..8fe5847 100644
--- a/src/skjold/sources/gemnasium.py
+++ b/src/skjold/sources/gemnasium.py
@@ -5,10 +5,16 @@ from collections import defaultdict
 from typing import List, Tuple, Callable
 
 from packaging import specifiers
+from packaging.utils import canonicalize_name, NormalizedName
 import yaml
 
 from skjold.cvss import parse_cvss
-from skjold.models import SecurityAdvisory, SecurityAdvisorySource, SkjoldException
+from skjold.core import (
+    Dependency,
+    SecurityAdvisory,
+    SecurityAdvisorySource,
+    SkjoldException,
+)
 from skjold.tasks import register_source
 
 
@@ -52,6 +58,10 @@ class GemnasiumSecurityAdvisory(SecurityAdvisory):
     def package_name(self) -> str:
         return str(self._json["package_slug"]).replace("pypi/", "").strip()
 
+    @property
+    def canonical_name(self) -> NormalizedName:
+        return canonicalize_name(self.package_name)
+
     @property
     def summary(self) -> str:
         return f"{self._json['title']}. {self._json['description']}"
@@ -113,7 +123,7 @@ class Gemnasium(SecurityAdvisorySource):
                 if obj_fh:
                     doc = yaml.load(obj_fh, Loader=yaml.SafeLoader)
                     advisory = GemnasiumSecurityAdvisory.using(doc)
-                    self._advisories[advisory.package_name.lower()].append(advisory)
+                    self._advisories[advisory.canonical_name].append(advisory)
                 else:  # pragma: no cover
                     raise SkjoldException(
                         f"Unable to extract '{obj.name}' from source archive."
@@ -131,18 +141,18 @@ class Gemnasium(SecurityAdvisorySource):
             with open(self.path, "wb") as fh:
                 fh.write(response.read())
 
-    def has_security_advisory_for(self, package_name: str) -> bool:
-        return package_name.strip().lower() in self.advisories.keys()
+    def has_security_advisory_for(self, dependency: Dependency) -> bool:
+        return dependency.canonical_name in self.advisories.keys()
 
     def is_vulnerable_package(
-        self, package_name: str, package_version: str
+        self, dependency: Dependency
     ) -> Tuple[bool, List[SecurityAdvisory]]:
-        if not self.has_security_advisory_for(package_name):
+        if not self.has_security_advisory_for(dependency):
             return False, []
 
         advisories = []
-        for candidate in self.advisories[package_name.strip().lower()]:
-            if candidate.is_affected(package_version):
+        for candidate in self.advisories[dependency.canonical_name]:
+            if candidate.is_affected(dependency.version):
                 advisories.append(candidate)
 
         return len(advisories) > 0, advisories
diff --git a/src/skjold/sources/github.py b/src/skjold/sources/github.py
index 9320da1..b55aa23 100644
--- a/src/skjold/sources/github.py
+++ b/src/skjold/sources/github.py
@@ -5,9 +5,10 @@ from collections import defaultdict
 from typing import List, Tuple, Optional, Iterator, Dict, Any
 
 from packaging import specifiers
+from packaging.utils import canonicalize_name, NormalizedName
 import click
 
-from skjold.models import SecurityAdvisory, SecurityAdvisorySource
+from skjold.core import Dependency, SecurityAdvisory, SecurityAdvisorySource
 from skjold.tasks import register_source
 
 
@@ -36,6 +37,10 @@ class GithubSecurityAdvisory(SecurityAdvisory):
     def package_name(self) -> str:
         return str(self._json["node"]["package"]["name"])
 
+    @property
+    def canonical_name(self) -> NormalizedName:
+        return canonicalize_name(self.package_name)
+
     @property
     def ecosystem(self) -> str:
         return str(self._json["node"]["package"]["ecosystem"])
@@ -184,7 +189,7 @@ class Github(SecurityAdvisorySource):
         with open(self.path, "rb") as fh:
             for item in json.load(fh):
                 obj = GithubSecurityAdvisory.using(item)
-                self._advisories[obj.package_name].append(obj)
+                self._advisories[obj.canonical_name].append(obj)
 
     @property
     def path(self) -> str:
@@ -195,15 +200,15 @@ class Github(SecurityAdvisorySource):
         with open(self.path, "w") as fh:
             json.dump(data, fh)
 
-    def has_security_advisory_for(self, package_name: str) -> bool:
-        return package_name.strip() in self.advisories.keys()
+    def has_security_advisory_for(self, dependency: Dependency) -> bool:
+        return dependency.canonical_name in self.advisories.keys()
 
     def is_vulnerable_package(
-        self, package_name: str, package_version: str
+        self, dependency: Dependency
     ) -> Tuple[bool, List[SecurityAdvisory]]:
         advisories = []
-        for candidate in self.advisories[package_name]:
-            if candidate.is_affected(package_version):
+        for candidate in self.advisories[dependency.canonical_name]:
+            if candidate.is_affected(dependency.version):
                 advisories.append(candidate)
 
         return len(advisories) > 0, advisories
diff --git a/src/skjold/sources/osv.py b/src/skjold/sources/osv.py
index 3bcdf5e..ff2bebd 100644
--- a/src/skjold/sources/osv.py
+++ b/src/skjold/sources/osv.py
@@ -4,13 +4,19 @@ import urllib.request
 from typing import List, Tuple, Callable, Any, Sequence, Optional, MutableMapping
 
 from packaging import specifiers
-
-from skjold.models import SecurityAdvisory, SecurityAdvisorySource, SecurityAdvisoryList
+from packaging.utils import NormalizedName, canonicalize_name
+
+from skjold.core import (
+    Dependency,
+    SecurityAdvisory,
+    SecurityAdvisorySource,
+    SecurityAdvisoryList,
+)
 from skjold.tasks import register_source
 
 
 def _osv_dev_api_request(
-    package_name: str, package_version: str, ecosystem: str = "PyPI"
+    package_name: NormalizedName, package_version: str, ecosystem: str = "PyPI"
 ) -> Any:
     """Return list of vulnerabilities for a given `package_name` and `package_version` via OSV.dev API."""
 
@@ -83,6 +89,10 @@ class OSVSecurityAdvisory(SecurityAdvisory):
     def package_name(self) -> str:
         return str(self._json["name"]).strip()
 
+    @property
+    def canonical_name(self) -> NormalizedName:
+        return canonicalize_name(self.package_name)
+
     @property
     def summary(self) -> str:
         return f"{self._json['details']}"
@@ -130,15 +140,15 @@ class OSV(SecurityAdvisorySource):
     def update(self) -> None:
         pass
 
-    def has_security_advisory_for(self, package_name: str) -> bool:
+    def has_security_advisory_for(self, dependency: Dependency) -> bool:
         """Always return `True` since to ensure we always call the OSV API for every package."""
         return True
 
     def is_vulnerable_package(
-        self, package_name: str, package_version: str
+        self, dependency: Dependency
     ) -> Tuple[bool, Sequence[SecurityAdvisory]]:
 
-        findings = _osv_dev_api_request(package_name.strip().lower(), package_version)
+        findings = _osv_dev_api_request(dependency.canonical_name, dependency.version)
         if not len(findings):
             return False, []
 
@@ -150,7 +160,9 @@ class OSV(SecurityAdvisorySource):
 
         return True, advisories
 
-    def get_security_advisories(self) -> MutableMapping[str, SecurityAdvisoryList]:
+    def get_security_advisories(
+        self,
+    ) -> MutableMapping[NormalizedName, SecurityAdvisoryList]:
         raise NotImplementedError
 
 
diff --git a/src/skjold/sources/pypa.py b/src/skjold/sources/pypa.py
index 12b1cb2..b9c4b7b 100644
--- a/src/skjold/sources/pypa.py
+++ b/src/skjold/sources/pypa.py
@@ -6,7 +6,12 @@ from typing import List, Tuple
 
 import yaml
 
-from skjold.models import SecurityAdvisory, SecurityAdvisorySource, SkjoldException
+from skjold.core import (
+    SecurityAdvisory,
+    SecurityAdvisorySource,
+    SkjoldException,
+    Dependency,
+)
 from skjold.sources.osv import OSVSecurityAdvisory
 from skjold.tasks import register_source
 
@@ -38,7 +43,7 @@ class PyPAAdvisoryDB(SecurityAdvisorySource):
                     doc = yaml.load(obj_fh, Loader=yaml.SafeLoader)
                     advisories = OSVSecurityAdvisory.using(doc)
                     for advisory in advisories:
-                        self._advisories[advisory.package_name.lower()].append(advisory)
+                        self._advisories[advisory.canonical_name].append(advisory)
                 else:  # pragma: no cover
                     raise SkjoldException(
                         f"Unable to extract '{obj.name}' from source archive."
@@ -56,18 +61,18 @@ class PyPAAdvisoryDB(SecurityAdvisorySource):
             with open(self.path, "wb") as fh:
                 fh.write(response.read())
 
-    def has_security_advisory_for(self, package_name: str) -> bool:
-        return package_name.strip().lower() in self.advisories.keys()
+    def has_security_advisory_for(self, dependency: Dependency) -> bool:
+        return dependency.canonical_name in self.advisories.keys()
 
     def is_vulnerable_package(
-        self, package_name: str, package_version: str
+        self, dependency: Dependency
     ) -> Tuple[bool, List[SecurityAdvisory]]:
-        if not self.has_security_advisory_for(package_name):
+        if not self.has_security_advisory_for(dependency):
             return False, []
 
         advisories = []
-        for candidate in self.advisories[package_name.strip().lower()]:
-            if candidate.is_affected(package_version):
+        for candidate in self.advisories[dependency.canonical_name]:
+            if candidate.is_affected(dependency.version):
                 advisories.append(candidate)
 
         return len(advisories) > 0, advisories
diff --git a/src/skjold/sources/pyup.py b/src/skjold/sources/pyup.py
index b1325c3..7429585 100644
--- a/src/skjold/sources/pyup.py
+++ b/src/skjold/sources/pyup.py
@@ -6,8 +6,9 @@ from collections import defaultdict
 from typing import Tuple, List, Dict, Callable, Union, Any
 
 from packaging import specifiers
+from packaging.utils import canonicalize_name, NormalizedName
 
-from skjold.models import SecurityAdvisorySource, SecurityAdvisory
+from skjold.core import SecurityAdvisorySource, SecurityAdvisory, Dependency
 from skjold.tasks import register_source
 
 
@@ -45,6 +46,10 @@ class PyUpSecurityAdvisory(SecurityAdvisory):
     def package_name(self) -> str:
         return self._json["name"]
 
+    @property
+    def canonical_name(self) -> NormalizedName:
+        return canonicalize_name(self.package_name)
+
     @property
     def summary(self) -> str:
         return self._json["advisory"]
@@ -108,7 +113,7 @@ class PyUp(SecurityAdvisorySource):
 
             for advisory in advisories:
                 obj = PyUpSecurityAdvisory.using(package_name, advisory)
-                self._advisories[obj.package_name].append(obj)
+                self._advisories[obj.canonical_name].append(obj)
 
     def update(self) -> None:
         request_ = urllib.request.Request(
@@ -121,15 +126,15 @@ class PyUp(SecurityAdvisorySource):
         with open(self.path, "w") as fh:
             json.dump(json_, fh)
 
-    def has_security_advisory_for(self, package_name: str) -> bool:
-        return package_name.strip() in self.advisories.keys()
+    def has_security_advisory_for(self, dependency: Dependency) -> bool:
+        return dependency.canonical_name in self.advisories.keys()
 
     def is_vulnerable_package(
-        self, package_name: str, package_version: str
+        self, dependency: Dependency
     ) -> Tuple[bool, List[SecurityAdvisory]]:
         advisories = []
-        for candidate in self.advisories[package_name]:
-            if candidate.is_affected(package_version):
+        for candidate in self.advisories[dependency.canonical_name]:
+            if candidate.is_affected(dependency.version):
                 advisories.append(candidate)
 
         return len(advisories) > 0, advisories
diff --git a/src/skjold/tasks.py b/src/skjold/tasks.py
index 8c8b30b..ab15069 100644
--- a/src/skjold/tasks.py
+++ b/src/skjold/tasks.py
@@ -7,7 +7,11 @@ from typing import List, MutableMapping, Type, AbstractSet, Union, Dict, Any, Tu
 import click
 import toml
 
-from skjold.models import SecurityAdvisorySource, PackageList, SkjoldException
+from skjold.core import (
+    DependencyList,
+    SecurityAdvisorySource,
+    SkjoldException,
+)
 from skjold.ignore import SkjoldIgnore
 
 _sources: MutableMapping[str, Type[SecurityAdvisorySource]] = {}
@@ -158,6 +162,8 @@ def report(
             click.secho(finding["source"], fg="cyan", nl=False)
             click.secho(" as ", nl=False)
             click.secho(finding["identifier"], fg="yellow", nl=False)
+            click.secho(" found in ", nl=False)
+            click.secho(finding["__file__"]["path"], fg=_color, nl=False)
             click.secho(" ignored until ", nl=False)
             click.secho(finding["ignored"]["expires"], fg="cyan", nl=False)
             click.secho(".")
@@ -176,6 +182,8 @@ def report(
         click.secho(finding["source"], fg="cyan", nl=False)
         click.secho(" as ", nl=False)
         click.secho(finding["identifier"], fg="yellow", nl=False)
+        click.secho(" found in ", nl=False)
+        click.secho(finding["__file__"]["path"], fg=_color, nl=False)
         click.secho("")
 
         click.secho("")
@@ -206,7 +214,7 @@ def report(
 
 def audit(
     configuration: Configuration,
-    packages: PackageList,
+    dependencies: DependencyList,
     ignore: SkjoldIgnore,
 ) -> List[Dict[str, Any]]:
     """..."""
@@ -217,11 +225,9 @@ def audit(
             cache_dir=configuration.cache_dir, cache_expires=configuration.cache_expires
         )
 
-        for package_name, package_version in packages:
-            if source.has_security_advisory_for(package_name):
-                is_vulnerable, advisories = source.is_vulnerable_package(
-                    package_name, package_version
-                )
+        for dependency in dependencies:
+            if source.has_security_advisory_for(dependency):
+                is_vulnerable, advisories = source.is_vulnerable_package(dependency)
 
                 if is_vulnerable:
                     for advisory in advisories:
@@ -233,8 +239,8 @@ def audit(
                             {
                                 "identifier": advisory.identifier,
                                 "severity": advisory.severity,
-                                "name": package_name,
-                                "version": package_version,
+                                "name": dependency.name,
+                                "version": dependency.version,
                                 "versions": advisory.vulnerable_versions,
                                 "source": source.name,
                                 "summary": advisory.summary,
@@ -245,6 +251,10 @@ def audit(
                                     "expires": entry.get("expires"),
                                     "reason": entry.get("reason"),
                                 },
+                                "__file__": {
+                                    "path": dependency.source[0],
+                                    "lineno": dependency.source[1],
+                                },
                             }
                         )
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 482dc27..fb59aff 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -173,6 +173,43 @@ def test_cli_ensure_formats_are_handled_properly(
     assert json_[0]["source"] == "github"
 
 
+def test_cli_run_audit_with_multiple_different_files(
+    runner: click.testing.CliRunner,
+    cache_dir: str,
+    monkeypatch: MonkeyPatch,
+) -> None:
+    monkeypatch.setenv("SKJOLD_CACHE_DIR", cache_dir)
+    config = Configuration()
+    config.use({"report_only": False, "report_format": "json", "sources": ["pyup"]})
+
+    file_a = format_fixture_path_for("requirements.txt")
+    file_b = format_fixture_path_for("poetry.lock")
+    file_c = format_fixture_path_for("Pipfile.lock")
+    files = [file_a, file_b, file_c]
+
+    result = runner.invoke(
+        cli,
+        args=["audit", *files],
+        env={"SKJOLD_CACHE_DIR": cache_dir},
+        obj=config,
+    )
+
+    assert result.exception
+    assert result.exit_code == 1
+
+    findings = json.loads(result.stdout)
+    assert len(findings) > 0
+
+    sources = set({})
+    for finding in findings:
+        assert finding["name"] == "urllib3"
+        assert finding["version"] == "1.23"
+        assert finding["source"] == "pyup"
+        sources.add(finding["__file__"]["path"])
+
+    assert sources == set(files)
+
+
 def test_cli_configuration_override_via_cli(
     runner: click.testing.CliRunner,
     cache_dir: str,
diff --git a/tests/test_formats.py b/tests/test_formats.py
index 0bb39a4..f021645 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -1,13 +1,19 @@
 import io
 import os
-from typing import Optional
+from typing import Optional, List, Tuple, Iterator
 
 import pytest
 
-from skjold.formats import extract_package_list_from, read_requirements_txt_from
-from skjold.models import PackageList
+from skjold.formats import (
+    _extract_package_list_from,
+    read_requirements_txt_from,
+    extract_dependencies_from_files,
+)
+from skjold.core import Dependency
 from skjold.tasks import Configuration
 
+from packaging.utils import NormalizedName
+
 
 def format_fixture_path_for(folder: str, filename: str) -> str:
     path_ = os.path.join(
@@ -25,7 +31,7 @@ def test_extract_dependencies_using_minimal_examples(
     folder: str, filename: str
 ) -> None:
     with open(format_fixture_path_for(folder, filename)) as fh:
-        packages = list(extract_package_list_from(Configuration(), fh, None))
+        packages = list(_extract_package_list_from(Configuration(), fh, None))
         assert len(packages) > 0
 
 
@@ -46,56 +52,118 @@ def test_extract_package_versions_from_with_poetry_lock(
 ) -> None:
 
     with open(format_fixture_path_for(folder, filename)) as fh:
-        packages = list(extract_package_list_from(Configuration(), fh, format_))
+        packages = list(_extract_package_list_from(Configuration(), fh, format_))
         assert len(packages) > 0
 
 
+@pytest.mark.parametrize(
+    "name, version, expected_name, expected_version, expected_canonical_name",
+    [
+        ("PyYAML", "1.0", "PyYAML", "1.0", "pyyaml"),
+        ("Requests", "1.23.0", "Requests", "1.23.0", "requests"),
+        ("requests", "1.22.0", "requests", "1.22.0", "requests"),
+        ("Django", "1.22.0", "Django", "1.22.0", "django"),
+        (
+            "google_cloud_storage",
+            "1.0",
+            "google_cloud_storage",
+            "1.0",
+            "google-cloud-storage",
+        ),
+    ],
+)
+def test_dependency_canonicalize_name(
+    name: str,
+    version: str,
+    expected_name: str,
+    expected_version: str,
+    expected_canonical_name: NormalizedName,
+) -> None:
+    dependency = Dependency(name=name, version=version)
+
+    assert dependency.name == expected_name
+    assert dependency.version == expected_version
+    assert dependency.canonical_name == expected_canonical_name
+
+
 @pytest.mark.parametrize(
     "stdin, expected_package_list",
     [
-        ('package==0.6.0; python_version < "3.8"', [("package", "0.6.0")]),
+        ('package==0.6.0; python_version < "3.8"', [("package", "0.6.0", 1)]),
         (
             'foo==0.6.0; python_version < "3.8"\nbar==1.0.0',
-            [("foo", "0.6.0"), ("bar", "1.0.0")],
+            [("foo", "0.6.0", 1), ("bar", "1.0.0", 2)],
         ),
-        ('foo==1.4.0; python_version < "3.8"', [("foo", "1.4.0")]),
-        ('foo==1.3.0; sys_platform == "win32"', [("foo", "1.3.0")]),
+        ('foo==1.4.0; python_version < "3.8"', [("foo", "1.4.0", 1)]),
+        ('foo==1.3.0; sys_platform == "win32"', [("foo", "1.3.0", 1)]),
         # Ensure that we are able to handle dependencies with (multi-line) hashes.
         (
             "foo==1.3.0 --hash=sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612",
-            [("foo", "1.3.0")],
+            [("foo", "1.3.0", 1)],
         ),
         (
             "foo==1.3.0 --hash=sha256:deaddood...\\ \n --hash=sha256:deadbeef...\nbar==1.2.0",
-            [("foo", "1.3.0"), ("bar", "1.2.0")],
+            [("foo", "1.3.0", 1), ("bar", "1.2.0", 3)],
         ),
         # Ensure that we are able to handle comments.
         ("# comment==0.1.2", []),
         (
             'bar==1.2.0\n # comment==0.1.2\nfoo==1.3.0; sys_platform == "win32"',
-            [("bar", "1.2.0"), ("foo", "1.3.0")],
+            [("bar", "1.2.0", 1), ("foo", "1.3.0", 3)],
         ),
         # Ensure we skip invalid lines.
         (
             "bar==1.2.0\n--trusted-host internal-host\nfoo==1.3.0\n--extra-index-url http://internal-host/pypi/index/",
-            [("bar", "1.2.0"), ("foo", "1.3.0")],
+            [("bar", "1.2.0", 1), ("foo", "1.3.0", 3)],
         ),
     ],
 )
 def test_extract_package_versions_from(
-    stdin: str, expected_package_list: PackageList
+    stdin: str, expected_package_list: List[Tuple[str, str, int]]
 ) -> None:
-    packages = read_requirements_txt_from(io.StringIO(stdin))
-    assert list(packages) == expected_package_list
+    contents = io.StringIO(stdin)
+    contents.name = "<stdin>"
+    packages = read_requirements_txt_from(contents)
+
+    def _get_dependencies(items: List[Tuple[str, str, int]]) -> Iterator[Dependency]:
+        for name, version, line_no in items:
+            yield Dependency(name=name, version=version, source=("<stdin>", line_no))
+
+    assert list(packages) == list(_get_dependencies(expected_package_list))
+
+
+def test_extract_dependencies_from_files() -> None:
+    path_a = format_fixture_path_for("minimal", "requirements.txt")
+    path_b = format_fixture_path_for("minimal", "poetry.lock")
+    path_c = format_fixture_path_for("minimal", "Pipfile.lock")
+    config = Configuration()
+
+    with open(path_a) as fha:
+        with open(path_b) as fhb:
+            with open(path_c) as fhc:
+                dependencies = list(
+                    extract_dependencies_from_files(config, [fha, fhb, fhc])
+                )
+
+    sources = set()
+    for dep in dependencies:
+        assert dep.canonical_name
+        assert dep.name
+        assert dep.version
+        assert dep.source[0]
+        sources.add(dep.source[0])
+
+    assert sources == set({path_a, path_b, path_c})
 
 
 def test_extract_package_versions_from_file_with_hashes() -> None:
-    with open(format_fixture_path_for("pip", "requirements_with_hashes.txt")) as fh:
+    path_ = format_fixture_path_for("pip", "requirements_with_hashes.txt")
+    with open(path_) as fh:
         packages = read_requirements_txt_from(fh)
         assert list(packages) == [
-            ("appdirs", "1.4.3"),
-            ("argh", "0.26.2"),
-            ("aspy.yaml", "1.3.0"),
-            ("atomicwrites", "1.3.0"),
-            ("attrs", "19.3.0"),
+            Dependency("appdirs", "1.4.3", (path_, 1)),
+            Dependency("argh", "0.26.2", (path_, 4)),
+            Dependency("aspy.yaml", "1.3.0", (path_, 7)),
+            Dependency("atomicwrites", "1.3.0", (path_, 10)),
+            Dependency("attrs", "19.3.0", (path_, 13)),
         ]
diff --git a/tests/test_gemnasium.py b/tests/test_gemnasium.py
index 1374b54..d157401 100644
--- a/tests/test_gemnasium.py
+++ b/tests/test_gemnasium.py
@@ -1,6 +1,7 @@
 import os
 import pytest
 from typing import Any
+from skjold.core import Dependency
 
 from skjold.sources.gemnasium import GemnasiumSecurityAdvisory, Gemnasium
 
@@ -22,6 +23,7 @@ def test_ensure_gemnasium_advisory_from_yaml_with_cvss3_and_cvss2() -> None:
     """Ensure that we are able to create GemnasiumSecurityAdvisories from a given YAML document."""
     obj = GemnasiumSecurityAdvisory.using(gemnasium_advisory_yml("CVE-2019-19844.yml"))
     assert obj.package_name == "Django"
+    assert obj.canonical_name == "django"
     assert obj.identifier == "CVE-2019-19844"
     assert obj.source == "gemnasium"
     assert obj.severity == "CRITICAL"
@@ -44,6 +46,7 @@ def test_ensure_gemnasium_advisory_from_yaml_with_cvss2_only() -> None:
     obj._json.pop("cvss_v3", None)
 
     assert obj.package_name == "Pillow"
+    assert obj.canonical_name == "pillow"
     assert obj.identifier == "CVE-2014-1932"
     assert obj.source == "gemnasium"
     assert obj.severity == "MEDIUM"
@@ -129,6 +132,7 @@ def test_ensure_is_affected(
 ) -> None:
     obj = GemnasiumSecurityAdvisory.using(doc)
     assert obj.package_name == "package"
+    assert obj.canonical_name == "package"
     assert len(obj.vulnerable_version_range) == len(doc["affected_range"].split("||"))
     assert obj.is_affected(package_version) is is_vulnerable
 
@@ -141,16 +145,16 @@ def test_ensure_gemnasium_update(cache_dir: str) -> None:
     assert len(source._advisories) > 0
     assert source.total_count > 100
 
-    assert source.has_security_advisory_for("Django")
+    assert source.has_security_advisory_for(Dependency("Django", "X.X.X"))
 
-    found, findings = source.is_vulnerable_package("doesnotexist", "1.0.0")
+    found, findings = source.is_vulnerable_package(Dependency("doesnotexist", "1.0.0"))
     assert found is False and len(findings) == 0
 
-    found, findings = source.is_vulnerable_package("Django", "2.2.8")
+    found, findings = source.is_vulnerable_package(Dependency("Django", "2.2.8"))
     assert found and len(findings) > 0
 
-    found, findings = source.is_vulnerable_package("django", "2.2.8")
+    found, findings = source.is_vulnerable_package(Dependency("django", "2.2.8"))
     assert found and len(findings) > 0
 
-    found, findings = source.is_vulnerable_package("Django", "2.3.0")
+    found, findings = source.is_vulnerable_package(Dependency("Django", "2.3.0"))
     assert found is False and len(findings) == 0
diff --git a/tests/test_osv.py b/tests/test_osv.py
index ff83c9b..1323c2c 100644
--- a/tests/test_osv.py
+++ b/tests/test_osv.py
@@ -3,6 +3,9 @@ from typing import Any
 
 import pytest
 import yaml
+from skjold.core import Dependency
+
+from packaging.utils import NormalizedName
 
 from skjold.sources.osv import OSV, OSVSecurityAdvisory, _osv_dev_api_request
 
@@ -111,7 +114,7 @@ def test_ensure_is_affected(
 
 
 def test_osv_advisory_with_vulnerable_package_via_osv_api() -> None:
-    vulnerabilities = _osv_dev_api_request("jinja2", "2.11.2")
+    vulnerabilities = _osv_dev_api_request(NormalizedName("jinja2"), "2.11.2")
     assert vulnerabilities[0]
 
     obj = OSVSecurityAdvisory.using(vulnerabilities[0])[0]
@@ -135,16 +138,16 @@ def test_ensure_pypi_advisory_db_update(cache_dir: str) -> None:
     assert source.total_count == 0
     assert len(source._advisories) == 0
 
-    assert source.has_security_advisory_for("ansible")
+    assert source.has_security_advisory_for(Dependency("ansible", "0.0.0"))
 
-    found, findings = source.is_vulnerable_package("doesnotexist", "1.0.0")
+    found, findings = source.is_vulnerable_package(Dependency("doesnotexist", "1.0.0"))
     assert found is False and len(findings) == 0
 
-    found, findings = source.is_vulnerable_package("ansible", "2.8.1")
+    found, findings = source.is_vulnerable_package(Dependency("ansible", "2.8.1"))
     assert found and len(findings) > 0
 
-    found, findings = source.is_vulnerable_package("ansible", "2.8.3")
+    found, findings = source.is_vulnerable_package(Dependency("ansible", "2.8.3"))
     assert found and len(findings) > 0
 
-    found, findings = source.is_vulnerable_package("httpx", "0.19.0")
+    found, findings = source.is_vulnerable_package(Dependency("httpx", "0.19.0"))
     assert found is True and len(findings) > 0
diff --git a/tests/test_pypa.py b/tests/test_pypa.py
index 7b83d8c..2dd16ee 100644
--- a/tests/test_pypa.py
+++ b/tests/test_pypa.py
@@ -1,3 +1,4 @@
+from skjold.core import Dependency
 from skjold.sources.pypa import PyPAAdvisoryDB
 
 
@@ -10,16 +11,16 @@ def test_ensure_pypi_advisory_db_update(cache_dir: str) -> None:
     assert len(source._advisories) > 0
     assert source.total_count > 100
 
-    assert source.has_security_advisory_for("ansible")
+    assert source.has_security_advisory_for(Dependency("ansible", "0.0.0"))
 
-    found, findings = source.is_vulnerable_package("doesnotexist", "1.0.0")
+    found, findings = source.is_vulnerable_package(Dependency("doesnotexist", "1.0.0"))
     assert found is False and len(findings) == 0
 
-    found, findings = source.is_vulnerable_package("ansible", "2.8.1")
+    found, findings = source.is_vulnerable_package(Dependency("ansible", "2.8.1"))
     assert found and len(findings) > 0
 
-    found, findings = source.is_vulnerable_package("ansible", "2.8.3")
+    found, findings = source.is_vulnerable_package(Dependency("ansible", "2.8.3"))
     assert found and len(findings) > 0
 
-    found, findings = source.is_vulnerable_package("httpx", "0.19.0")
+    found, findings = source.is_vulnerable_package(Dependency("httpx", "0.19.0"))
     assert found and len(findings) >= 0
diff --git a/tests/test_pyup.py b/tests/test_pyup.py
index fbf81da..613f815 100644
--- a/tests/test_pyup.py
+++ b/tests/test_pyup.py
@@ -4,6 +4,7 @@ from typing import List, Dict, Any
 from unittest import mock
 
 import pytest
+from skjold.core import Dependency
 
 from skjold.sources.pyup import PyUpSecurityAdvisory, PyUp
 
@@ -26,6 +27,7 @@ from skjold.sources.pyup import PyUpSecurityAdvisory, PyUp
 def test_ensure_using_build_obj(name: str, raw: Dict[Any, Any]) -> None:
     obj = PyUpSecurityAdvisory.using(name, raw)
     assert obj.package_name == "package_name"
+    assert obj.canonical_name == "package-name"
     assert obj.identifier == "pyup.io-XXXXXX"
     assert obj.source == "pyup"
     assert obj.summary == "Advisory summary."
@@ -84,22 +86,21 @@ def test_ensure_is_affected_single(
 
 
 @pytest.mark.parametrize(
-    "source_name, package_name, package_version, is_vulnerable",
+    "source_name, dependency, is_vulnerable",
     [
-        ("pyup", "werkzeug", "0.11.10", True),
-        ("pyup", "werkzeug", "0.9", True),
-        ("pyup", "werkzeug", "0.11.11", True),
-        ("pyup", "werkzeug", "0.12", True),
-        ("pyup", "werkzeug", "0.12", True),
-        ("pyup", "werkzeug", "1.0.0", False),
-        ("pyup", "werkzeug", "1.0.1", False),
-        ("pyup", "does-not-exist", "0", False),
+        ("pyup", Dependency("werkzeug", "0.11.10"), True),
+        ("pyup", Dependency("werkzeug", "0.9"), True),
+        ("pyup", Dependency("werkzeug", "0.11.11"), True),
+        ("pyup", Dependency("werkzeug", "0.12"), True),
+        ("pyup", Dependency("werkzeug", "0.12"), True),
+        ("pyup", Dependency("werkzeug", "1.0.0"), False),
+        ("pyup", Dependency("werkzeug", "1.0.1"), False),
+        ("pyup", Dependency("does-not-exist", "0"), False),
     ],
 )
 def test_ensure_source_is_affected_single(
     source_name: str,
-    package_name: str,
-    package_version: str,
+    dependency: Dependency,
     is_vulnerable: bool,
     cache_dir: str,
 ) -> None:
@@ -113,9 +114,9 @@ def test_ensure_source_is_affected_single(
     assert source.total_count > 0
 
     if is_vulnerable:
-        assert source.has_security_advisory_for(package_name)
+        assert source.has_security_advisory_for(dependency)
 
-    vulnerable, _ = source.is_vulnerable_package(package_name, package_version)
+    vulnerable, _ = source.is_vulnerable_package(dependency)
     assert vulnerable == is_vulnerable
 
 
diff --git a/tests/test_sources.py b/tests/test_sources.py
index 3ecb96d..ea6ef7a 100644
--- a/tests/test_sources.py
+++ b/tests/test_sources.py
@@ -3,8 +3,14 @@ from typing import List, Any, Tuple
 
 import pytest
 
-from skjold.models import SecurityAdvisorySource, SecurityAdvisory, SkjoldException
+from skjold.core import (
+    Dependency,
+    SecurityAdvisorySource,
+    SecurityAdvisory,
+    SkjoldException,
+)
 from skjold.tasks import register_source, is_registered_source, Configuration
+from packaging.utils import NormalizedName
 
 
 class DummyAdvisory(SecurityAdvisory):
@@ -18,7 +24,11 @@ class DummyAdvisory(SecurityAdvisory):
 
     @property
     def package_name(self) -> str:
-        return "dummy"
+        return "DuMmY"
+
+    @property
+    def canonical_name(self) -> NormalizedName:
+        return NormalizedName(self.package_name)
 
     @property
     def url(self) -> str:
@@ -59,15 +69,15 @@ class DummyAdvisorySource(SecurityAdvisorySource):
         pass
 
     def update(self) -> None:
-        self._advisories = {"single": [DummyAdvisory()]}
+        self._advisories = {NormalizedName("single"): [DummyAdvisory()]}
 
-    def has_security_advisory_for(self, package_name: str) -> bool:
-        if package_name in ["vulnerable"]:
+    def has_security_advisory_for(self, dependency: Dependency) -> bool:
+        if dependency.canonical_name in ["vulnerable"]:
             return True
         return False
 
     def is_vulnerable_package(
-        self, package_name: str, package_version: str
+        self, dependency: Dependency
     ) -> Tuple[bool, List[SecurityAdvisory]]:
         return False, []