|
| 1 | +import pytest |
| 2 | +import re |
| 3 | +import rstr |
| 4 | + |
| 5 | +from sherlock_project.sherlock import sherlock |
| 6 | +from sherlock_project.notify import QueryNotify |
| 7 | +from sherlock_project.result import QueryResult, QueryStatus |
| 8 | + |
| 9 | + |
| 10 | +FALSE_POSITIVE_ATTEMPTS: int = 2 # Since the usernames are randomly generated, it's POSSIBLE that a real username can be hit |
| 11 | +FALSE_POSITIVE_QUANTIFIER_UPPER_BOUND: int = 15 # If a pattern uses quantifiers such as `+` `*` or `{n,}`, limit the upper bound (0 to disable) |
| 12 | +FALSE_POSITIVE_DEFAULT_PATTERN: str = r'^[a-zA-Z0-9]{7,20}$' # Used in absence of a regexCheck entry |
| 13 | + |
| 14 | + |
| 15 | +def set_pattern_upper_bound(pattern: str, upper_bound: int = FALSE_POSITIVE_QUANTIFIER_UPPER_BOUND) -> str: |
| 16 | + """Set upper bound for regex patterns that use quantifiers such as `+` `*` or `{n,}`.""" |
| 17 | + def replace_upper_bound(match: re.Match) -> str: # type: ignore |
| 18 | + lower_bound: int = int(match.group(1)) if match.group(1) else 0 # type: ignore |
| 19 | + upper_bound = upper_bound if lower_bound < upper_bound else lower_bound # type: ignore # noqa: F823 |
| 20 | + return f'{{{lower_bound},{upper_bound}}}' |
| 21 | + |
| 22 | + pattern = re.sub(r'(?<!\\)\{(\d+),\}', replace_upper_bound, pattern) # {n,} # type: ignore |
| 23 | + pattern = re.sub(r'(?<!\\)\+', f'{{1,{upper_bound}}}', pattern) # + |
| 24 | + pattern = re.sub(r'(?<!\\)\*', f'{{0,{upper_bound}}}', pattern) # * |
| 25 | + |
| 26 | + return pattern |
| 27 | + |
| 28 | +def false_positive_check(sites_info: dict[str, dict[str, str]], site: str, pattern: str) -> QueryStatus: |
| 29 | + """Check if a site is likely to produce false positives.""" |
| 30 | + status: QueryStatus = QueryStatus.UNKNOWN |
| 31 | + |
| 32 | + for _ in range(FALSE_POSITIVE_ATTEMPTS): |
| 33 | + query_notify: QueryNotify = QueryNotify() |
| 34 | + username: str = rstr.xeger(pattern) |
| 35 | + |
| 36 | + result: QueryResult | str = sherlock( |
| 37 | + username=username, |
| 38 | + site_data=sites_info, |
| 39 | + query_notify=query_notify, |
| 40 | + )[site]['status'] |
| 41 | + |
| 42 | + if not hasattr(result, 'status'): |
| 43 | + raise TypeError(f"Result for site {site} does not have 'status' attribute. Actual result: {result}") |
| 44 | + if type(result.status) is not QueryStatus: # type: ignore |
| 45 | + raise TypeError(f"Result status for site {site} is not of type QueryStatus. Actual type: {type(result.status)}") # type: ignore |
| 46 | + status = result.status # type: ignore |
| 47 | + |
| 48 | + if status in (QueryStatus.AVAILABLE, QueryStatus.WAF): |
| 49 | + return status |
| 50 | + |
| 51 | + return status |
| 52 | + |
| 53 | + |
| 54 | +def false_negative_check(sites_info: dict[str, dict[str, str]], site: str) -> QueryStatus: |
| 55 | + """Check if a site is likely to produce false negatives.""" |
| 56 | + status: QueryStatus = QueryStatus.UNKNOWN |
| 57 | + query_notify: QueryNotify = QueryNotify() |
| 58 | + |
| 59 | + result: QueryResult | str = sherlock( |
| 60 | + username=sites_info[site]['username_claimed'], |
| 61 | + site_data=sites_info, |
| 62 | + query_notify=query_notify, |
| 63 | + )[site]['status'] |
| 64 | + |
| 65 | + if not hasattr(result, 'status'): |
| 66 | + raise TypeError(f"Result for site {site} does not have 'status' attribute. Actual result: {result}") |
| 67 | + if type(result.status) is not QueryStatus: # type: ignore |
| 68 | + raise TypeError(f"Result status for site {site} is not of type QueryStatus. Actual type: {type(result.status)}") # type: ignore |
| 69 | + status = result.status # type: ignore |
| 70 | + |
| 71 | + return status |
| 72 | + |
| 73 | +@pytest.mark.validate_targets |
| 74 | +@pytest.mark.online |
| 75 | +class Test_All_Targets: |
| 76 | + |
| 77 | + @pytest.mark.validate_targets_fp |
| 78 | + def test_false_pos(self, chunked_sites: dict[str, dict[str, str]]): |
| 79 | + """Iterate through all sites in the manifest to discover possible false-positive inducting targets.""" |
| 80 | + pattern: str |
| 81 | + for site in chunked_sites: |
| 82 | + try: |
| 83 | + pattern = chunked_sites[site]['regexCheck'] |
| 84 | + except KeyError: |
| 85 | + pattern = FALSE_POSITIVE_DEFAULT_PATTERN |
| 86 | + |
| 87 | + if FALSE_POSITIVE_QUANTIFIER_UPPER_BOUND > 0: |
| 88 | + pattern = set_pattern_upper_bound(pattern) |
| 89 | + |
| 90 | + result: QueryStatus = false_positive_check(chunked_sites, site, pattern) |
| 91 | + assert result is QueryStatus.AVAILABLE, f"{site} produced false positive with pattern {pattern}, result was {result}" |
| 92 | + |
| 93 | + @pytest.mark.validate_targets_fn |
| 94 | + def test_false_neg(self, chunked_sites: dict[str, dict[str, str]]): |
| 95 | + """Iterate through all sites in the manifest to discover possible false-negative inducting targets.""" |
| 96 | + for site in chunked_sites: |
| 97 | + result: QueryStatus = false_negative_check(chunked_sites, site) |
| 98 | + assert result is QueryStatus.CLAIMED, f"{site} produced false negative, result was {result}" |
| 99 | + |
0 commit comments