Repository: EndlessTrax/pyanchor (language: python)

Issue:
Documentation for Contributing / Testing PyAnchor
Contribution and "How to Test" documentation needed for future contributors. 


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index fdb9f12..9fd870d 100644
--- a/README.md
+++ b/README.md
@@ -75,12 +75,29 @@ Example:
 As you can see the `results` attribute is a dictionary containing all response codes returned as a
 dictionary key, with a list of URLs that achieve that response code as the dictionary value.
 
-## What's next?
+### Analyzing Links
 
-The plan for PyAnchor is to add further analysis of anchor tags, such as missing attributes and
-security considerations.
+PyAnchor give you the ability to use the `LinkAnalysis` class to check the links in a given URL for unsafe and obsolete attributes.
 
-After that, who knows? 🤷‍♂️
+To check for obsolete attributes use the `obsolete_attrs` property:
+
+```
+>>> from pyanchor.link_checker import LinkAnalysis
+>>> r = LinkAnalysis("https://mysite.com/")
+>>> r.obsolete_attrs
+{'/about/link-1': ['charset', 'rev'], '/about/link-2': ['name']}
+```
+
+Likewise you can check for unsafe linkes with `unsafe_attrs`:
+
+```
+>>> from pyanchor.link_checker import LinkAnalysis
+>>> r = LinkAnalysis("https://mysite.com/")
+>>> r.unsafe_attrs
+{<a href="/about/link-4" target="_blank">Link 4</a>: True, <a href="/about/link-5" rel="noreferrer noopener" target="_blank">Link 5</a>: False}
+```
+
+Any link that **does not** include `rel="noopener"` when the `target` attribute is used will return `True`. As in, **it is True that this link is unsafe**. Therfore, links with appropriate attributes will return `False`.
 
 ## Feedback
 
@@ -98,6 +115,18 @@ And if you would prefer to donate to me personally instead,
 [you can sponsor me on Github](https://github.com/sponsors/EndlessTrax)? 🤓
 
 
+## How to Contribute
+
+Please **raise an issue** before making a PR, so that the issue and implementation can be discussed before you write any code. **This will save you time**, and increase the chances of your PR being merged without significant changes. 
+
+Please make PR's on a **new branch**, and _not_ on main/master. 
+
+Please **format you code** with [Black](https://pypi.org/project/black/).
+
+Please **include tests** for any PR's that include code (unless current tests cover your code contribution).
+
+
+
 ## Contributors
 
 Thank you to:
diff --git a/pyanchor/__init__.py b/pyanchor/__init__.py
index a3dd44a..275dafe 100644
--- a/pyanchor/__init__.py
+++ b/pyanchor/__init__.py
@@ -1,3 +1,3 @@
 """Check you site for broken links!"""
 
-__version__ = '0.4'
\ No newline at end of file
+__version__ = "0.5"
diff --git a/pyanchor/cli.py b/pyanchor/cli.py
index 0351514..0b3e0b3 100644
--- a/pyanchor/cli.py
+++ b/pyanchor/cli.py
@@ -8,7 +8,14 @@
 
 
 def print_results(links: dict, verbose: bool) -> int:
-    """Simple utility function to print to terminal"""
+    """Simple utility function to print to terminal
+    
+    Args:
+        Dictionary of links from LinkResults.results
+    Returns:
+        Two integers for the number of successful and unsuccessful links checked.
+    """
+
     num_of_successful_links = 0
     num_of_failed_links = 0
     for http_code, url_list in links.items():
@@ -29,7 +36,8 @@ def print_results(links: dict, verbose: bool) -> int:
     return num_of_successful_links, num_of_failed_links
 
 
-def print_totals(success:int, failed:int):
+def print_totals(success: int, failed: int):
+    """Prints results to terminal"""
     typer.echo("========================")
     typer.echo(f"TOTAL LINKS CHECKED: {success + failed}")
     typer.echo(f"FAILED: {failed}")
@@ -42,7 +50,9 @@ def main(
         False, "--sitemap", help="Use if the URL is a sitemap.xml link"
     ),
     verbose: bool = typer.Option(
-        False, "--verbose", help="By default all 200 responses will be hidden from the final output. Use verbose to view ALL results"
+        False,
+        "--verbose",
+        help="By default all 200 responses will be hidden from the final output. Use verbose to view ALL results",
     ),
 ):
     """Check for broken links on any given web page. Pass in a sitemap URL to
@@ -69,12 +79,12 @@ def main(
             link_results = LinkResults(_url).results
             if len(link_results) > 0:
                 all_results.append(link_results)
-        
+
         success_totals = 0
         failed_totals = 0
         for results_dict in all_results:
             _success, _failed = print_results(results_dict, verbose=verbose)
-            success_totals =  success_totals + _success
+            success_totals = success_totals + _success
             failed_totals = failed_totals + _failed
 
         print_totals(success_totals, failed_totals)
diff --git a/pyanchor/link_checker.py b/pyanchor/link_checker.py
index 2b1495c..45d499f 100644
--- a/pyanchor/link_checker.py
+++ b/pyanchor/link_checker.py
@@ -1,6 +1,6 @@
 """The main parser for finding and checking links
 
-The LinkResults class expects a URL as it's only argument. It will return a 
+The LinkResults class expects a URL as its only argument. It will return a 
 dictionary of all links on the given html page which is stores in the object.results 
 attribute. The returning dictionary consists of the link (dict value) and the http 
 response (dict key).
@@ -8,13 +8,25 @@
     Typical usage example:
 
     results = LinkResults(url).results
+
+The LinkAnalysis class expects a URL as its only argument, also. It returns a 
+dictionary of all links that have the `target` attribute, and then analyzes to 
+see if they are safe links (i.e. have a `rel` attribute, too). Links are also 
+checked for obsolote attributes.
+
+    Typical usage example:
+
+    obsolete = LinkAnalysis(url).obsolete_attrs
+    or
+    unsafe = LinkAnalysis(url).unsafe_attrs
 """
 
 import requests
 from bs4 import BeautifulSoup
 import re
 
-class LinkResults:
+
+class AllTags:
     def __init__(self, url: str):
         if url.endswith("/"):
             self.base_url = url
@@ -22,11 +34,31 @@ def __init__(self, url: str):
             self.base_url = url + "/"
 
         self.all_atags = self.find_all_atags(self.base_url)
-        self.results = self.build_results_dictionary()
 
     def __str__(self) -> str:
         return f"All links for {self.base_url}"
 
+    def find_all_atags(self, url: str):
+        """Find all anchor tags on a given URL.
+        
+        Args:
+            url: A URL string
+
+        Returns:
+            A bs4.element.ResultSet object
+        """
+
+        r = requests.get(url)
+        if r.status_code == 200:
+            soup = BeautifulSoup(r.content, "html.parser")
+            return soup.find_all("a")
+
+
+class LinkResults(AllTags):
+    def __init__(self, url: str):
+        super().__init__(url)
+        self.results = self.build_results_dictionary()
+
     def check_link_for_http_scheme(self, href: str) -> str:
         """Checks a link for http scheme.
         
@@ -54,32 +86,16 @@ def check_link_for_http_scheme(self, href: str) -> str:
             return self.base_url + re.sub("../", "", href)
 
         else:
-            return None # Catches any links starting with #
-
-
-    def find_all_atags(self, url: str):
-        """Find all anchor tags on a given URL.
-        
-        Args:
-            url: A URL string
-
-        Returns:
-            A bs4.element.ResultSet object
-        """
-
-        r = requests.get(url)
-        if r.status_code == 200:
-            soup = BeautifulSoup(r.content, "html.parser")
-            return soup.find_all("a")
+            return None  # Catches any links starting with hash(#)
 
     def build_results_dictionary(self) -> dict:
         """Build the final results dictionary.
         
-        Once all_tags has been populated, the final results dictionary is build by 
-        testing each link. 
+        Once all_tags has been populated in the AllTags base class, the final 
+        results dictionary is build by testing each link.
 
         Returns:
-            A dictionary with the key = the HTTP response, 
+            A dictionary with the key = the HTTP response,
             and the value = a List of URLs that achieved that response code.
         """
 
@@ -100,3 +116,69 @@ def build_results_dictionary(self) -> dict:
             pass
 
         return results
+
+
+class LinkAnalysis(AllTags):
+    def __init__(self, url: str):
+        super().__init__(url)
+        self.obsolete_attrs = self.obsolete_attributes(self.all_atags)
+        self.unsafe_attrs = self.unsafe_attributes(self.all_atags)
+
+    def obsolete_attributes(self, links) -> dict:
+        """ Check links for the presence of obsolete attributes
+        
+        Args:
+            links: Takes a bs4 ResultSet which is returned from AllTags in self.all_atags
+
+        Returns:
+            Returns a dictionary with the key being the `href` of the link, 
+            and the value being a `List` of obsolete attributes that the link has. 
+            Any link with zero obsolete attributes is ingnored.
+
+        """
+
+        OBSOLETE_ATTRS = ("charset", "coords", "name", "rev", "shape")
+
+        return_dict = dict()
+        for link in links:
+            obs_link_attrs = []
+            for attribute in OBSOLETE_ATTRS:
+                if link.get(attribute):
+                    obs_link_attrs.append(attribute)
+
+            if len(obs_link_attrs) > 0:
+                href = link["href"]
+                if href in return_dict:
+                    for _attr in obs_link_attrs:
+                        return_dict[href].append(_attr)
+                else:
+                    return_dict[href] = obs_link_attrs
+
+        return return_dict
+
+    def unsafe_attributes(self, links) -> dict:
+        """ Checks to see if links are unsafe.
+
+        All links with the `target` attribute are checked for the presence/absence
+        of a `rel="noopener"`. This should be present on all anchor tags with the
+        `target` attribute to ensure the linked site does not have access to 
+        window.opener property.
+        
+        For more details: https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/noopener 
+
+        Args:
+            links: Takes a bs4 ResultSet which is returned from AllTags in self.all_atags
+
+        Returns:
+            Returns a dictionary with the key being the full anchor tag checked, 
+            and the value being a `Bool`. False = safe link, True = unsafe.
+        """
+        return_dict = dict()
+        for link in links:
+            if link.get("target"):
+                if link.get("rel") and "noopener" in link.get("rel"):
+                    return_dict[link] = False
+                else:
+                    return_dict[link] = True
+
+        return return_dict


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c6905ee..cfcf6f7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,11 +3,11 @@
 from threading import Thread
 from tests.test_webapp.app import app
 
-class ServerThread(Thread):
 
+class ServerThread(Thread):
     def __init__(self, app):
         Thread.__init__(self)
-        self.server = make_server('127.0.0.1', 5000, app)
+        self.server = make_server("127.0.0.1", 5000, app)
         self.ctx = app.app_context()
         self.ctx.push()
 
@@ -17,9 +17,10 @@ def run(self):
     def shutdown(self):
         self.server.shutdown()
 
+
 @pytest.fixture(scope="session")
 def server():
     server = ServerThread(app)
     server.start()
     yield server
-    server.shutdown()
\ No newline at end of file
+    server.shutdown()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index e619773..af42336 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -7,7 +7,7 @@
 runner = CliRunner()
 
 
-@pytest.mark.usefixtures('server')
+@pytest.mark.usefixtures("server")
 class TestCli:
     def test_exception_on_invalid_url_http_scheme(self):
         result = runner.invoke(app, ["google.com"])
diff --git a/tests/test_link_checker.py b/tests/test_link_checker.py
index db82e4f..9c29e60 100644
--- a/tests/test_link_checker.py
+++ b/tests/test_link_checker.py
@@ -1,20 +1,38 @@
 import pytest
 
-from pyanchor.link_checker import LinkResults
+from pyanchor.link_checker import LinkAnalysis, LinkResults
 
 
 @pytest.fixture
-def example_object():
+def example_LinkResults_object():
     return LinkResults("http://127.0.0.1:5000/")
 
 
-class TestLinkChecker:
-    def test_results_is_dict(self, example_object):
-        assert isinstance(example_object.results, dict)
+@pytest.fixture
+def example_LinkAnalysis_object():
+    return LinkAnalysis("http://127.0.0.1:5000/")
+
+
+class TestLinkResults:
+    def test_results_is_dict(self, example_LinkResults_object):
+        assert isinstance(example_LinkResults_object.results, dict)
+
+    def test_link_result_class_str(self, example_LinkResults_object):
+        assert str(example_LinkResults_object) == "All links for http://127.0.0.1:5000/"
+
+    def test_successful_parse_relative_links(self, example_LinkResults_object):
+        assert "http://127.0.0.1:5000/rel" in example_LinkResults_object.results[200]
+        assert "http://127.0.0.1:5000/rel2" in example_LinkResults_object.results[200]
+
+
+class TestLinkAnalysis:
+    def test_obsolete_attrs_returns_dict(self, example_LinkAnalysis_object):
+        assert isinstance(example_LinkAnalysis_object.obsolete_attrs, dict)
 
-    def test_link_result_class_str(self, example_object):
-        assert str(example_object) == "All links for http://127.0.0.1:5000/"
+    def test_obsolete_attrs_on_tag(self, example_LinkAnalysis_object):
+        attr_list = example_LinkAnalysis_object.obsolete_attrs
+        assert attr_list["/about/link-1"][0] == "charset"
+        assert attr_list["/about/link-2"][0] == "name"
 
-    def test_successful_parse_relative_links(self, example_object):
-        assert "http://127.0.0.1:5000/rel" in example_object.results[200]
-        assert "http://127.0.0.1:5000/rel2" in example_object.results[200]
+    def test_unsafe_attrs_returns_dict(self, example_LinkAnalysis_object):
+        assert isinstance(example_LinkAnalysis_object.unsafe_attrs, dict)
diff --git a/tests/test_webapp/app.py b/tests/test_webapp/app.py
index 2970b9b..1e2d6f2 100644
--- a/tests/test_webapp/app.py
+++ b/tests/test_webapp/app.py
@@ -29,18 +29,22 @@ def about_link_2():
 def contact():
     return render_template("success.html", name="Contact")
 
+
 @app.route("/rel")
 def relative_link():
-    return render_template("success.html",name="Rel")
+    return render_template("success.html", name="Rel")
+
 
 @app.route("/rel2")
 def relative_link2():
-    return render_template("success.html",name="Rel2")       
+    return render_template("success.html", name="Rel2")
+
 
 @app.route("/login")
 def login():
     return render_template("error.html", status_code=401), 401
 
+
 @app.route("/500")
 def five_hundred():
     return render_template("error.html", status_code=500), 500
@@ -48,9 +52,8 @@ def five_hundred():
 
 @app.route("/sitemap.xml")
 def sitemap():
-    return render_template('sitemap.xml')
+    return render_template("sitemap.xml")
 
 
 if __name__ == "__main__":
     app.run()
-
diff --git a/tests/test_webapp/templates/index.html b/tests/test_webapp/templates/index.html
index 62f11b4..a1f96f9 100644
--- a/tests/test_webapp/templates/index.html
+++ b/tests/test_webapp/templates/index.html
@@ -17,5 +17,14 @@
     <a href="/dhjaow">Failing Link</a>
     <a href="/login">Login Link</a>
     <a href="/500">500 Error</a>
+
+    <!-- These have varied attributes to test LinkAnalysis -->
+    <a href="/about/link-1" charset="utf-8">Link 1</a>
+    <a href="/about/link-2" name="test">Link 2</a>
+    <a href="/about/link-1" rev="test" coords="testing">Link 1 Repeat</a>
+
+    <!-- Checks for `rel` attr on links with `target` attr -->
+    <a href="/about/link-4" target="_blank">Link 4</a>
+    <a href="/about/link-5" target="_blank" rel="noreferrer noopener">Link 5</a>
   </body>
 </html>
diff --git a/tests/test_webapp/templates/sitemap.xml b/tests/test_webapp/templates/sitemap.xml
index 08fea8a..8253afa 100644
--- a/tests/test_webapp/templates/sitemap.xml
+++ b/tests/test_webapp/templates/sitemap.xml
@@ -22,6 +22,21 @@
         <lastmod>2020-09-03</lastmod>
         <changefreq>weekly</changefreq>
     </url>
+    <url>
+        <loc>http://127.0.0.1:5000/about/link-3</loc>
+        <lastmod>2020-09-03</lastmod>
+        <changefreq>weekly</changefreq>
+    </url>
+    <url>
+        <loc>http://127.0.0.1:5000/about/link-4</loc>
+        <lastmod>2020-09-03</lastmod>
+        <changefreq>weekly</changefreq>
+    </url>
+    <url>
+        <loc>http://127.0.0.1:5000/about/link-5</loc>
+        <lastmod>2020-09-03</lastmod>
+        <changefreq>weekly</changefreq>
+    </url>
     <url>
         <loc>http://127.0.0.1:5000/contact</loc>
         <lastmod>2020-09-03</lastmod>