diff --git a/.github/.pre-commit-config.yaml b/.github/.pre-commit-config.yaml index e1f1fa33c..d9f96451b 100644 --- a/.github/.pre-commit-config.yaml +++ b/.github/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: # Python linting with Ruff - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.7 + rev: v0.15.11 hooks: - id: ruff name: ruff-lint diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7e63182b3..f5695ad25 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,3 +1 @@ -GreedyBear is handled by the same maintainers of [IntelOwl](https://github.com/intelowlproject/IntelOwl/). - -So, please refer to the [Contribute guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Contribute) \ No newline at end of file +Please refer to our [Contribution guidelines](https://github.com/GreedyBear-Project/GreedyBear/wiki/Contribute). diff --git a/README.md b/README.md index 6269d1b8c..a7ff51d44 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,25 @@

GreedyBear

# GreedyBear -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/intelowlproject/Greedybear)](https://github.com/intelowlproject/Greedybear/releases) -[![GitHub Repo stars](https://img.shields.io/github/stars/intelowlproject/Greedybear?style=social)](https://github.com/intelowlproject/Greedybear/stargazers) -[![Twitter Follow](https://img.shields.io/twitter/follow/intel_owl?style=social)](https://twitter.com/intel_owl) -[![Linkedin](https://img.shields.io/badge/LinkedIn-0077B5?style=flat&logo=linkedin&logoColor=white)](https://www.linkedin.com/company/intelowl/) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/GreedyBear-Project/Greedybear)](https://github.com/GreedyBear-Project/Greedybear/releases) +[![GitHub Repo stars](https://img.shields.io/github/stars/GreedyBear-Project/Greedybear?style=social)](https://github.com/GreedyBear-Project/Greedybear/stargazers) +![GitHub License](https://img.shields.io/github/license/GreedyBear-Project/GreedyBear) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![CodeQL](https://github.com/intelowlproject/GreedyBear/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/intelowlproject/GreedyBear/actions/workflows/codeql-analysis.yml) -[![Dependency Review](https://github.com/intelowlproject/GreedyBear/actions/workflows/dependency_review.yml/badge.svg)](https://github.com/intelowlproject/GreedyBear/actions/workflows/dependency_review.yml) -[![Pull request automation](https://github.com/intelowlproject/GreedyBear/actions/workflows/pull_request_automation.yml/badge.svg)](https://github.com/intelowlproject/GreedyBear/actions/workflows/pull_request_automation.yml) +[![CodeQL](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/codeql-analysis.yml) +[![Dependency Review](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/dependency_review.yml/badge.svg)](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/dependency_review.yml) +[![Pull request automation](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/pull_request_automation.yml/badge.svg)](https://github.com/GreedyBear-Project/GreedyBear/actions/workflows/pull_request_automation.yml) -The project goal is to extract data of the attacks detected by a [TPOT](https://github.com/telekom-security/tpotce) or a cluster of them and to generate some feeds that can be used to prevent and detect attacks. +The project goal is to extract attack data detected by a [T-Pot](https://github.com/telekom-security/tpotce) or a cluster of them and to generate some feeds that can be used to prevent and detect attacks. You can read the [official announcement here](https://www.honeynet.org/2021/12/27/new-project-available-greedybear/). -[Official announcement here](https://www.honeynet.org/2021/12/27/new-project-available-greedybear/). - -## Documentation - -Documentation about GreedyBear installation, usage, configuration and contribution can be found at [this link](https://github.com/GreedyBear-Project/GreedyBear/wiki) - -## Public feeds - -There are public feeds provided by [The Honeynet Project](https://www.honeynet.org) in this [site](https://greedybear.honeynet.org). [Example](https://greedybear.honeynet.org/api/feeds/cowrie/all/recent.txt) - -Please do not perform too many requests to extract feeds or you will be banned. - -If you want to be updated regularly, please download the feeds only once every 10 minutes (this is the time between each internal update). - -To check all the available feeds, Please refer to our [usage guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Usage) - - -## Enrichment Service - -GreedyBear provides an easy-to-query API to get the information available in GB regarding the queried observable (domain or IP address). - -To understand more, Please refer to our [usage guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Usage) - -## Run Greedybear on your environment -The tool has been created not only to provide the feeds from The Honeynet Project's cluster of TPOTs. - -If you manage one or more T-POTs of your own, you can get the code of this application and run Greedybear on your environment. -In this way, you are able to provide new feeds of your own. - -To install it locally, Please refer to our [installation guide](https://github.com/GreedyBear-Project/GreedyBear/wiki/Installation) +## How to ... +- **... try it out**: visit the [public instance](https://greedybear.honeynet.org) provided by [The Honeynet Project](https://www.honeynet.org) and take a look at a [threat intelligence live feed example](https://greedybear.honeynet.org/api/feeds/cowrie/all/recent.txt) +- **... dive in**: read through our documentation in the [Wiki](https://github.com/GreedyBear-Project/GreedyBear/wiki) and explore GreedyBear's features +- **... run your own instance**: to leverage everything GreedyBear has to offer, you might want to [install](https://github.com/GreedyBear-Project/GreedyBear/wiki/Installation) it and connect it to your own T-Pot +- **... stay up to date**: [read](https://greedybear-project.github.io/) and [subscribe](https://greedybear-project.github.io/feed.xml) to our blog, where we regularly write about the most recent changes and new features +- **... contact us**: using a Github [issue](https://github.com/GreedyBear-Project/GreedyBear/issues) or start a [discussion](https://github.com/GreedyBear-Project/GreedyBear/discussions) +- **... contribute**: read through our [contribution guidelines](https://github.com/GreedyBear-Project/GreedyBear/wiki/Contribute), open an [issue](https://github.com/GreedyBear-Project/GreedyBear/issues), get assigned and raise a [pull request](https://github.com/GreedyBear-Project/GreedyBear/pulls) ## Sponsors and Acknowledgements @@ -57,15 +34,23 @@ Thanks to [The Honeynet Project](https://www.honeynet.org) we are providing free #### Google Summer of Code GSoC logo -In 2026 we started participating to the [Google Summer of Code](https://summerofcode.withgoogle.com/) (GSoC)! +In 2026 we started participating in the [Google Summer of Code](https://summerofcode.withgoogle.com/) (GSoC)! If you are interested in participating in the next Google Summer of Code, check all the info available in the [dedicated repository](https://github.com/intelowlproject/gsoc)! -## Maintainers and Key Contributors +## Maintainers and Contributors This project was started as a personal Christmas project by [Matteo Lodi](https://twitter.com/matte_lodi) in 2021. Special thanks to: -* [Tim Leonhard](https://github.com/regulartim) for having greatly improved the project and added Machine Learning Models during his master thesis. He's the actual Principal Mantainer. -* [Martina Carella](https://github.com/carellamartina) for having created the GUI during her master thesis. -* [Daniele Rosetti](https://github.com/drosetti) for helping maintaining the Frontend. +- [Tim Leonhard](https://github.com/regulartim) for having greatly improved the project and added Machine Learning Models during his master thesis. He's the current Principal Maintainer. +- [Martina Carella](https://github.com/carellamartina) for having created the GUI during her master thesis. +- [Daniele Rosetti](https://github.com/drosetti) for helping maintaining the Frontend. +- and everyone who has contributed to GreedyBear! + + + GreedyBear contributors + + +## License +Distributed under the MIT license. See [`LICENSE`](LICENSE) for the full text. diff --git a/api/serializers.py b/api/serializers.py index ead3b02dc..5485e323f 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -237,6 +237,7 @@ class FeedsResponseSerializer(serializers.Serializer): attacker_country = serializers.CharField(allow_null=True, allow_blank=True, max_length=120) attacker_country_code = serializers.CharField(allow_null=True, allow_blank=True, max_length=2) tags = TagSerializer(many=True, required=False, default=list) + sensors = SensorSerializer(many=True, required=False, default=list) def validate_feed_type(self, feed_type): logger.debug(f"FeedsResponseSerializer - validation feed_type: '{feed_type}'") diff --git a/api/urls.py b/api/urls.py index ef5ed5a8e..34c15dfb6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -15,6 +15,7 @@ feeds_pagination, feeds_revoke, feeds_share, + feeds_tokens, general_honeypot_list, health_view, news_view, @@ -30,6 +31,7 @@ path("feeds/share", feeds_share), path("feeds/consume/", feeds_consume), path("feeds/revoke/", feeds_revoke), + path("feeds/tokens/", feeds_tokens), path("feeds/advanced/", feeds_advanced), path("feeds/asn/", feeds_asn), path("feeds///.", feeds), diff --git a/api/views/cowrie_session.py b/api/views/cowrie_session.py index 7bc8be4c3..df8b38ea1 100644 --- a/api/views/cowrie_session.py +++ b/api/views/cowrie_session.py @@ -101,7 +101,7 @@ def cowrie_session_view(request): if include_similar: commands = {s.commands for s in sessions if s.commands} clusters = {cmd.cluster for cmd in commands if cmd.cluster is not None} - related_sessions = CowrieSession.objects.filter(commands__cluster__in=clusters).prefetch_related("source", "commands", "credentials") + related_sessions = CowrieSession.objects.filter(commands__cluster__in=clusters, duration__gt=0).prefetch_related("source", "commands", "credentials") sessions = sessions.union(related_sessions) response_data = { diff --git a/api/views/feeds.py b/api/views/feeds.py index dec97ec18..e31dff140 100644 --- a/api/views/feeds.py +++ b/api/views/feeds.py @@ -41,6 +41,14 @@ "prioritize", ] +_TOKEN_LIST_FIELDS = ( + "token_hash", + "reason", + "created_at", + "revoked", + "revoked_at", +) + @api_view([GET]) @throttle_classes([FeedsThrottle]) @@ -145,13 +153,14 @@ def feeds_advanced(request): valid_feed_types, tag_key=request.query_params.get("tag_key", "").strip(), tag_value=request.query_params.get("tag_value", "").strip(), + include_sensors=True, ) if paginate: paginator = CustomPageNumberPagination() iocs = paginator.paginate_queryset(iocs_queryset, request) - resp_data = feeds_response(request, iocs, feed_params, valid_feed_types, dict_only=True, verbose=verbose) + resp_data = feeds_response(request, iocs, feed_params, valid_feed_types, dict_only=True, verbose=verbose, include_sensors=True) return paginator.get_paginated_response(resp_data) - return feeds_response(request, iocs_queryset, feed_params, valid_feed_types, verbose=verbose) + return feeds_response(request, iocs_queryset, feed_params, valid_feed_types, verbose=verbose, include_sensors=True) @api_view(["GET"]) @@ -219,20 +228,27 @@ def feeds_share(request): port (int): Filter by destination port. start_date (str): Filter by start date (YYYY-MM-DD). end_date (str): Filter by end date (YYYY-MM-DD). + reason (str): Optional human-readable label for this share token (max 256 chars). Returns: Response: A JSON object containing the signed shareable URL. """ - logger.info(f"request /api/feeds/share with params: {request.query_params}") + safe_params = {k: v for k, v in request.query_params.items() if k != "reason"} + logger.info(f"request /api/feeds/share with params: {safe_params}") feed_params = FeedRequestParams(request.query_params) data = vars(feed_params) # Remove internal or non-serializable objects if any data.pop("feed_type_sorting", None) + reason = request.query_params.get("reason", "").strip()[:256] + # Generate signed token and persist a ShareToken record token = signing.dumps(data, salt="greedybear-feeds") token_hash = hashlib.sha256(token.encode()).hexdigest() - ShareToken.objects.get_or_create(token_hash=token_hash, defaults={"user": request.user}) + ShareToken.objects.get_or_create( + token_hash=token_hash, + defaults={"user": request.user, "reason": reason}, + ) host = request.build_absolute_uri("/") share_url = f"{host}api/feeds/consume/{token}" @@ -323,3 +339,32 @@ def feeds_revoke(request, token): share_token.revoked_at = timezone.now() share_token.save(update_fields=["revoked", "revoked_at"]) return Response({"detail": "Token revoked successfully."}, status=status.HTTP_200_OK) + + +@api_view([GET]) +@authentication_classes([CookieTokenAuthentication]) +@permission_classes([IsAuthenticated]) +def feeds_tokens(request): + """ + List the calling user's share tokens with safe metadata. + + Returns only non-sensitive fields: a truncated hash prefix (first 12 hex + chars), the reason label, creation timestamp, and revocation status. + The raw token is never stored and therefore cannot be returned. + + Returns: + Response: A JSON list of token metadata objects. + """ + logger.info("request /api/feeds/tokens/") + tokens = ShareToken.objects.filter(user=request.user).order_by("-created_at").values(*_TOKEN_LIST_FIELDS) + results = [ + { + "hash_prefix": t["token_hash"][:12], + "reason": t["reason"], + "created_at": t["created_at"], + "revoked": t["revoked"], + "revoked_at": t["revoked_at"], + } + for t in tokens + ] + return Response(results) diff --git a/api/views/statistics.py b/api/views/statistics.py index d4bd1f751..bbcdb017a 100644 --- a/api/views/statistics.py +++ b/api/views/statistics.py @@ -86,18 +86,25 @@ def countries(self, request): request: The incoming request object. Returns: - Response: A JSON list of {country, count} objects ordered by count descending. + Response: A JSON list of {country, code, count} objects ordered by count descending. """ delta, _ = self.__parse_range(self.request) qs = ( IOC.objects.filter(last_seen__gte=delta) .exclude(attacker_country="") .filter(honeypots__active=True) - .values("attacker_country") + .values("attacker_country", "attacker_country_code") .annotate(count=Count("id", distinct=True)) .order_by("-count") ) - data = [{"country": item["attacker_country"], "count": item["count"]} for item in qs] + data = [ + { + "country": item["attacker_country"], + "code": item["attacker_country_code"], + "count": item["count"], + } + for item in qs + ] return Response(data) @action(detail=False, methods=["get"]) diff --git a/api/views/utils.py b/api/views/utils.py index b281cc1bd..b4e64d586 100644 --- a/api/views/utils.py +++ b/api/views/utils.py @@ -153,7 +153,9 @@ def get_valid_feed_types() -> frozenset[str]: return frozenset(feed_types) -def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, serializer_class=FeedsRequestSerializer, tag_key="", tag_value=""): +def get_queryset( + request, feed_params, valid_feed_types, is_aggregated=False, serializer_class=FeedsRequestSerializer, tag_key="", tag_value="", include_sensors=False +): """ Build a queryset to filter IOC data based on the request parameters. @@ -172,6 +174,8 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se - Default: `FeedsRequestSerializer`. tag_key (str, optional): Filter IOCs by tag key. Only passed from feeds_advanced. tag_value (str, optional): Filter IOCs by tag value (case-insensitive substring). Only passed from feeds_advanced. + include_sensors (bool, optional): If True, annotates sensors_json for each IOC. + Only passed from authenticated views like feeds_advanced. Default: False. Returns: QuerySet: The filtered queryset of IOC data. @@ -252,6 +256,15 @@ def get_queryset(request, feed_params, valid_feed_types, is_aggregated=False, se distinct=True, ) ) + if include_sensors: + iocs = iocs.annotate( + sensors_json=ArrayAgg( + JSONObject(address=F("sensors__address"), label=F("sensors__label")), + filter=Q(sensors__isnull=False), + default=Value([]), + distinct=True, + ) + ) iocs = iocs.order_by(feed_params.ordering) iocs = iocs[: int(feed_params.feed_size)] @@ -276,7 +289,7 @@ def ioc_as_dict(ioc, fields: set) -> dict: return {k: v for k, v in ioc.__dict__.items() if k in fields} -def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=None, dict_only=False, verbose=False): +def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=None, dict_only=False, verbose=False, include_sensors=False): """ Format the IOC data into the requested format (e.g., JSON, CSV, TXT). @@ -339,13 +352,19 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N required_fields = base_fields + verbose_only_fields if verbose else base_fields # `tags_json` is annotated in get_queryset (only for JSON format) to avoid conflicting - # with the `tags` reverse FK on IOC. When the queryset comes from a repository method + # with the `tags` reverse FK on IOC. When the queryset comes from a repository method # that does not annotate `tags_json` (e.g. the ML scoring path), exclude the field. + # `sensors_json` follows the same pattern and is only annotated for authenticated views. if isinstance(iocs, list): has_tags_annotation = bool(iocs) and hasattr(iocs[0], "tags_json") + has_sensors_annotation = include_sensors and bool(iocs) and hasattr(iocs[0], "sensors_json") else: has_tags_annotation = "tags_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations + has_sensors_annotation = include_sensors and "sensors_json" in getattr(iocs, "query", type("", (), {"annotations": {}})()).annotations + required_fields = tuple(("tags_json" if f == "tags" else f) for f in required_fields if f != "tags" or has_tags_annotation) + if has_sensors_annotation: + required_fields = required_fields + ("sensors_json",) iocs_iter: object if isinstance(iocs, list): @@ -362,6 +381,7 @@ def feeds_response(request=None, iocs=None, feed_params=None, valid_feed_types=N "destination_port_count": len(ioc.get("destination_ports", [])), "asn": ioc.get("autonomous_system", ""), "tags": ioc.pop("tags_json", []), + **({"sensors": ioc.pop("sensors_json", [])} if has_sensors_annotation else {}), } if not verbose: @@ -557,7 +577,7 @@ def get_greedybear_news() -> list[dict]: feed = feedparser.parse(response.content) filtered_entries = sorted( - [entry for entry in feed.entries if "greedybear" in entry.get("title", "").lower() and entry.get("published_parsed")], + [entry for entry in feed.entries if entry.get("published_parsed")], key=lambda e: e.published_parsed, reverse=True, ) diff --git a/authentication/views.py b/authentication/views.py index d6df05397..fc02600f9 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -6,7 +6,6 @@ from certego_saas.ext.throttling import POSTUserRateThrottle from django.conf import settings from django.contrib.auth import get_user_model, login -from django.core.cache import cache from durin import views as durin_views from durin.models import AuthToken from rest_framework import status @@ -142,9 +141,6 @@ def post(self, request, *args, **kwargs): logger.info(f"administrator:'{uname}' was logged in.") except Exception: logger.exception(f"administrator:'{uname}' login failed.") - # just a hacky way to store the current host - # as this is the first endpoint hit by a user. - cache.set("current_site", request.get_host(), timeout=60 * 60 * 24) return response diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e5839d3f..d2c02a12e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -76,8 +76,8 @@ RUN mkdir -p ${LOG_PATH}/django ${LOG_PATH}/gunicorn \ FROM production AS development -# Install dev dependencies -RUN uv sync --group dev --locked +# Install dev + test dependencies (hot-reload tools + test runners) +RUN uv sync --group dev --group test --locked ## ------------------------------- Default Stage ------------------------------ ## diff --git a/docker/env_file_template b/docker/env_file_template index aa6bebc1a..a909c7792 100644 --- a/docker/env_file_template +++ b/docker/env_file_template @@ -7,6 +7,10 @@ DJANGO_SECRET= # Example: DJANGO_ALLOWED_HOSTS=greedybear.example.com,api.example.com DJANGO_ALLOWED_HOSTS= +# Public URI used for email verification and password reset links. Defaults to http://localhost. +# Example: HOST_URI=https://api.greedybear.example.com +HOST_URI= + DB_HOST=postgres DB_PORT=5432 DB_USER=user @@ -84,6 +88,13 @@ ABUSEIPDB_API_KEY = # Rate limiting for feeds endpoints (format: number/period, e.g. 30/minute) FEEDS_THROTTLE_RATE=30/minute FEEDS_ADVANCED_THROTTLE_RATE=100/minute +FEEDS_SHARED_THROTTLE_RATE=10/minute + +# Trending attackers settings +# Max API window in minutes (must be >= 60 and multiple of 60) +TRENDING_MAX_WINDOW_MINUTES=22320 +# Bucket retention in hours (must be >= 1) +TRENDING_BUCKET_RETENTION_HOURS=744 # Optional feed license URL to include in API responses # If not set, no license information will be included in feeds @@ -93,4 +104,4 @@ FEEDS_LICENSE= # Optional IntelOwl base URL. When set, a link to analyze each IOC on IntelOwl # will appear in the Feeds table. # Example: https://your-intelowl-instance.example.com -VITE_INTELOWL_URL= \ No newline at end of file +VITE_INTELOWL_URL= diff --git a/docker/local.override.yml b/docker/local.override.yml index c7a76921c..a006f8508 100644 --- a/docker/local.override.yml +++ b/docker/local.override.yml @@ -26,5 +26,6 @@ services: image: intelowlproject/greedybear:test volumes: - ../:/opt/deploy/greedybear + command: sh -c "python manage.py setup_schedules && exec watchfiles --filter python 'python manage.py qcluster' /opt/deploy/greedybear/greedybear" environment: - DEBUG=True \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8156ffaa6..bfb3187c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,11 @@ "version": "0.1.0", "dependencies": { "@certego/certego-ui": "0.1.14", - "axios": "^1.15.0", + "axios": "^1.15.2", "axios-hooks": "^3.1.5", "bootstrap": ">=5.3.8", "formik": "^2.4.9", + "i18n-iso-countries": "^7.14.0", "prop-types": "^15.8.1", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -31,7 +32,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.4", + "@vitest/coverage-v8": "^4.1.5", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -41,8 +42,8 @@ "jsdom": "^29.0.2", "prettier": "^3.8.3", "stylelint": "^17.8.0", - "vite": "^8.0.8", - "vitest": "^4.1.4" + "vite": "^8.0.9", + "vitest": "^4.1.5" } }, "node_modules/@adobe/css-tools": { @@ -70,9 +71,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", - "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -979,9 +980,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", "dev": true, "license": "MIT", "funding": { @@ -1322,9 +1323,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", "cpu": [ "arm64" ], @@ -1339,9 +1340,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", "cpu": [ "arm64" ], @@ -1356,9 +1357,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", "cpu": [ "x64" ], @@ -1373,9 +1374,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", "cpu": [ "x64" ], @@ -1390,9 +1391,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", "cpu": [ "arm" ], @@ -1407,9 +1408,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", "cpu": [ "arm64" ], @@ -1427,9 +1428,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", "cpu": [ "arm64" ], @@ -1447,9 +1448,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", "cpu": [ "ppc64" ], @@ -1467,9 +1468,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", "cpu": [ "s390x" ], @@ -1487,9 +1488,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", "cpu": [ "x64" ], @@ -1507,9 +1508,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", "cpu": [ "x64" ], @@ -1527,9 +1528,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", "cpu": [ "arm64" ], @@ -1544,9 +1545,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", "cpu": [ "wasm32" ], @@ -1556,16 +1557,16 @@ "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", "cpu": [ "arm64" ], @@ -1580,9 +1581,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", "cpu": [ "x64" ], @@ -1958,14 +1959,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", - "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1979,8 +1980,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.4", - "vitest": "4.1.4" + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1989,16 +1990,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2007,13 +2008,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2034,9 +2035,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -2047,13 +2048,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -2061,14 +2062,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2077,9 +2078,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -2087,13 +2088,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2448,9 +2449,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -3324,6 +3325,12 @@ "node": ">=8" } }, + "node_modules/diacritics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", + "integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3377,13 +3384,13 @@ "license": "MIT" }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -4815,9 +4822,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4881,6 +4888,18 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/i18n-iso-countries": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", + "integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==", + "license": "MIT", + "dependencies": { + "diacritics": "1.3.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6375,9 +6394,9 @@ } }, "node_modules/nano-css/node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, "node_modules/nanoid": { @@ -6747,13 +6766,13 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -7632,14 +7651,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7648,27 +7667,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", "dev": true, "license": "MIT" }, @@ -7706,15 +7725,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -9099,17 +9118,17 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -9177,19 +9196,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -9217,12 +9236,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index a512265c4..c25515bc0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,11 @@ }, "dependencies": { "@certego/certego-ui": "0.1.14", - "axios": "^1.15.0", + "axios": "^1.15.2", "axios-hooks": "^3.1.5", "bootstrap": ">=5.3.8", "formik": "^2.4.9", + "i18n-iso-countries": "^7.14.0", "prop-types": "^15.8.1", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -50,7 +51,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.4", + "@vitest/coverage-v8": "^4.1.5", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -60,7 +61,7 @@ "jsdom": "^29.0.2", "prettier": "^3.8.3", "stylelint": "^17.8.0", - "vite": "^8.0.8", - "vitest": "^4.1.4" + "vite": "^8.0.9", + "vitest": "^4.1.5" } } diff --git a/frontend/src/components/dashboard/AttackOriginMap.jsx b/frontend/src/components/dashboard/AttackOriginMap.jsx index a418c9eb8..da0d9b3d8 100644 --- a/frontend/src/components/dashboard/AttackOriginMap.jsx +++ b/frontend/src/components/dashboard/AttackOriginMap.jsx @@ -6,7 +6,9 @@ import { ZoomableGroup, } from "react-simple-maps"; import { useTimePickerStore } from "@certego/certego-ui"; +import countries from "i18n-iso-countries"; import useAttackerCountriesStore from "../../stores/useAttackerCountriesStore"; + const WORLD_ATLAS_GEO_URL = `${import.meta.env.BASE_URL}countries-110m.json`; function lerpColor(a, b, t) { @@ -29,6 +31,38 @@ const COLOR_LOW = "#ffffb2"; const COLOR_MID = "#fd8d3c"; const COLOR_HIGH = "#bd0026"; +const MapPaths = React.memo( + ({ getColor, handleMouseEnter, handleMouseLeave }) => { + return ( + + {({ geographies }) => + geographies.map((geo) => ( + handleMouseEnter(geo, evt)} + onMouseLeave={handleMouseLeave} + style={{ + default: { outline: "none" }, + hover: { + outline: "none", + fill: "#facc15", + transition: "fill 80ms", + }, + pressed: { outline: "none" }, + }} + /> + )) + } + + ); + }, +); +MapPaths.displayName = "MapPaths"; + export default function AttackOriginMap() { const { range } = useTimePickerStore(); const { @@ -52,27 +86,43 @@ export default function AttackOriginMap() { fetchData(range); }, [range, fetchData]); + const colors = React.useMemo(() => { + const colorMap = {}; + if (maxCount <= 0) return colorMap; + for (const [alpha2, count] of Object.entries(countryData)) { + if (!count) continue; + const t = Math.sqrt(count / maxCount); + colorMap[alpha2] = + t < 0.5 + ? lerpColor(COLOR_LOW, COLOR_MID, t * 2) + : lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); + } + return colorMap; + }, [countryData, maxCount]); + + /** + * Resolve fill colour for a geography. + * geo.id is the ISO 3166-1 numeric code (e.g. "840"). + * i18n-iso-countries converts it to alpha-2 ("US"), which is the key + * used in countryData — no hand-maintained name table required. + */ const getColor = React.useCallback( - (geoName) => { - const count = countryData[geoName]; - if (maxCount <= 0 || !count) return COLOR_EMPTY; - const t = Math.sqrt(count / maxCount); // sqrt scale so small values are still visible - // 3-stop: low (yellow) → mid (orange) → high (red) - if (t < 0.5) return lerpColor(COLOR_LOW, COLOR_MID, t * 2); - return lerpColor(COLOR_MID, COLOR_HIGH, (t - 0.5) * 2); + (geoId) => { + const alpha2 = countries.numericToAlpha2(geoId); + return (alpha2 && colors[alpha2]) || COLOR_EMPTY; }, - [countryData, maxCount], + [colors], ); const handleMouseEnter = React.useCallback( (geo, evt) => { - const name = geo.properties.name; - const count = countryData[name] ?? 0; + const alpha2 = countries.numericToAlpha2(geo.id); + const count = alpha2 ? (countryData[alpha2] ?? 0) : 0; setTooltip({ visible: true, x: evt.clientX, y: evt.clientY, - name, + name: geo.properties.name, // canonical TopoJSON name for display count, }); }, @@ -165,30 +215,11 @@ export default function AttackOriginMap() { onMouseLeave={handleMouseLeave} > - - {({ geographies }) => - geographies.map((geo) => ( - handleMouseEnter(geo, evt)} - onMouseLeave={handleMouseLeave} - style={{ - default: { outline: "none" }, - hover: { - outline: "none", - fill: "#facc15", - transition: "fill 80ms", - }, - pressed: { outline: "none" }, - }} - /> - )) - } - + diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index ec73582ee..8279ca38a 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -23,4 +23,4 @@ export const UUID_REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; export const FEEDS_LICENSE = - "https://github.com/intelowlproject/GreedyBear/blob/main/FEEDS_LICENSE.md"; + "https://github.com/GreedyBear-Project/GreedyBear/blob/main/FEEDS_LICENSE.md"; diff --git a/frontend/src/layouts/AppFooter.jsx b/frontend/src/layouts/AppFooter.jsx index 67bd06c9f..053eb7f60 100644 --- a/frontend/src/layouts/AppFooter.jsx +++ b/frontend/src/layouts/AppFooter.jsx @@ -32,32 +32,14 @@ function AppFooter() { Follow us on: - - - - - - diff --git a/frontend/src/stores/useAttackerCountriesStore.jsx b/frontend/src/stores/useAttackerCountriesStore.jsx index 2240c8397..602bca37a 100644 --- a/frontend/src/stores/useAttackerCountriesStore.jsx +++ b/frontend/src/stores/useAttackerCountriesStore.jsx @@ -1,7 +1,6 @@ import axios from "axios"; import { create } from "zustand"; import { IOC_ATTACKER_COUNTRIES_URI } from "../constants/api"; -import { normalizeCountryName } from "../utils/country"; const useAttackerCountriesStore = create((set, get) => ({ normalizedData: [], @@ -34,33 +33,42 @@ const useAttackerCountriesStore = create((set, get) => ({ signal: controller.signal, }); - const normalizedData = (Array.isArray(resp?.data) ? resp.data : []).map( - (item) => { - if ( - item && - typeof item === "object" && - typeof item.country === "string" - ) { - return { ...item, country: normalizeCountryName(item.country) }; - } - return item; - }, - ); - + const rawData = Array.isArray(resp?.data) ? resp.data : []; const countryDataMap = {}; + const countryNameMap = {}; // alpha-2 code → first-seen display name let maxCount = 0; - normalizedData.forEach((item) => { + rawData.forEach((item) => { if (item && typeof item === "object") { - const { country, count } = item; - if (typeof country === "string") { - const countNum = Number(count) || 0; - countryDataMap[country] = countNum; - if (countNum > maxCount) maxCount = countNum; + const code = + typeof item.code === "string" ? item.code.toUpperCase() : null; + if (!code) return; // skip items without an ISO-A2 code + + const countNum = Number(item.count) || 0; + + // Aggregate count by alpha-2 code + countryDataMap[code] = (countryDataMap[code] || 0) + countNum; + + // Keep the first-seen display name for this code + if (!countryNameMap[code]) { + countryNameMap[code] = item.country || code; + } + + if (countryDataMap[code] > maxCount) { + maxCount = countryDataMap[code]; } } }); + // Build unique aggregated list for charts + const normalizedData = Object.entries(countryDataMap) + .map(([code, count]) => ({ + country: countryNameMap[code], + count, + code, + })) + .sort((a, b) => b.count - a.count); + if (get().currentController === controller) { set({ normalizedData, diff --git a/frontend/src/utils/country.js b/frontend/src/utils/country.js deleted file mode 100644 index 0ef67ac40..000000000 --- a/frontend/src/utils/country.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Normalise country names from T-Pot geoip to match Natural Earth names used by world-atlas@2. - * (https://github.com/topojson/world-atlas) - */ -export const COUNTRY_NAME_FIXES = { - "United States": "United States of America", - "Czech Republic": "Czechia", - "Ivory Coast": "Côte d'Ivoire", - "Democratic Republic of the Congo": "Dem. Rep. Congo", - "Republic of the Congo": "Congo", - "Bosnia and Herzegovina": "Bosnia and Herz.", - "Central African Republic": "Central African Rep.", - "Dominican Republic": "Dominican Rep.", - "Equatorial Guinea": "Eq. Guinea", - "South Sudan": "S. Sudan", - "North Macedonia": "Macedonia", - Eswatini: "eSwatini", - "State of Palestine": "Palestine", - "Western Sahara": "W. Sahara", - "Solomon Islands": "Solomon Is.", - "Falkland Islands": "Falkland Is.", - "French Southern Territories": "Fr. S. Antarctic Lands", -}; - -/** - * Returns a normalised country name if a fix is available, otherwise returns the original name. - * - * @param {string|null|undefined} name - Raw country name (e.g., from T-Pot GeoIP) - * @returns {string|null|undefined} - Normalised country name (matching Natural Earth standards) - */ -export function normalizeCountryName(name) { - return COUNTRY_NAME_FIXES[name] ?? name; -} diff --git a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx index 03320e0d4..44fba10d1 100644 --- a/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginCountriesChart.test.jsx @@ -29,23 +29,25 @@ vi.mock("recharts", async (importOriginal) => { }); const COUNTRIES_DATA = [ - { country: "China", count: 120 }, - { country: "United States", count: 80 }, - { country: "Russia", count: 60 }, - { country: "Germany", count: 40 }, - { country: "India", count: 30 }, + { country: "China", code: "CN", count: 120 }, + { country: "United States", code: "US", count: 80 }, + { country: "Russia", code: "RU", count: 60 }, + { country: "Germany", code: "DE", count: 40 }, + { country: "India", code: "IN", count: 30 }, ]; -// 16 entries (one more than the 15-entry limit) +// 16 entries (one more than the 15-entry limit). +// Each entry needs a code so the store doesn't skip codeless items. const SIXTEEN_COUNTRIES = Array.from({ length: 16 }, (_, i) => ({ country: `Country${i + 1}`, + code: `T${String(i + 1).padStart(1, "0")}`.slice(0, 2), // fictional alpha-2 count: 100 - i, })); describe("AttackOriginCountriesChart", () => { beforeEach(() => { useAttackerCountriesStore.setState({ - rawData: [], + normalizedData: [], countryDataMap: {}, maxCount: 0, loading: false, diff --git a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx index 493d90b29..6ec9ac38e 100644 --- a/frontend/tests/components/dashboard/AttackOriginMap.test.jsx +++ b/frontend/tests/components/dashboard/AttackOriginMap.test.jsx @@ -12,7 +12,10 @@ vi.mock("@certego/certego-ui", () => ({ useTimePickerStore: () => ({ range: "7d" }), })); -// Mock react-simple-maps components +// Mock react-simple-maps components. +// Each mock geography carries a numeric ISO id (geo.id) so that +// AttackOriginMap can resolve it to an alpha-2 code via i18n-iso-countries, +// matching the alpha-2-keyed countryDataMap from the store. vi.mock("react-simple-maps", () => ({ ComposableMap: ({ children, onMouseMove, onMouseLeave }) => (
({ Geographies: ({ children }) => children({ geographies: [ - { rsmKey: "geo-cn", properties: { name: "China" } }, - { rsmKey: "geo-usa", properties: { name: "United States of America" } }, - { rsmKey: "geo-fr", properties: { name: "France" } }, + { rsmKey: "geo-cn", id: "156", properties: { name: "China" } }, + { + rsmKey: "geo-usa", + id: "840", + properties: { name: "United States of America" }, + }, + { rsmKey: "geo-fr", id: "250", properties: { name: "France" } }, ], }), Geography: ({ fill, onMouseEnter, onMouseLeave, geography }) => ( @@ -43,15 +50,15 @@ vi.mock("react-simple-maps", () => ({ })); const COUNTRIES_DATA = [ - { country: "China", count: 120 }, - { country: "United States", count: 80 }, - { country: "Germany", count: 40 }, + { country: "China", code: "CN", count: 120 }, + { country: "United States", code: "US", count: 80 }, + { country: "Germany", code: "DE", count: 40 }, ]; describe("AttackOriginMap", () => { beforeEach(() => { useAttackerCountriesStore.setState({ - rawData: [], + normalizedData: [], countryDataMap: {}, maxCount: 0, loading: false, @@ -127,15 +134,17 @@ describe("AttackOriginMap", () => { await waitFor(() => expect(screen.getByTestId("geography-geo-fr")).toBeInTheDocument(), ); - // France is not in COUNTRIES_DATA so it must receive the empty/default colour + // France (id="250") is not in COUNTRIES_DATA so it must receive the empty/default colour const franceEl = screen.getByTestId("geography-geo-fr"); expect(franceEl.dataset.fill).toBe("#2a2a3a"); - // China IS in the data so it must not receive the empty colour + // China (id="156") IS in the data so it must not receive the empty colour const chinaEl = screen.getByTestId("geography-geo-cn"); expect(chinaEl.dataset.fill).not.toBe("#2a2a3a"); }); - test("country name normalisation is applied (United States => United States of America)", async () => { + test("geo.id numeric lookup correctly colours a country regardless of API name variant", async () => { + // The API returns "United States" but the map looks up by geo.id="840" → alpha-2 "US" + // so the geography is coloured even though the name doesn't match the TopoJSON name axios.get.mockResolvedValue({ data: COUNTRIES_DATA }); render(); await waitFor(() => diff --git a/frontend/tests/components/feeds/Feeds.test.jsx b/frontend/tests/components/feeds/Feeds.test.jsx index c53d6b176..0dd86a863 100644 --- a/frontend/tests/components/feeds/Feeds.test.jsx +++ b/frontend/tests/components/feeds/Feeds.test.jsx @@ -113,7 +113,7 @@ describe("Feeds component", () => { }); expect(buttonFeedsLicense).toHaveAttribute( "href", - "https://github.com/intelowlproject/GreedyBear/blob/main/FEEDS_LICENSE.md", + "https://github.com/GreedyBear-Project/GreedyBear/blob/main/FEEDS_LICENSE.md", ); const feedTypeSelectElement = screen.getByLabelText("Feed type:"); diff --git a/frontend/tests/stores/useAttackerCountriesStore.test.jsx b/frontend/tests/stores/useAttackerCountriesStore.test.jsx index e02932698..a040db5f3 100644 --- a/frontend/tests/stores/useAttackerCountriesStore.test.jsx +++ b/frontend/tests/stores/useAttackerCountriesStore.test.jsx @@ -45,8 +45,8 @@ describe("useAttackerCountriesStore", () => { const mockRange = "24h"; const rangeStr = JSON.stringify(mockRange); const mockData = [ - { country: "United States", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, ]; test("successfully fetches and normalizes data", async () => { @@ -55,13 +55,14 @@ describe("useAttackerCountriesStore", () => { await useAttackerCountriesStore.getState().fetchData(mockRange); const state = useAttackerCountriesStore.getState(); + // countryDataMap is keyed by alpha-2; normalizedData uses raw API name expect(state.normalizedData).toEqual([ - { country: "United States of America", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, ]); expect(state.countryDataMap).toEqual({ - "United States of America": 100, - Italy: 50, + US: 100, + IT: 50, }); expect(state.maxCount).toBe(100); expect(state.loading).toBe(false); @@ -144,8 +145,49 @@ describe("useAttackerCountriesStore", () => { expect(axios.get).toHaveBeenCalledTimes(2); expect(useAttackerCountriesStore.getState().normalizedData).toEqual([ - { country: "United States of America", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, + ]); + }); + + test("aggregates data correctly when same ISO code has different names", async () => { + const complexMockData = [ + { country: "United States", code: "US", count: 100 }, + { country: "USA", code: "US", count: 50 }, + { country: "Italy", code: "IT", count: 30 }, + ]; + axios.get.mockResolvedValue({ data: complexMockData }); + + await useAttackerCountriesStore.getState().fetchData("all"); + + const state = useAttackerCountriesStore.getState(); + // Aggregated by alpha-2; display name is the first-seen value + expect(state.normalizedData).toEqual([ + { country: "United States", code: "US", count: 150 }, + { country: "Italy", code: "IT", count: 30 }, + ]); + expect(state.countryDataMap).toEqual({ + US: 150, + IT: 30, + }); + expect(state.maxCount).toBe(150); + }); + + test("skips items that have no ISO code", async () => { + const mixedData = [ + { country: "United States", code: "US", count: 100 }, + { country: "Unknown", code: null, count: 50 }, + { country: "Italy", code: "IT", count: 30 }, + ]; + axios.get.mockResolvedValue({ data: mixedData }); + + await useAttackerCountriesStore.getState().fetchData(mockRange); + + const state = useAttackerCountriesStore.getState(); + // The entry with no code is excluded + expect(state.normalizedData).toEqual([ + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 30 }, ]); }); @@ -191,8 +233,8 @@ describe("useAttackerCountriesStore", () => { // Now loading should be false expect(useAttackerCountriesStore.getState().loading).toBe(false); expect(useAttackerCountriesStore.getState().normalizedData).toEqual([ - { country: "United States of America", count: 100 }, - { country: "Italy", count: 50 }, + { country: "United States", code: "US", count: 100 }, + { country: "Italy", code: "IT", count: 50 }, ]); }); }); diff --git a/frontend/tests/utils/country.test.js b/frontend/tests/utils/country.test.js deleted file mode 100644 index 8457b8308..000000000 --- a/frontend/tests/utils/country.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { normalizeCountryName } from "../../src/utils/country"; - -describe("normalizeCountryName", () => { - it("should normalize known mismatched names", () => { - expect(normalizeCountryName("United States")).toBe( - "United States of America", - ); - expect(normalizeCountryName("Czech Republic")).toBe("Czechia"); - expect(normalizeCountryName("Ivory Coast")).toBe("Côte d'Ivoire"); - expect(normalizeCountryName("South Sudan")).toBe("S. Sudan"); - }); - - it("should return the same name if no mismatch is known", () => { - expect(normalizeCountryName("Italy")).toBe("Italy"); - expect(normalizeCountryName("Brazil")).toBe("Brazil"); - expect(normalizeCountryName("France")).toBe("France"); - }); - - it("should handle edge cases like empty strings or nulls", () => { - expect(normalizeCountryName("")).toBe(""); - expect(normalizeCountryName(null)).toBe(null); - expect(normalizeCountryName(undefined)).toBe(undefined); - }); -}); diff --git a/greedybear/admin.py b/greedybear/admin.py index 3372630f2..96211f8a1 100644 --- a/greedybear/admin.py +++ b/greedybear/admin.py @@ -8,6 +8,7 @@ from greedybear.models import ( IOC, + AttackerActivityBucket, CommandSequence, CowrieSession, Credential, @@ -183,6 +184,16 @@ def get_queryset(self, request): return super().get_queryset(request).select_related("autonomous_system").prefetch_related("sensors", "honeypots") +@admin.register(AttackerActivityBucket) +class AttackerActivityBucketAdmin(admin.ModelAdmin): + list_display = ["attacker_ip", "feed_type", "bucket_start", "interaction_count"] + list_filter = ["feed_type"] + search_fields = ["attacker_ip"] + search_help_text = "search for the attacker IP address" + date_hierarchy = "bucket_start" + ordering = ["-bucket_start"] + + @admin.register(Honeypot) class HoneypotAdmin(admin.ModelAdmin): list_display = [ diff --git a/greedybear/consts.py b/greedybear/consts.py index 5ed7f0d5a..a120418cb 100644 --- a/greedybear/consts.py +++ b/greedybear/consts.py @@ -15,23 +15,29 @@ DOMAIN = "domain" IP = "ip" -REQUIRED_FIELDS = [ +FIELDS_TO_EXTRACT = [ "@timestamp", - "src_ip", + "body", "dest_port", - "ip_rep", + "duration", + "eventid", "geoip", - "url", + "geoip_ext", + "ip_rep", "message", - "eventid", + "outfile", + "password", + "path", + "post_data", + "protocol", "session", + "shasum", + "src_ip", + "t-pot_ip_ext", "timestamp", - "duration", + "type", + "url", "username", - "password", - "t-pot_ip_ext", - "shasum", - "outfile", ] @@ -53,7 +59,7 @@ # we used this const to implement news feature -RSS_FEED_URL = "https://intelowlproject.github.io/feed.xml" +RSS_FEED_URL = "https://greedybear-project.github.io/feed.xml" CACHE_KEY_GREEDYBEAR_NEWS = "greedybear_news" CACHE_TIMEOUT_SECONDS = 60 * 60 diff --git a/greedybear/cronjobs/bucket_cleanup.py b/greedybear/cronjobs/bucket_cleanup.py new file mode 100644 index 000000000..90c93d4f8 --- /dev/null +++ b/greedybear/cronjobs/bucket_cleanup.py @@ -0,0 +1,56 @@ +from datetime import timedelta + +from django.conf import settings +from django.utils import timezone + +from greedybear.cronjobs.base import Cronjob +from greedybear.cronjobs.repositories import TrendingBucketRepository + +DEFAULT_TRENDING_MAX_WINDOW_MINUTES = (24 * 31 * 60) // 2 +DEFAULT_TRENDING_BUCKET_RETENTION_HOURS = 24 * 31 + + +class TrendingBucketCleanupCron(Cronjob): + @staticmethod + def _positive_int_setting(name: str, value) -> int: + try: + parsed_value = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a positive integer, got {value!r}") from exc + + if parsed_value < 1: + raise ValueError(f"{name} must be >= 1, got {parsed_value}") + + return parsed_value + + def _validated_settings(self) -> tuple[int, int]: + max_window_minutes = self._positive_int_setting( + "TRENDING_MAX_WINDOW_MINUTES", + getattr(settings, "TRENDING_MAX_WINDOW_MINUTES", DEFAULT_TRENDING_MAX_WINDOW_MINUTES), + ) + if max_window_minutes < 60: + raise ValueError(f"TRENDING_MAX_WINDOW_MINUTES must be >= 60, got {max_window_minutes}") + if max_window_minutes % 60: + raise ValueError(f"TRENDING_MAX_WINDOW_MINUTES must be a multiple of 60, got {max_window_minutes}") + + retention_hours = self._positive_int_setting( + "TRENDING_BUCKET_RETENTION_HOURS", + getattr(settings, "TRENDING_BUCKET_RETENTION_HOURS", DEFAULT_TRENDING_BUCKET_RETENTION_HOURS), + ) + + retention_minutes = retention_hours * 60 + required_retention_minutes = 2 * max_window_minutes + if retention_minutes < required_retention_minutes: + raise ValueError( + "TRENDING_BUCKET_RETENTION_HOURS must retain at least two windows " + f"for TRENDING_MAX_WINDOW_MINUTES={max_window_minutes}: " + f"required >= {required_retention_minutes} minutes, got {retention_minutes}" + ) + + return max_window_minutes, retention_hours + + def run(self) -> None: + now = timezone.now().replace(minute=0, second=0, microsecond=0) + _, retention_hours = self._validated_settings() + cutoff = now - timedelta(hours=retention_hours) + TrendingBucketRepository().delete_older_than(cutoff) diff --git a/greedybear/cronjobs/extraction/bucket_updater.py b/greedybear/cronjobs/extraction/bucket_updater.py new file mode 100644 index 000000000..dd57a07c0 --- /dev/null +++ b/greedybear/cronjobs/extraction/bucket_updater.py @@ -0,0 +1,67 @@ +import logging +from collections import Counter +from collections.abc import Iterable +from datetime import datetime +from ipaddress import ip_address + +from greedybear.cronjobs.extraction.utils import parse_timestamp +from greedybear.cronjobs.repositories import TrendingBucketRepository +from greedybear.utils import is_non_global_ip + +logger = logging.getLogger(__name__) + +BucketKey = tuple[str, str, datetime] + + +class BucketUpdater: + def __init__(self): + self.counters: Counter[BucketKey] = Counter() + self.total_update_count: int = 0 + + def collect_hits(self, hits: Iterable[dict]) -> None: + for hit in hits: + key = _bucket_key_from_hit(hit) + if key is not None: + self.counters[key] += 1 + + def update(self) -> int: + if not self.counters: + return 0 + + try: + update_count = TrendingBucketRepository().upsert_bucket_counts(self.counters) + logger.debug(f"Updated {update_count} buckets") + self.total_update_count += update_count + return update_count + except Exception as exc: + logger.error("Failed to update activity buckets from hits for current chunk: %s", exc, exc_info=True) + return 0 + finally: + self.counters = Counter() + + +def _bucket_start(timestamp: str) -> datetime: + parsed = parse_timestamp(timestamp) + return parsed.replace(minute=0, second=0, microsecond=0) + + +def _bucket_key_from_hit(hit: dict) -> BucketKey | None: + attacker_ip = hit.get("src_ip") + feed_type = hit.get("type") + timestamp = hit.get("@timestamp") + if not attacker_ip or not feed_type or not timestamp: + return None + + normalized_ip = str(attacker_ip) + try: + parsed_ip = ip_address(normalized_ip) + except ValueError: + return None + + if is_non_global_ip(parsed_ip): + return None + + try: + return normalized_ip, str(feed_type).lower(), _bucket_start(timestamp) + except Exception: + return None diff --git a/greedybear/cronjobs/extraction/pipeline.py b/greedybear/cronjobs/extraction/pipeline.py index f434bd83d..a7e3e64a3 100644 --- a/greedybear/cronjobs/extraction/pipeline.py +++ b/greedybear/cronjobs/extraction/pipeline.py @@ -3,6 +3,7 @@ from django.core.cache import caches +from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater from greedybear.cronjobs.extraction.strategies.factory import ExtractionStrategyFactory from greedybear.cronjobs.repositories import ( ElasticRepository, @@ -52,11 +53,13 @@ def execute(self) -> int: 2. For each chunk, group hits by honeypot type and extract sensors 3. Apply honeypot-specific extraction strategies 4. Update IOC scores + 5. Update activity buckets Returns: Number of IOC records processed. """ ioc_record_count = 0 + bucket_updater = BucketUpdater() factory = ExtractionStrategyFactory(self.ioc_repo, self.sensor_repo) # 1. Search in chunks @@ -68,30 +71,34 @@ def execute(self) -> int: # 2. Group by honeypot self.log.info("Grouping hits by honeypot type") for hit in chunk: + # convert hit to dict for easier handling + hit = hit.to_dict() # skip hits with non-existing or empty sources if "src_ip" not in hit or not hit["src_ip"].strip(): continue # skip hits with non-existing or empty types (=honeypots) if "type" not in hit or not hit["type"].strip(): continue - # extract sensor and include in hit dict - hit_dict = hit.to_dict() if "t-pot_ip_ext" in hit: sensor = self.sensor_repo.get_or_create_sensor(hit["t-pot_ip_ext"]) - hit_dict["_sensor"] = sensor # include sensor for strategies + hit["_sensor"] = sensor # include sensor for strategies - sensor_country = hit_dict.get("geoip_ext", {}).get("country_name") + sensor_country = hit.get("geoip_ext", {}).get("country_name") if sensor_country is not None: self.sensor_repo.update_country(sensor, sensor_country) - hits_by_honeypot[hit["type"]].append(hit_dict) + hits_by_honeypot[hit["type"]].append(hit) # 3. Extract using strategies for honeypot, hits in sorted(hits_by_honeypot.items()): if not self.ioc_repo.is_ready_for_extraction(honeypot): self.log.info(f"Skipping honeypot {honeypot}") continue + + self.log.info(f"Collect hits for activity buckets from honeypot {honeypot}") + bucket_updater.collect_hits(hits) + self.log.info(f"Extracting hits from honeypot {honeypot}") strategy = factory.get_strategy(honeypot) try: @@ -106,7 +113,11 @@ def execute(self) -> int: UpdateScores().score_only(ioc_records) ioc_record_count += len(ioc_records) - # 5. Invalidate API caches only if any IOC records were processed + # 5. Update activity buckets + self.log.info("Updating activity buckets") + bucket_updater.update() + + # 6. Invalidate API caches only if any IOC records were processed if ioc_record_count > 0: # Use the shared DB-backed cache so the version bump is visible to # gunicorn API workers (LocMemCache is per-process). @@ -117,4 +128,12 @@ def execute(self) -> int: except ValueError: shared_cache.set("asn_feeds_version", 2, timeout=None) + if bucket_updater.total_update_count > 0: + self.log.info("Invalidating feeds trending cache") + shared_cache = caches["django-q"] + try: + shared_cache.incr("trending_feeds_version") + except ValueError: + shared_cache.set("trending_feeds_version", 2, timeout=None) + return ioc_record_count diff --git a/greedybear/cronjobs/extraction/utils.py b/greedybear/cronjobs/extraction/utils.py index 62e2ca961..bdd002d3c 100644 --- a/greedybear/cronjobs/extraction/utils.py +++ b/greedybear/cronjobs/extraction/utils.py @@ -9,7 +9,7 @@ from greedybear.cronjobs.repositories import ASRepository from greedybear.enums import IpReputation from greedybear.models import IOC, FireHolList, MassScanner -from greedybear.utils import get_ioc_type, parse_timestamp +from greedybear.utils import get_ioc_type, is_non_global_ip, parse_timestamp def normalize_credential_field(value: object, max_length: int = 256) -> str: @@ -135,7 +135,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: as_repository = ASRepository() # single instance for this batch for ip, hits in hits_by_ip.items(): extracted_ip = ip_address(ip) - if extracted_ip.is_loopback or extracted_ip.is_private or extracted_ip.is_multicast or extracted_ip.is_link_local or extracted_ip.is_reserved: + if is_non_global_ip(extracted_ip): continue firehol_categories = get_firehol_categories(ip, extracted_ip, firehol_exact_map, cidr_entries) @@ -161,7 +161,7 @@ def iocs_from_hits(hits: list[dict]) -> list[IOC]: geoip = next((h.get("geoip") for h in hits if h.get("geoip")), {}) attacker_country = geoip.get("country_name", "") - raw_country_code = geoip.get("country_iso_code", "") + raw_country_code = geoip.get("country_code2", "") attacker_country_code = raw_country_code if len(raw_country_code) == 2 else "" asn = geoip.get("asn") diff --git a/greedybear/cronjobs/repositories/__init__.py b/greedybear/cronjobs/repositories/__init__.py index 337eac0c8..39d9bf07a 100644 --- a/greedybear/cronjobs/repositories/__init__.py +++ b/greedybear/cronjobs/repositories/__init__.py @@ -7,3 +7,4 @@ from greedybear.cronjobs.repositories.sensor import * from greedybear.cronjobs.repositories.tag import * from greedybear.cronjobs.repositories.tor import * +from greedybear.cronjobs.repositories.trending_bucket import * diff --git a/greedybear/cronjobs/repositories/elastic.py b/greedybear/cronjobs/repositories/elastic.py index 0ca0716ff..55686ee2d 100644 --- a/greedybear/cronjobs/repositories/elastic.py +++ b/greedybear/cronjobs/repositories/elastic.py @@ -5,7 +5,7 @@ from django.conf import settings from elasticsearch.dsl import Q, Search -from greedybear.consts import REQUIRED_FIELDS +from greedybear.consts import FIELDS_TO_EXTRACT from greedybear.settings import EXTRACTION_INTERVAL @@ -56,7 +56,7 @@ def search(self, minutes_back_to_lookup: int) -> Iterator[list]: minutes_back_to_lookup: Number of minutes to look back from the current time. Yields: - list: Log entries sorted by @timestamp for each chunk, containing only REQUIRED_FIELDS. + list: Log entries sorted by @timestamp for each chunk, containing only FIELDS_TO_EXTRACT. Raises: ElasticServerDownError: If Elasticsearch is unreachable. @@ -72,7 +72,7 @@ def search(self, minutes_back_to_lookup: int) -> Iterator[list]: search = Search(using=self.elastic_client, index="logstash-*") q = Q("range", **{"@timestamp": {"gte": chunk_start, "lt": chunk_end}}) search = search.query(q) - search.source(REQUIRED_FIELDS) + search = search.source(FIELDS_TO_EXTRACT) result = list(search.scan()) self.log.debug(f"found {len(result)} hits") result.sort(key=lambda hit: hit["@timestamp"]) diff --git a/greedybear/cronjobs/repositories/trending_bucket.py b/greedybear/cronjobs/repositories/trending_bucket.py new file mode 100644 index 000000000..5b7f9101c --- /dev/null +++ b/greedybear/cronjobs/repositories/trending_bucket.py @@ -0,0 +1,73 @@ +from collections import Counter +from collections.abc import Iterable +from datetime import datetime + +from django.db import connection +from django.db.models import Sum + +from greedybear.models import AttackerActivityBucket + +BucketKey = tuple[str, str, datetime] + + +class TrendingBucketRepository: + """Repository for reading and writing aggregated attacker activity buckets.""" + + UPSERT_BATCH_SIZE = 10_000 + _UPSERT_VALUE_PLACEHOLDER = "(%s, %s, %s, %s)" + + @classmethod + def _build_upsert_query(cls, quoted_table_name: str, row_count: int) -> str: + values_sql = ",".join([cls._UPSERT_VALUE_PLACEHOLDER] * row_count) + return f""" + INSERT INTO {quoted_table_name} (attacker_ip, feed_type, bucket_start, interaction_count) + VALUES {values_sql} + ON CONFLICT (attacker_ip, feed_type, bucket_start) + DO UPDATE + SET interaction_count = {quoted_table_name}.interaction_count + EXCLUDED.interaction_count + """ + + @staticmethod + def _build_upsert_params(batch: list[tuple[BucketKey, int]]) -> list[object]: + params: list[object] = [] + for (attacker_ip, feed_type, bucket_start), interaction_count in batch: + params.extend((attacker_ip, feed_type, bucket_start, interaction_count)) + return params + + @staticmethod + def _normalize_feed_types(feed_types: str | Iterable[str]) -> list[str]: + if isinstance(feed_types, str): + return [feed_types] + return list(feed_types) + + def upsert_bucket_counts(self, counters: Counter[BucketKey]) -> int: + """Insert or increment bucket counts in batches and return the number of unique keys.""" + if not counters: + return 0 + + table_name = AttackerActivityBucket._meta.db_table + quoted_table_name = connection.ops.quote_name(table_name) + counter_items = list(counters.items()) + with connection.cursor() as cursor: + for batch_start in range(0, len(counter_items), self.UPSERT_BATCH_SIZE): + batch = counter_items[batch_start : batch_start + self.UPSERT_BATCH_SIZE] + query = self._build_upsert_query(quoted_table_name, len(batch)) + params = self._build_upsert_params(batch) + cursor.execute(query, params) + + return len(counters) + + def get_counts_in_window(self, window_start: datetime, window_end: datetime, feed_types: str | Iterable[str]) -> dict[str, int]: + """Return summed interaction counts per attacker IP inside the requested time window.""" + queryset = AttackerActivityBucket.objects.filter(bucket_start__gte=window_start, bucket_start__lt=window_end) + normalized_feed_types = self._normalize_feed_types(feed_types) + + if "all" not in normalized_feed_types: + queryset = queryset.filter(feed_type__in=normalized_feed_types) + + return dict(queryset.values("attacker_ip").annotate(total=Sum("interaction_count")).values_list("attacker_ip", "total")) + + def delete_older_than(self, cutoff: datetime) -> int: + """Delete buckets older than the cutoff and return Django's reported delete count.""" + deleted_count, _ = AttackerActivityBucket.objects.filter(bucket_start__lt=cutoff).delete() + return deleted_count diff --git a/greedybear/cronjobs/schedules.py b/greedybear/cronjobs/schedules.py index 03271a232..eb9615767 100644 --- a/greedybear/cronjobs/schedules.py +++ b/greedybear/cronjobs/schedules.py @@ -48,6 +48,12 @@ def setup_schedules(): "func": "greedybear.tasks.monitor_logs", "cron": "7 * * * *", }, + # Trending Buckets Cleanup: Hourly at :12 + { + "name": "clean_up_trending_buckets", + "func": "greedybear.tasks.clean_up_trending_buckets", + "cron": "12 * * * *", + }, # Cluster Commands: Daily at 01:07 { "name": "cluster_commands", diff --git a/greedybear/cronjobs/trending.py b/greedybear/cronjobs/trending.py new file mode 100644 index 000000000..46d91f1a6 --- /dev/null +++ b/greedybear/cronjobs/trending.py @@ -0,0 +1,112 @@ +from collections.abc import Mapping + +AttackerCounts = Mapping[str, int] +AttackerRank = int | None +RankedAttacker = dict[str, str | int | float | None] + +UNRANKED_ATTACKER_SORT_ORDER = 10**9 + + +def _rank_map(sorted_counts: list[tuple[str, int]]) -> dict[str, int]: + return {attacker_ip: rank for rank, (attacker_ip, _) in enumerate(sorted_counts, start=1)} + + +def growth_score(current_count: int, previous_count: int) -> float: + if previous_count == 0: + return round(float(current_count), 4) + return round((current_count - previous_count) / previous_count, 4) + + +def rank_delta(current_rank: int | None, previous_rank: int | None) -> int | None: + if current_rank is not None and previous_rank is not None: + return previous_rank - current_rank + if current_rank is None and previous_rank is not None: + return -previous_rank + return None + + +def attacker_sort_tuple( + attacker_ip: str, + current_rank: AttackerRank, + current_count: int, + previous_count: int, +) -> tuple[bool, int, int, int, str]: + return ( + current_rank is None, + current_rank or UNRANKED_ATTACKER_SORT_ORDER, + -(current_count - previous_count), + -previous_count, + attacker_ip, + ) + + +def _ranked_attacker( + attacker_ip: str, + current_rank: AttackerRank, + previous_rank: AttackerRank, + current_count: int, + previous_count: int, +) -> RankedAttacker: + return { + "attacker_ip": attacker_ip, + "current_interactions": current_count, + "previous_interactions": previous_count, + "interaction_delta": current_count - previous_count, + "growth_score": growth_score(current_count, previous_count), + "current_rank": current_rank, + "previous_rank": previous_rank, + "rank_delta": rank_delta(current_rank, previous_rank), + } + + +def build_ranked_attackers(current_counts: AttackerCounts, previous_counts: AttackerCounts, limit: int) -> list[RankedAttacker]: + sorted_current = sorted(current_counts.items(), key=lambda item: (-item[1], item[0])) + sorted_previous = sorted(previous_counts.items(), key=lambda item: (-item[1], item[0])) + + current_ranks = _rank_map(sorted_current) + previous_ranks = _rank_map(sorted_previous) + + candidate_ips = {ip for ip, _ in sorted_current[:limit]} + candidate_ips |= {ip for ip, _ in sorted_previous[:limit]} + + previous_rank_offset = 1 + + def _effective_rank(attacker_ip: str) -> int: + current_rank = current_ranks.get(attacker_ip) + if current_rank is not None: + return current_rank + + previous_rank = previous_ranks.get(attacker_ip) + if previous_rank is not None: + return previous_rank + previous_rank_offset + + return UNRANKED_ATTACKER_SORT_ORDER + + sorted_ips = sorted( + candidate_ips, + key=lambda attacker_ip: ( + _effective_rank(attacker_ip), + -(current_counts.get(attacker_ip, 0) - previous_counts.get(attacker_ip, 0)), + -previous_counts.get(attacker_ip, 0), + attacker_ip, + ), + )[:limit] + + return [ + _ranked_attacker( + attacker_ip, + current_ranks.get(attacker_ip), + previous_ranks.get(attacker_ip), + current_counts.get(attacker_ip, 0), + previous_counts.get(attacker_ip, 0), + ) + for attacker_ip in sorted_ips + ] + + +def validate_window_minutes(window_minutes: int, max_window_minutes: int) -> int: + if window_minutes > max_window_minutes: + raise ValueError(f"window_minutes cannot be greater than {max_window_minutes}") + if window_minutes % 60 != 0: + raise ValueError("window_minutes must be a multiple of 60") + return window_minutes diff --git a/greedybear/migrations/0050_attackeractivitybucket.py b/greedybear/migrations/0050_attackeractivitybucket.py new file mode 100644 index 000000000..2ffe55f9f --- /dev/null +++ b/greedybear/migrations/0050_attackeractivitybucket.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.12 on 2026-03-20 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("greedybear", "0049_rename_generalhoneypot_honeypot_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="AttackerActivityBucket", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("attacker_ip", models.GenericIPAddressField()), + ("feed_type", models.CharField(max_length=32)), + ("bucket_start", models.DateTimeField()), + ("interaction_count", models.IntegerField(default=0)), + ], + options={ + "indexes": [ + models.Index(fields=["bucket_start"], name="greedybear__bucket__ce4aaf_idx"), + models.Index(fields=["feed_type", "bucket_start"], name="greedybear__feed_ty_84e90b_idx"), + models.Index(fields=["attacker_ip", "bucket_start"], name="greedybear__attacke_910f2f_idx"), + ], + "constraints": [ + models.UniqueConstraint(fields=("attacker_ip", "feed_type", "bucket_start"), name="unique_attacker_activity_bucket"), + ], + }, + ), + ] diff --git a/greedybear/models.py b/greedybear/models.py index 4156634c9..be153518e 100644 --- a/greedybear/models.py +++ b/greedybear/models.py @@ -305,3 +305,23 @@ class ShareToken(models.Model): def __str__(self): status = "revoked" if self.revoked else "active" return f"ShareToken({self.token_hash[:12]}… [{status}])" + + +class AttackerActivityBucket(models.Model): + attacker_ip = models.GenericIPAddressField() + feed_type = models.CharField(max_length=32) + bucket_start = models.DateTimeField() + interaction_count = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["attacker_ip", "feed_type", "bucket_start"], name="unique_attacker_activity_bucket"), + ] + indexes = [ + models.Index(fields=["bucket_start"]), + models.Index(fields=["feed_type", "bucket_start"]), + models.Index(fields=["attacker_ip", "bucket_start"]), + ] + + def __str__(self): + return f"{self.attacker_ip} [{self.feed_type}] @ {self.bucket_start} ({self.interaction_count})" diff --git a/greedybear/settings.py b/greedybear/settings.py index e1d9b60f2..c364a4b4c 100644 --- a/greedybear/settings.py +++ b/greedybear/settings.py @@ -59,6 +59,27 @@ CSRF_COOKIE_SAMESITE = "Strict" CSRF_COOKIE_HTTPONLY = True +# Prevent browsers from MIME-sniffing the content type, reducing +# the risk of drive-by downloads. +SECURE_CONTENT_TYPE_NOSNIFF = True + +# Block framing of the site entirely to prevent clickjacking. +# XFrameOptionsMiddleware is already in the middleware stack but +# defaults to SAMEORIGIN; DENY is stricter and appropriate here +# because GreedyBear has no legitimate need to be framed. +X_FRAME_OPTIONS = "DENY" + +if STAGE_PRODUCTION: + # Mark session and CSRF cookies as Secure so they are only + # sent over HTTPS connections. Gated to production because + # local/CI environments run plain HTTP. + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + # NOTE: SECURE_SSL_REDIRECT is intentionally omitted. TLS is + # terminated at nginx, which already redirects HTTP -> HTTPS. + # Enabling it here would cause an infinite redirect loop because + # Django only sees plain HTTP from the gunicorn unix socket. + # Read DJANGO_ALLOWED_HOSTS from env (comma-separated). # Falls back to ["*"] for backward compatibility. # For security checks, see greedybear/checks.py. @@ -69,7 +90,7 @@ ALLOWED_HOSTS = ["*"] # certego_saas -HOST_URI = "http://localhost" +HOST_URI = os.environ.get("HOST_URI", "http://localhost") HOST_NAME = "GreedyBear" # Application definition @@ -465,6 +486,9 @@ COWRIE_SESSION_RETENTION = int(os.environ.get("COWRIE_SESSION_RETENTION", "365")) COMMAND_SEQUENCE_RETENTION = int(os.environ.get("COMMAND_SEQUENCE_RETENTION", "365")) +TRENDING_MAX_WINDOW_MINUTES = int(os.environ.get("TRENDING_MAX_WINDOW_MINUTES", str((24 * 31 * 60) // 2))) +TRENDING_BUCKET_RETENTION_HOURS = int(os.environ.get("TRENDING_BUCKET_RETENTION_HOURS", str(24 * 31))) + THREATFOX_API_KEY = os.environ.get("THREATFOX_API_KEY", "") ABUSEIPDB_API_KEY = os.environ.get("ABUSEIPDB_API_KEY", "") diff --git a/greedybear/tasks.py b/greedybear/tasks.py index 0a26ffc02..8bf906631 100644 --- a/greedybear/tasks.py +++ b/greedybear/tasks.py @@ -105,3 +105,9 @@ def extract_spamhaus_drop(): from greedybear.cronjobs.spamhaus_drop import SpamhausDropCron SpamhausDropCron().execute() + + +def clean_up_trending_buckets(): + from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron + + TrendingBucketCleanupCron().execute() diff --git a/greedybear/utils.py b/greedybear/utils.py index aeb5f310a..b87056ae5 100644 --- a/greedybear/utils.py +++ b/greedybear/utils.py @@ -27,6 +27,19 @@ def is_ip_address(string: str) -> bool: return True +def is_non_global_ip(value) -> bool: + """ + Return True when an IP should be treated as non-global/unroutable. + + Args: + value: IPv4Address or IPv6Address from ipaddress module. + + Returns: + bool: True for loopback/private/multicast/link-local/reserved addresses. + """ + return value.is_loopback or value.is_private or value.is_multicast or value.is_link_local or value.is_reserved + + def is_valid_domain(string: str) -> bool: """ Validate if a string is a safe domain name for use in STIX patterns. diff --git a/pyproject.toml b/pyproject.toml index 29713ba01..33cddbe1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,47 +1,60 @@ [project] name = "greedybear" -version = "3.3.2" -requires-python = "==3.13.*" +version = "3.4.0" +description = "Threat intelligence platform that extracts attack data from a T-Pot or a cluster of them and generates actionable live feeds." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.13,<3.14" dependencies = [ # Django core - "Django==5.2.13", - "djangorestframework==3.17.1", + "Django~=5.2.13", + "djangorestframework~=3.17.1", "django-rest-email-auth==5.0.0", - "django-ses==4.7.2", - "django-q2==1.9.0", - "croniter==6.2.2", + "django-ses~=4.7", + "django-q2~=1.9", + "croniter~=6.2", "certego-saas==0.7.12", # Server Gateway Interface - "gunicorn==25.3.0", + "gunicorn~=25.3", # Data stores "elasticsearch==9.3.0", - "psycopg[c]==3.3.3", + "psycopg[c]~=3.3", # ML / data science - "scikit-learn==1.8.0", - "pandas==3.0.2", - "numpy==2.4.4", - "joblib==1.5.3", - "datasketch==1.9.0", + "scikit-learn~=1.8.0", + "pandas~=3.0", + "numpy~=2.4", + "joblib~=1.5", + "datasketch~=1.10", # File Format Support - "feedparser==6.0.12", - "stix2==3.0.2", + "feedparser~=6.0", + "stix2~=3.0", # Utilities - "requests==2.33.1", - "slack-sdk==3.41.0", + "requests~=2.33", + "slack-sdk~=3.41", ] +[project.urls] +Repository = "https://github.com/GreedyBear-Project/GreedyBear" +Documentation = "https://github.com/GreedyBear-Project/GreedyBear/wiki" +Blog = "https://greedybear-project.github.io/" + [dependency-groups] dev = [ + "django-watchfiles~=1.4", +] +test = [ "coverage==7.13.5", "django-test-migrations==1.5.0", - "django-watchfiles==1.4.0", ] lint = [ - "ruff==0.15.10", + "ruff==0.15.11", ] [tool.uv] +# necessary because certego-saas transitively depends on djangorestframework-filters 1.0.0.dev2 (dev prerelease) prerelease = "allow" +# necessary because certego-saas pins Markdown <3.4, but we want a current release for security fixes override-dependencies = ["markdown==3.10.2"] [tool.ruff] @@ -49,6 +62,8 @@ extend-exclude = [ ".github", ".idea", ".vscode", + ".venv", + "venv", "**/migrations/*", ] include = ["*.py"] @@ -68,17 +83,30 @@ skip-magic-trailing-comma = false [tool.ruff.lint] select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "DJ", # flake8-django "E", # pycodestyle errors - "W", # pycodestyle warnings "F", # pyflakes "I", # isort + "ICN", # flake8-import-conventions + "LOG", # flake8-logging "N", # pep8-naming + "TC", # flake8-type-checking "UP", # pyupgrade - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "DJ", # flake8-django + "W", # pycodestyle warnings ] -ignore = [ - "F403", # Allow wildcard imports in __init__.py files - "E501", # Allow long lines in docstrings + +[tool.ruff.lint.per-file-ignores] +# Re-exporting submodules with wildcard imports is intentional in package __init__.py files +"**/__init__.py" = ["F403"] +# Long parameter documentation lines in API view docstrings +"api/views/*" = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = [ + "api", + "authentication", + "configuration", + "greedybear", ] diff --git a/static/gsoc_logo.png b/static/gsoc_logo.png new file mode 100644 index 000000000..9c5ca29fb Binary files /dev/null and b/static/gsoc_logo.png differ diff --git a/tests/__init__.py b/tests/__init__.py index 021340269..d8e3d58ef 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -53,6 +53,7 @@ def setUpTestData(cls): recurrence_probability=0.1, expected_interactions=11.1, attacker_country="China", + attacker_country_code="CN", ) cls.ioc_2 = IOC.objects.create( @@ -74,6 +75,7 @@ def setUpTestData(cls): recurrence_probability=0.1, expected_interactions=11.1, attacker_country="China", + attacker_country_code="CN", ) cls.ioc_3 = IOC.objects.create( @@ -95,6 +97,7 @@ def setUpTestData(cls): recurrence_probability=0.1, expected_interactions=11.1, attacker_country="United States", + attacker_country_code="US", ) cls.ioc_domain = IOC.objects.create( @@ -144,6 +147,7 @@ def setUpTestData(cls): attack_count=1, interaction_count=1, attacker_country="Russia", + attacker_country_code="RU", ) cls.ioc_inactive_country.honeypots.add(cls.ddospot) cls.ioc_inactive_country.save() diff --git a/tests/api/views/test_cowrie_session_view.py b/tests/api/views/test_cowrie_session_view.py index 5d08a6ad4..fa960d5bb 100644 --- a/tests/api/views/test_cowrie_session_view.py +++ b/tests/api/views/test_cowrie_session_view.py @@ -1,6 +1,7 @@ from django.test import override_settings from rest_framework.test import APIClient +from greedybear.models import CowrieSession from tests import CustomTestCase @@ -34,6 +35,27 @@ def test_ip_address_query_with_similar(self): self.assertNotIn("sessions", response.data) self.assertEqual(len(response.data["sources"]), 2) + def test_include_similar_excludes_non_positive_duration_sessions(self): + """Test that include_similar only returns sessions with duration > 0.""" + CowrieSession.objects.create( + session_id=int("dddddddddddd", 16), + start_time=self.current_time, + duration=0, + login_attempt=True, + command_execution=True, + interaction_count=1, + source=self.ioc_3, + commands=self.command_sequence_2, + ) + + response = self.client.get("/api/cowrie_session?query=140.246.171.141&include_similar=true&include_session_data=true") + + self.assertEqual(response.status_code, 200) + self.assertIn("99.99.99.99", response.data["sources"]) + self.assertNotIn("100.100.100.100", response.data["sources"]) + self.assertTrue(any(session["duration"] > 0 for session in response.data["sessions"])) + self.assertFalse(any(session["source"] == "100.100.100.100" for session in response.data["sessions"])) + def test_ip_address_query_with_credentials(self): """Test view with a valid IP address query including credentials.""" response = self.client.get("/api/cowrie_session?query=140.246.171.141&include_credentials=true") diff --git a/tests/api/views/test_enrichment_view.py b/tests/api/views/test_enrichment_view.py index e7ad06f4d..21772f8e0 100644 --- a/tests/api/views/test_enrichment_view.py +++ b/tests/api/views/test_enrichment_view.py @@ -1,5 +1,6 @@ from rest_framework.test import APIClient +from greedybear.models import Sensor from tests import CustomTestCase @@ -86,3 +87,16 @@ def test_valid_domain(self): response = self.client.get("/api/enrichment?query=example.com") self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["found"], False) + + def test_enrichment_includes_sensors(self): + """Sensors field appears in enrichment response for authenticated users.""" + + sensor = Sensor.objects.create(address="10.0.0.3", label="enrichment-sensor") + self.ioc.sensors.add(sensor) + response = self.client.get(f"/api/enrichment?query={self.ioc.name}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["found"], True) + sensors = response.json()["ioc"]["sensors"] + self.assertEqual(len(sensors), 1) + self.assertEqual(sensors[0]["address"], "10.0.0.3") + self.assertEqual(sensors[0]["label"], "enrichment-sensor") diff --git a/tests/api/views/test_feeds_advanced_view.py b/tests/api/views/test_feeds_advanced_view.py index 2b70df106..daa303056 100644 --- a/tests/api/views/test_feeds_advanced_view.py +++ b/tests/api/views/test_feeds_advanced_view.py @@ -8,7 +8,7 @@ from rest_framework.test import APIClient from api.throttles import SharedFeedRateThrottle -from greedybear.models import IOC, AutonomousSystem, IocType, ShareToken +from greedybear.models import IOC, AutonomousSystem, IocType, Sensor, ShareToken from tests import CustomTestCase @@ -109,15 +109,37 @@ def test_200_feed_contains_attacker_country_code(self): """ self.ioc.attacker_country_code = "NP" self.ioc.save() - response = self.client.get("/api/feeds/advanced/") - iocs = response.json()["iocs"] target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None) - self.assertIsNotNone(target_ioc) self.assertEqual(target_ioc["attacker_country_code"], "NP") + def test_feeds_advanced_includes_sensors(self): + """Sensors field appears in feeds_advanced response for authenticated users.""" + sensor = Sensor.objects.create(address="10.0.0.1", label="test-sensor") + self.ioc.sensors.add(sensor) + response = self.client.get("/api/feeds/advanced/") + self.assertEqual(response.status_code, 200) + iocs = response.json()["iocs"] + target_ioc = next((i for i in iocs if i["value"] == self.ioc.name), None) + self.assertIsNotNone(target_ioc) + self.assertIn("sensors", target_ioc) + self.assertEqual(len(target_ioc["sensors"]), 1) + self.assertEqual(target_ioc["sensors"][0]["address"], "10.0.0.1") + self.assertEqual(target_ioc["sensors"][0]["label"], "test-sensor") + + def test_public_feeds_excludes_sensors(self): + """Sensors field must NOT appear in public feeds response.""" + sensor = Sensor.objects.create(address="10.0.0.2", label="secret-sensor") + self.ioc.sensors.add(sensor) + self.client.logout() + response = self.client.get("/api/feeds/cowrie/all/recent.json") + self.assertEqual(response.status_code, 200) + iocs = response.json()["iocs"] + for ioc in iocs: + self.assertNotIn("sensors", ioc) + class FeedsEnhancementsTestCase(CustomTestCase): """Tests for advanced filtering, STIX export, and shareable feeds functionality.""" @@ -236,12 +258,12 @@ def test_filter_by_date_range(self): def test_filter_by_country_code(self): """Filter by country_code returns only matching IOCs.""" - self.ioc.attacker_country_code = "CN" + self.ioc.attacker_country_code = "IT" self.ioc.save() - self.ioc2.attacker_country_code = "US" + self.ioc2.attacker_country_code = "FR" self.ioc2.save() - response = self.client.get("/api/feeds/advanced/?country_code=CN") + response = self.client.get("/api/feeds/advanced/?country_code=IT") self.assertEqual(response.status_code, 200) iocs = response.json()["iocs"] self.assertEqual(len(iocs), 1) diff --git a/tests/api/views/test_feeds_share_view.py b/tests/api/views/test_feeds_share_view.py new file mode 100644 index 000000000..73009eee9 --- /dev/null +++ b/tests/api/views/test_feeds_share_view.py @@ -0,0 +1,157 @@ +from rest_framework.test import APIClient + +from greedybear.models import ShareToken +from tests import CustomTestCase + + +class FeedsShareReasonTestCase(CustomTestCase): + """Tests for the optional ?reason= parameter on GET /api/feeds/share.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + self.client.force_authenticate(user=self.superuser) + + def test_share_without_reason(self): + """When no reason is supplied, ShareToken.reason defaults to empty string.""" + response = self.client.get("/api/feeds/share") + self.assertEqual(response.status_code, 200) + + token_hash = self._hash_from_url(response.json()["url"]) + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(share_token.reason, "") + + def test_share_with_reason(self): + """When ?reason=... is supplied, it is persisted on the ShareToken.""" + response = self.client.get("/api/feeds/share?reason=monthly+report") + self.assertEqual(response.status_code, 200) + + token_hash = self._hash_from_url(response.json()["url"]) + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(share_token.reason, "monthly report") + + def test_share_reason_truncated_at_256_chars(self): + """A reason longer than 256 characters is silently truncated.""" + long_reason = "x" * 300 + response = self.client.get(f"/api/feeds/share?reason={long_reason}") + self.assertEqual(response.status_code, 200) + + token_hash = self._hash_from_url(response.json()["url"]) + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(len(share_token.reason), 256) + + def test_share_reason_only_set_on_create(self): + """get_or_create: when the same token already exists, reason is NOT overwritten.""" + r1 = self.client.get("/api/feeds/share?reason=first") + self.assertEqual(r1.status_code, 200) + + # Same feed params → same signed token → get_or_create returns existing record + r2 = self.client.get("/api/feeds/share?reason=second") + self.assertEqual(r2.status_code, 200) + + # Both calls must have produced the exact same token/URL + self.assertEqual(r1.json()["url"], r2.json()["url"]) + + # Only one ShareToken should exist (not two) + token_hash = self._hash_from_url(r1.json()["url"]) + self.assertEqual(ShareToken.objects.filter(token_hash=token_hash).count(), 1) + + share_token = ShareToken.objects.get(token_hash=token_hash) + self.assertEqual(share_token.reason, "first") + + # ── helpers ──────────────────────────────────────────────────────────── + + @staticmethod + def _hash_from_url(url): + import hashlib + + raw_token = url.split("/api/feeds/consume/")[1] + return hashlib.sha256(raw_token.encode()).hexdigest() + + +class FeedsTokensListTestCase(CustomTestCase): + """Tests for GET /api/feeds/tokens/ — lists the calling user's share tokens.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + self.client.force_authenticate(user=self.superuser) + + def test_empty_list(self): + """A user with no tokens gets an empty JSON list.""" + response = self.client.get("/api/feeds/tokens/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_list_returns_own_tokens(self): + """After creating two tokens, the list endpoint returns both.""" + self.client.get("/api/feeds/share?reason=alpha") + self.client.get("/api/feeds/share?reason=beta&asn=11111") + response = self.client.get("/api/feeds/tokens/") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 2) + + reasons = {t["reason"] for t in data} + self.assertEqual(reasons, {"alpha", "beta"}) + + def test_list_returns_only_own_tokens(self): + """Tokens created by another user are NOT visible.""" + self.client.get("/api/feeds/share?reason=superuser-token") + + other_client = APIClient() + other_client.force_authenticate(user=self.regular_user) + other_client.get("/api/feeds/share?reason=regular-token&asn=22222") + + # superuser sees only their own + response = self.client.get("/api/feeds/tokens/") + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["reason"], "superuser-token") + + # regular_user sees only their own + response = other_client.get("/api/feeds/tokens/") + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["reason"], "regular-token") + + def test_hash_prefix_not_full_hash(self): + """The response exposes only the first 12 chars of the hash.""" + self.client.get("/api/feeds/share?reason=test") + response = self.client.get("/api/feeds/tokens/") + data = response.json() + self.assertEqual(len(data[0]["hash_prefix"]), 12) + + def test_token_metadata_fields(self): + """Each token entry contains the expected metadata keys.""" + self.client.get("/api/feeds/share?reason=check-fields") + response = self.client.get("/api/feeds/tokens/") + token = response.json()[0] + expected_keys = {"hash_prefix", "reason", "created_at", "revoked", "revoked_at"} + self.assertEqual(set(token.keys()), expected_keys) + self.assertFalse(token["revoked"]) + self.assertIsNone(token["revoked_at"]) + + def test_revoked_token_shows_status(self): + """After revocation, the token list reflects revoked=True.""" + share = self.client.get("/api/feeds/share?reason=to-revoke") + raw_token = share.json()["url"].split("/api/feeds/consume/")[1] + self.client.get(f"/api/feeds/revoke/{raw_token}") + + response = self.client.get("/api/feeds/tokens/") + token = response.json()[0] + self.assertTrue(token["revoked"]) + self.assertIsNotNone(token["revoked_at"]) + + def test_unauthenticated_returns_401_or_403(self): + """Unauthenticated requests are rejected.""" + anon = APIClient() + response = anon.get("/api/feeds/tokens/") + self.assertIn(response.status_code, [401, 403]) + + def test_list_ordering_newest_first(self): + """Tokens are returned newest-first (descending created_at).""" + self.client.get("/api/feeds/share?reason=first") + self.client.get("/api/feeds/share?reason=second&asn=22222") + response = self.client.get("/api/feeds/tokens/") + data = response.json() + self.assertEqual(data[0]["reason"], "second") + self.assertEqual(data[1]["reason"], "first") diff --git a/tests/api/views/test_feeds_throttle.py b/tests/api/views/test_feeds_throttle.py deleted file mode 100644 index c69e9c535..000000000 --- a/tests/api/views/test_feeds_throttle.py +++ /dev/null @@ -1,88 +0,0 @@ -from unittest.mock import patch - -from django.core.cache import cache -from rest_framework import status -from rest_framework.test import APIClient - -from tests import CustomTestCase - - -class FeedsThrottleTestCase(CustomTestCase): - """Tests that rate limiting is applied to feeds endpoints.""" - - def setUp(self): - super().setUp() - cache.clear() - self.client = APIClient() - self.client.force_authenticate(user=self.superuser) - - @patch("api.throttles.FeedsAdvancedThrottle.get_rate", return_value="1/minute") - def test_feeds_advanced_throttled(self, mock_rate): - """Verify feeds_advanced returns 429 after exceeding the rate limit.""" - response = self.client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - @patch("api.throttles.FeedsAdvancedThrottle.get_rate", return_value="1/minute") - def test_feeds_asn_throttled(self, mock_rate): - """Verify feeds_asn returns 429 after exceeding the rate limit.""" - response = self.client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - @patch("api.throttles.FeedsThrottle.get_rate", return_value="1/minute") - def test_feeds_pagination_throttled(self, mock_rate): - """Verify feeds_pagination returns 429 after exceeding the rate limit.""" - response = self.client.get("/api/feeds/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get("/api/feeds/") - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - @patch("api.throttles.FeedsThrottle.get_rate", return_value="1/minute") - def test_feeds_legacy_throttled(self, mock_rate): - """Verify legacy feeds endpoint returns 429 after exceeding the rate limit.""" - url = "/api/feeds/all/all/recent.json" - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) - - def test_feeds_advanced_within_limit(self): - """Verify feeds_advanced succeeds when within the rate limit.""" - response = self.client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_asn_within_limit(self): - """Verify feeds_asn succeeds when within the rate limit.""" - response = self.client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_unauthenticated_access(self): - """Verify public feeds endpoints are accessible without authentication.""" - client = APIClient() - response = client.get("/api/feeds/all/all/recent.json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_pagination_unauthenticated_access(self): - """Verify public feeds pagination endpoint is accessible without authentication.""" - client = APIClient() - response = client.get("/api/feeds/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_feeds_advanced_unauthenticated_rejected(self): - """Verify authenticated feeds_advanced endpoint rejects unauthenticated requests.""" - client = APIClient() - response = client.get("/api/feeds/advanced/") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_feeds_asn_unauthenticated_rejected(self): - """Verify authenticated feeds_asn endpoint rejects unauthenticated requests.""" - client = APIClient() - response = client.get("/api/feeds/asn/") - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/tests/api/views/test_news_view.py b/tests/api/views/test_news_view.py index 3cb78ca19..725fb9845 100644 --- a/tests/api/views/test_news_view.py +++ b/tests/api/views/test_news_view.py @@ -32,38 +32,6 @@ def test_returns_cached_data(self, mock_parse): self.assertEqual(result, cached_data) mock_parse.assert_not_called() - @patch("api.views.utils.feedparser.parse") - def test_filters_only_greedybear_posts(self, mock_parse): - mock_parse.return_value = FeedParserDict( - entries=[ - FeedParserDict( - title="IntelOwl Update", - summary="intelowl news", - published="Wed, 01 Jan 2026 00:00:00 GMT", - published_parsed=(2026, 1, 1, 0, 0, 0, 2, 1, 0), - link="https://example.com/1", - ), - FeedParserDict( - title="GreedyBear v3 Release", - summary="greedybear release notes", - published="Thu, 29 Jan 2026 00:00:00 GMT", - published_parsed=(2026, 1, 29, 0, 0, 0, 3, 29, 0), - link="https://example.com/2", - ), - FeedParserDict( - title="IntelOwl Improvements", - summary="Not related to GreedyBear", - published="Mon, 01 Sep 2025 00:00:00 GMT", - published_parsed=(2025, 9, 1, 0, 0, 0, 0, 244, 0), - link="https://example.com/3", - ), - ] - ) - - result = get_greedybear_news() - self.assertEqual(len(result), 1) - self.assertEqual(result[0]["title"], "GreedyBear v3 Release") - @patch("api.views.utils.feedparser.parse") def test_sorts_posts_by_date_desc(self, mock_parse): mock_parse.return_value = FeedParserDict( diff --git a/tests/api/views/test_statistics_view.py b/tests/api/views/test_statistics_view.py index 59a7b4dfa..fb951559a 100644 --- a/tests/api/views/test_statistics_view.py +++ b/tests/api/views/test_statistics_view.py @@ -65,6 +65,12 @@ def test_200_countries(self): self.assertNotIn("Russia", countries) self.assertEqual(counts["China"], 2) self.assertEqual(counts["United States"], 1) + + # check codes + codes = {item["country"]: item["code"] for item in data} + self.assertEqual(codes["China"], "CN") + self.assertEqual(codes["United States"], "US") + # Results must be ordered descending by count count_values = [item["count"] for item in data] self.assertEqual(count_values, sorted(count_values, reverse=True)) diff --git a/tests/authentication/test_auth.py b/tests/authentication/test_auth.py index 721e13143..0367fee1d 100644 --- a/tests/authentication/test_auth.py +++ b/tests/authentication/test_auth.py @@ -61,6 +61,14 @@ def test_login_200(self): self.assertEqual(AuthToken.objects.count(), 1) + def test_login_does_not_set_current_site_cache(self): + """Login must not store the Host header in global cache.""" + cache.clear() + body = {**self.creds} + response = self.client.post(login_uri, body) + self.assertEqual(response.status_code, 200) + self.assertIsNone(cache.get("current_site")) + def test_logout_204(self): self.assertEqual(AuthToken.objects.count(), 0) diff --git a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py index b9dcdbecd..a0561d542 100644 --- a/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py +++ b/tests/greedybear/cronjobs/test_extraction_pipeline_edge_cases.py @@ -60,6 +60,134 @@ def test_partial_strategy_success(self, mock_factory, mock_scores): # Scoring should be called with successful IOCs mock_scores.return_value.score_only.assert_called_once() + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_activity_bucket_update_failure_does_not_abort_extraction(self, mock_factory, mock_scores, mock_bucket_updater_cls): + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "2.2.2.2", "type": "SuccessHoneypot"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.return_value = True + + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 0 + + mock_success = MagicMock() + mock_success.ioc_records = [self._create_mock_ioc("2.2.2.2")] + mock_factory.return_value.get_strategy.return_value = mock_success + + result = pipeline.execute() + + self.assertEqual(result, 1) + mock_success.extract_from_hits.assert_called_once() + mock_scores.return_value.score_only.assert_called_once() + bucket_updater.collect_hits.assert_called_once() + bucket_updater.update.assert_called_once() + + @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_bucket_updates_invalidate_trending_cache(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "2.2.2.2", "type": "SuccessHoneypot"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.return_value = True + + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 2 + + mock_strategy = MagicMock() + mock_strategy.ioc_records = [] + mock_factory.return_value.get_strategy.return_value = mock_strategy + + shared_cache = MagicMock() + mock_caches.__getitem__.return_value = shared_cache + + result = pipeline.execute() + + self.assertEqual(result, 0) + bucket_updater.collect_hits.assert_called_once() + bucket_updater.update.assert_called_once() + shared_cache.incr.assert_called_once_with("trending_feeds_version") + mock_scores.return_value.score_only.assert_not_called() + + @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_disabled_honeypot_hits_do_not_update_activity_buckets(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + """Hits from honeypots not ready for extraction must be excluded from bucket updates.""" + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledHoneypot"}), + MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledHoneypot"}), + MockElasticHit({"src_ip": "2.2.2.2", "type": "EnabledHoneypot"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.side_effect = lambda hp: hp == "EnabledHoneypot" + + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 0 + + mock_strategy = MagicMock() + mock_strategy.ioc_records = [] + mock_factory.return_value.get_strategy.return_value = mock_strategy + + shared_cache = MagicMock() + mock_caches.__getitem__.return_value = shared_cache + + pipeline.execute() + + # collect_hits must be called exactly once, only with hits from the enabled honeypot. + bucket_updater.collect_hits.assert_called_once() + passed_hits = list(bucket_updater.collect_hits.call_args.args[0]) + self.assertEqual(len(passed_hits), 1) + self.assertEqual(passed_hits[0]["type"], "EnabledHoneypot") + self.assertEqual(passed_hits[0]["src_ip"], "2.2.2.2") + + @patch("greedybear.cronjobs.extraction.pipeline.caches") + @patch("greedybear.cronjobs.extraction.pipeline.BucketUpdater") + @patch("greedybear.cronjobs.extraction.pipeline.UpdateScores") + @patch("greedybear.cronjobs.extraction.pipeline.ExtractionStrategyFactory") + def test_all_honeypots_disabled_skips_bucket_updates_entirely(self, mock_factory, mock_scores, mock_bucket_updater_cls, mock_caches): + """When every honeypot in a chunk is disabled, no hits are collected and the trending cache is not invalidated.""" + pipeline = self._create_pipeline_with_real_factory() + pipeline.log = MagicMock() + + hits = [ + MockElasticHit({"src_ip": "1.1.1.1", "type": "DisabledA"}), + MockElasticHit({"src_ip": "2.2.2.2", "type": "DisabledB"}), + ] + pipeline.elastic_repo.search.return_value = [hits] + pipeline.ioc_repo.is_empty.return_value = False + pipeline.ioc_repo.is_ready_for_extraction.return_value = False + + bucket_updater = mock_bucket_updater_cls.return_value + bucket_updater.total_update_count = 0 + + shared_cache = MagicMock() + mock_caches.__getitem__.return_value = shared_cache + + pipeline.execute() + + bucket_updater.collect_hits.assert_not_called() + mock_factory.return_value.get_strategy.assert_not_called() + shared_cache.incr.assert_not_called() + class TestLargeBatches(E2ETestCase): """Tests for large batch processing using REAL strategies.""" diff --git a/tests/greedybear/cronjobs/test_trending.py b/tests/greedybear/cronjobs/test_trending.py new file mode 100644 index 000000000..0ef047eb1 --- /dev/null +++ b/tests/greedybear/cronjobs/test_trending.py @@ -0,0 +1,267 @@ +from datetime import datetime +from unittest.mock import patch + +from django.test import SimpleTestCase, override_settings + +from greedybear.cronjobs.bucket_cleanup import TrendingBucketCleanupCron +from greedybear.cronjobs.extraction.bucket_updater import BucketUpdater +from greedybear.cronjobs.repositories.trending_bucket import TrendingBucketRepository +from greedybear.cronjobs.trending import ( + attacker_sort_tuple, + build_ranked_attackers, + growth_score, + rank_delta, + validate_window_minutes, +) +from greedybear.models import AttackerActivityBucket +from tests import CustomTestCase + + +class TrendingHelpersTestCase(SimpleTestCase): + def test_growth_score(self): + self.assertEqual(growth_score(10, 0), 10.0) + self.assertEqual(growth_score(12, 8), 0.5) + self.assertEqual(growth_score(8, 12), -0.3333) + + def test_rank_delta(self): + self.assertEqual(rank_delta(2, 5), 3) + self.assertEqual(rank_delta(None, 5), -5) + self.assertIsNone(rank_delta(3, None)) + self.assertIsNone(rank_delta(None, None)) + + def test_attacker_sort_tuple_prefers_ranked_entries(self): + ranked = attacker_sort_tuple("1.1.1.1", 2, 10, 7) + unranked = attacker_sort_tuple("2.2.2.2", None, 10, 7) + self.assertLess(ranked, unranked) + + def test_build_ranked_attackers_uses_current_and_previous_candidates(self): + current_counts = {"1.1.1.1": 12, "2.2.2.2": 10, "3.3.3.3": 8} + previous_counts = {"9.9.9.9": 30, "1.1.1.1": 9} + + ranked = build_ranked_attackers(current_counts, previous_counts, limit=3) + + self.assertEqual(len(ranked), 3) + self.assertEqual(ranked[0]["attacker_ip"], "1.1.1.1") + returned_ips = {entry["attacker_ip"] for entry in ranked} + self.assertIn("9.9.9.9", returned_ips) + self.assertEqual(next(entry for entry in ranked if entry["attacker_ip"] == "9.9.9.9")["current_rank"], None) + + +class ValidateWindowMinutesTestCase(SimpleTestCase): + def test_validate_window_minutes_returns_valid_value(self): + self.assertEqual(validate_window_minutes(120, 240), 120) + + def test_validate_window_minutes_raises_when_above_max(self): + with self.assertRaisesMessage(ValueError, "window_minutes cannot be greater than 240"): + validate_window_minutes(300, 240) + + def test_validate_window_minutes_raises_when_not_multiple_of_60(self): + with self.assertRaisesMessage(ValueError, "window_minutes must be a multiple of 60"): + validate_window_minutes(90, 240) + + +class UpdateActivityBucketsFromHitsTestCase(CustomTestCase): + def test_upsert_increments_existing_bucket_and_creates_missing_bucket(self): + AttackerActivityBucket.objects.create( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=3, + ) + + bu = BucketUpdater() + bu.collect_hits( + [ + {"src_ip": "1.1.1.1", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "1.1.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:50:00"}, + {"src_ip": "2.2.2.2", "type": "Heralding", "@timestamp": "2026-03-20T09:10:00"}, + ] + ) + unique_keys = bu.update() + + self.assertEqual(unique_keys, 2) + + existing_bucket = AttackerActivityBucket.objects.get( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + ) + self.assertEqual(existing_bucket.interaction_count, 5) + + created_bucket = AttackerActivityBucket.objects.get( + attacker_ip="2.2.2.2", + feed_type="heralding", + bucket_start=datetime(2026, 3, 20, 9, 0), + ) + self.assertEqual(created_bucket.interaction_count, 1) + + def test_invalid_hits_are_ignored(self): + bu = BucketUpdater() + bu.collect_hits( + [ + {"src_ip": "", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "999.999.999.999", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "3.3.3.3", "type": "", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "3.3.3.3", "type": "cowrie"}, + ] + ) + unique_keys = bu.update() + + self.assertEqual(unique_keys, 0) + self.assertEqual(AttackerActivityBucket.objects.count(), 0) + + def test_invalid_timestamp_is_ignored(self): + bu = BucketUpdater() + bu.collect_hits( + [ + {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "not-a-timestamp"}, + ] + ) + unique_keys = bu.update() + + self.assertEqual(unique_keys, 0) + self.assertEqual(AttackerActivityBucket.objects.count(), 0) + + def test_non_global_ip_hits_are_ignored(self): + bu = BucketUpdater() + bu.collect_hits( + [ + {"src_ip": "10.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "127.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "224.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "169.254.1.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "240.0.0.1", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + {"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}, + ] + ) + unique_keys = bu.update() + + self.assertEqual(unique_keys, 1) + self.assertEqual(AttackerActivityBucket.objects.count(), 1) + self.assertTrue(AttackerActivityBucket.objects.filter(attacker_ip="8.8.8.8").exists()) + + def test_global_ipv6_hit_is_counted(self): + bu = BucketUpdater() + bu.collect_hits( + [ + {"src_ip": "2001:4860:4860::8888", "type": "Cowrie", "@timestamp": "2026-03-20T09:15:00"}, + ] + ) + unique_keys = bu.update() + + self.assertEqual(unique_keys, 1) + self.assertTrue( + AttackerActivityBucket.objects.filter( + attacker_ip="2001:4860:4860::8888", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + ).exists() + ) + + def test_upsert_uses_multiple_batches_for_large_counter_sets(self): + counters = { + ("10.0.0.1", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.2", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.3", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.4", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + ("10.0.0.5", "cowrie", datetime(2026, 3, 20, 9, 0)): 1, + } + + repository = TrendingBucketRepository() + with patch.object(TrendingBucketRepository, "UPSERT_BATCH_SIZE", 2): + with patch("greedybear.cronjobs.repositories.trending_bucket.connection.cursor") as mock_cursor_factory: + mock_cursor = mock_cursor_factory.return_value.__enter__.return_value + inserted = repository.upsert_bucket_counts(counters) + + self.assertEqual(inserted, 5) + self.assertEqual(mock_cursor.execute.call_count, 3) + + @patch("greedybear.cronjobs.extraction.bucket_updater.TrendingBucketRepository.upsert_bucket_counts", side_effect=Exception("db down")) + def test_upsert_failure_returns_zero(self, mock_upsert): + bu = BucketUpdater() + bu.collect_hits([{"src_ip": "8.8.8.8", "type": "cowrie", "@timestamp": "2026-03-20T09:15:00"}]) + unique_keys = bu.update() + self.assertEqual(unique_keys, 0) + mock_upsert.assert_called_once() + + +class TrendingBucketCleanupCronTestCase(CustomTestCase): + def setUp(self): + super().setUp() + self.cron = TrendingBucketCleanupCron() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=2, + TRENDING_MAX_WINDOW_MINUTES=60, + ) + def test_run_applies_bucket_retention_cleanup(self): + AttackerActivityBucket.objects.bulk_create( + [ + AttackerActivityBucket( + attacker_ip="2.2.2.2", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 7, 0), + interaction_count=1, + ), + AttackerActivityBucket( + attacker_ip="3.3.3.3", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=1, + ), + ] + ) + + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + self.cron.run() + + self.assertFalse(AttackerActivityBucket.objects.filter(attacker_ip="2.2.2.2").exists()) + self.assertTrue(AttackerActivityBucket.objects.filter(attacker_ip="3.3.3.3").exists()) + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=0, + ) + def test_run_raises_on_invalid_retention_hours(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=1, + TRENDING_MAX_WINDOW_MINUTES=60, + ) + def test_run_raises_when_retention_cannot_cover_two_windows(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=1, + TRENDING_MAX_WINDOW_MINUTES=120, + ) + def test_run_raises_when_max_window_exceeds_retention_horizon(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=4, + TRENDING_MAX_WINDOW_MINUTES=59, + ) + def test_run_raises_when_max_window_below_60(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + @override_settings( + TRENDING_BUCKET_RETENTION_HOURS=4, + TRENDING_MAX_WINDOW_MINUTES=130, + ) + def test_run_raises_when_max_window_not_multiple_of_60(self): + with patch("greedybear.cronjobs.bucket_cleanup.timezone.now", return_value=datetime(2026, 3, 20, 10, 30, 0)): + with self.assertRaises(ValueError): + self.cron.run() + + def test_positive_int_setting_rejects_non_numeric(self): + with self.assertRaisesMessage(ValueError, "TRENDING_BUCKET_RETENTION_HOURS must be a positive integer"): + self.cron._positive_int_setting("TRENDING_BUCKET_RETENTION_HOURS", "abc") diff --git a/tests/greedybear/management/test_setup_schedules.py b/tests/greedybear/management/test_setup_schedules.py index 78b5384da..b46297e76 100644 --- a/tests/greedybear/management/test_setup_schedules.py +++ b/tests/greedybear/management/test_setup_schedules.py @@ -26,6 +26,10 @@ def test_extraction_interval_10(self, mock_schedule): self.assertEqual(extract_call[1]["defaults"]["schedule_type"], Schedule.CRON) self.assertEqual(extract_call[1]["defaults"]["cron"], "*/10 * * * *") + trending_cleanup_call = next(c for c in calls if c[1]["name"] == "clean_up_trending_buckets") + self.assertEqual(trending_cleanup_call[1]["defaults"]["schedule_type"], Schedule.CRON) + self.assertEqual(trending_cleanup_call[1]["defaults"]["cron"], "12 * * * *") + @patch("greedybear.cronjobs.schedules.Schedule") @override_settings(EXTRACTION_INTERVAL=60) def test_extraction_interval_60_clamps_minute(self, mock_schedule): diff --git a/tests/greedybear/test_security_settings.py b/tests/greedybear/test_security_settings.py new file mode 100644 index 000000000..96bb53802 --- /dev/null +++ b/tests/greedybear/test_security_settings.py @@ -0,0 +1,30 @@ +# This file is a part of GreedyBear https://github.com/honeynet/GreedyBear +# See the file 'LICENSE' for copying permission. +from django.conf import settings +from django.test import SimpleTestCase + + +class SecuritySettingsTests(SimpleTestCase): + def test_content_type_nosniff_enabled(self): + """SECURE_CONTENT_TYPE_NOSNIFF should always be True.""" + self.assertTrue(settings.SECURE_CONTENT_TYPE_NOSNIFF) + + def test_x_frame_options_deny(self): + """X_FRAME_OPTIONS should be DENY to block all framing.""" + self.assertEqual(settings.X_FRAME_OPTIONS, "DENY") + + def test_ssl_redirect_not_set(self): + """SECURE_SSL_REDIRECT must not be enabled (TLS terminates at nginx).""" + self.assertFalse(getattr(settings, "SECURE_SSL_REDIRECT", False)) + + def test_cookie_security_matches_environment(self): + """Cookies should only be marked secure in production.""" + is_production = getattr(settings, "STAGE_PRODUCTION", False) + + # Test SESSION_COOKIE_SECURE + session_secure = getattr(settings, "SESSION_COOKIE_SECURE", False) + self.assertEqual(session_secure, is_production) + + # Test CSRF_COOKIE_SECURE + csrf_secure = getattr(settings, "CSRF_COOKIE_SECURE", False) + self.assertEqual(csrf_secure, is_production) diff --git a/tests/test_elastic_repository.py b/tests/test_elastic_repository.py index 04b8e92d9..083456416 100644 --- a/tests/test_elastic_repository.py +++ b/tests/test_elastic_repository.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from unittest.mock import Mock, call, patch +from greedybear.consts import FIELDS_TO_EXTRACT from greedybear.cronjobs.repositories import ElasticRepository, get_time_window from . import CustomTestCase @@ -113,6 +114,27 @@ def test_search_uses_time_window(self, mock_get_time_window, mock_search_class): mock_get_time_window.assert_called_once() + @patch("greedybear.cronjobs.repositories.elastic.get_time_window") + @patch("greedybear.cronjobs.repositories.elastic.Search") + def test_search_scans_reassigned_source_filtered_search(self, mock_search_class, mock_get_time_window): + base_search = Mock() + filtered_search = Mock() + mock_search_class.return_value = base_search + base_search.query.return_value = base_search + base_search.source.return_value = filtered_search + filtered_search.scan.return_value = iter([{"@timestamp": 1}]) + mock_get_time_window.return_value = (datetime(2025, 1, 1, 12, 0), datetime(2025, 1, 1, 12, 10)) + + chunks = list(self.repo.search(minutes_back_to_lookup=10)) + + self.assertEqual(chunks, [[{"@timestamp": 1}]]) + base_search.source.assert_called_once_with(FIELDS_TO_EXTRACT) + filtered_search.scan.assert_called_once() + base_search.scan.assert_not_called() + + def test_fields_to_extract_include_type_for_trending_bucketing(self): + self.assertIn("type", FIELDS_TO_EXTRACT) + class TestSearchChunking(CustomTestCase): """Tests for the chunked iteration behavior of search().""" diff --git a/tests/test_extraction_utils.py b/tests/test_extraction_utils.py index ca5030913..2e1fa1c6d 100644 --- a/tests/test_extraction_utils.py +++ b/tests/test_extraction_utils.py @@ -693,7 +693,7 @@ def test_ioc_attacker_country_set_correctly(self): self.assertEqual(ioc.interaction_count, 1) def test_ioc_attacker_country_code_set_correctly(self): - """Verify that iocs_from_hits extracts country_iso_code from geoip.""" + """Verify that iocs_from_hits extracts country_code2 from geoip.""" hits = [ self._create_hit( src_ip="8.8.8.8", @@ -702,7 +702,7 @@ def test_ioc_attacker_country_code_set_correctly(self): ) ] - hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NP"} + hits[0]["geoip"] = {"country_name": "Nepal", "country_code2": "NP"} iocs = iocs_from_hits(hits) self.assertEqual(len(iocs), 1) @@ -712,7 +712,7 @@ def test_ioc_attacker_country_code_set_correctly(self): self.assertEqual(ioc.attacker_country_code, "NP") def test_ioc_attacker_country_code_defaults_to_empty(self): - """Verify that attacker_country_code defaults to empty when geoip has no country_iso_code.""" + """Verify that attacker_country_code defaults to empty when geoip has no country_code2.""" hits = [ self._create_hit( src_ip="8.8.8.8", @@ -740,7 +740,7 @@ def test_ioc_attacker_country_code_rejects_invalid_length(self): ) ] - hits[0]["geoip"] = {"country_name": "Nepal", "country_iso_code": "NPL"} + hits[0]["geoip"] = {"country_name": "Nepal", "country_code2": "NPL"} iocs = iocs_from_hits(hits) self.assertEqual(len(iocs), 1) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 7c73287de..70afc8dec 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -71,6 +71,7 @@ def test_simple_wrapper_tasks(self): ("monitor_honeypots", "greedybear.cronjobs.monitor_honeypots.MonitorHoneypots"), ("monitor_logs", "greedybear.cronjobs.monitor_logs.MonitorLogs"), ("clean_up_db", "greedybear.cronjobs.cleanup.CleanUp"), + ("clean_up_trending_buckets", "greedybear.cronjobs.bucket_cleanup.TrendingBucketCleanupCron"), ("get_mass_scanners", "greedybear.cronjobs.mass_scanners.MassScannersCron"), ("get_whatsmyip", "greedybear.cronjobs.whatsmyip.WhatsMyIPCron"), ("extract_firehol_lists", "greedybear.cronjobs.firehol.FireHolCron"), diff --git a/tests/test_trending_bucket_repository.py b/tests/test_trending_bucket_repository.py new file mode 100644 index 000000000..fd1ff8a93 --- /dev/null +++ b/tests/test_trending_bucket_repository.py @@ -0,0 +1,136 @@ +from collections import Counter +from datetime import datetime + +from greedybear.cronjobs.repositories.trending_bucket import TrendingBucketRepository +from greedybear.models import AttackerActivityBucket +from tests import CustomTestCase + + +class TestTrendingBucketRepository(CustomTestCase): + def setUp(self): + super().setUp() + self.repo = TrendingBucketRepository() + + def test_build_upsert_query_contains_expected_placeholders(self): + query = self.repo._build_upsert_query('"greedybear_attackeractivitybucket"', 3) + + self.assertIn("INSERT INTO", query) + self.assertEqual(query.count("(%s, %s, %s, %s)"), 3) + self.assertIn("ON CONFLICT (attacker_ip, feed_type, bucket_start)", query) + + def test_build_upsert_params_flattens_batch(self): + batch = [ + (("1.1.1.1", "cowrie", datetime(2026, 3, 20, 9, 0)), 2), + (("2.2.2.2", "heralding", datetime(2026, 3, 20, 10, 0)), 4), + ] + + params = self.repo._build_upsert_params(batch) + + self.assertEqual( + params, + [ + "1.1.1.1", + "cowrie", + datetime(2026, 3, 20, 9, 0), + 2, + "2.2.2.2", + "heralding", + datetime(2026, 3, 20, 10, 0), + 4, + ], + ) + + def test_normalize_feed_types_from_string(self): + self.assertEqual(self.repo._normalize_feed_types("cowrie"), ["cowrie"]) + + def test_normalize_feed_types_from_iterable(self): + self.assertEqual(self.repo._normalize_feed_types(("cowrie", "heralding")), ["cowrie", "heralding"]) + + def test_upsert_bucket_counts_returns_zero_for_empty_counter(self): + self.assertEqual(self.repo.upsert_bucket_counts(Counter()), 0) + + def test_get_counts_in_window_filters_feed_types(self): + AttackerActivityBucket.objects.bulk_create( + [ + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=4, + ), + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="heralding", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=2, + ), + AttackerActivityBucket( + attacker_ip="2.2.2.2", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=3, + ), + ] + ) + + counts = self.repo.get_counts_in_window( + window_start=datetime(2026, 3, 20, 9, 0), + window_end=datetime(2026, 3, 20, 10, 0), + feed_types=["cowrie"], + ) + + self.assertEqual(counts, {"1.1.1.1": 4, "2.2.2.2": 3}) + + def test_get_counts_in_window_with_all_feed_type_combines_totals(self): + AttackerActivityBucket.objects.bulk_create( + [ + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=4, + ), + AttackerActivityBucket( + attacker_ip="1.1.1.1", + feed_type="heralding", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=2, + ), + ] + ) + + counts = self.repo.get_counts_in_window( + window_start=datetime(2026, 3, 20, 9, 0), + window_end=datetime(2026, 3, 20, 10, 0), + feed_types="all", + ) + + self.assertEqual(counts, {"1.1.1.1": 6}) + + def test_get_counts_in_window_returns_empty_when_no_matches(self): + counts = self.repo.get_counts_in_window( + window_start=datetime(2026, 3, 20, 9, 0), + window_end=datetime(2026, 3, 20, 10, 0), + feed_types=["cowrie"], + ) + self.assertEqual(counts, {}) + + def test_delete_older_than_removes_only_older_rows(self): + old_bucket = AttackerActivityBucket.objects.create( + attacker_ip="1.1.1.1", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 7, 0), + interaction_count=1, + ) + fresh_bucket = AttackerActivityBucket.objects.create( + attacker_ip="2.2.2.2", + feed_type="cowrie", + bucket_start=datetime(2026, 3, 20, 9, 0), + interaction_count=1, + ) + + deleted_count = self.repo.delete_older_than(datetime(2026, 3, 20, 8, 0)) + + self.assertEqual(deleted_count, 1) + self.assertFalse(AttackerActivityBucket.objects.filter(id=old_bucket.id).exists()) + self.assertTrue(AttackerActivityBucket.objects.filter(id=fresh_bucket.id).exists()) diff --git a/tests/test_utils.py b/tests/test_utils.py index f7bd78357..10be6b6dc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,11 @@ # This file is a part of GreedyBear https://github.com/honeynet/GreedyBear # See the file 'LICENSE' for copying permission. +from ipaddress import ip_address + from django.test import SimpleTestCase -from greedybear.utils import is_ip_address, is_sha256hash, is_valid_domain +from greedybear.utils import is_ip_address, is_non_global_ip, is_sha256hash, is_valid_domain class UtilsTestCase(SimpleTestCase): @@ -45,3 +47,15 @@ def test_is_sha256hash(self): self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8555")) # 65 chars self.assertFalse(is_sha256hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4g49b934ca495991b7852b855")) # Invalid char 'g' self.assertFalse(is_sha256hash("")) + + def test_is_non_global_ip(self): + self.assertTrue(is_non_global_ip(ip_address("127.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("10.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("169.254.1.1"))) + self.assertTrue(is_non_global_ip(ip_address("224.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("240.0.0.1"))) + self.assertTrue(is_non_global_ip(ip_address("::1"))) + self.assertTrue(is_non_global_ip(ip_address("fc00::1"))) + + self.assertFalse(is_non_global_ip(ip_address("8.8.8.8"))) + self.assertFalse(is_non_global_ip(ip_address("2001:4860:4860::8888"))) diff --git a/uv.lock b/uv.lock index 613af8979..d05a98ba2 100644 --- a/uv.lock +++ b/uv.lock @@ -54,30 +54,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.89" +version = "1.42.93" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/0c/f7bccb22b245cabf392816baba20f9e95f78ace7dbc580fd40136e80e732/boto3-1.42.89.tar.gz", hash = "sha256:3e43aacc0801bba9bcd23a8c271c089af297a69565f783fcdd357ae0e330bf1e", size = 113165, upload-time = "2026-04-13T19:36:17.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/ac/e6b2b24d53c830500176f710594efcde626186b5b3c9aead6f8837976956/boto3-1.42.93.tar.gz", hash = "sha256:ff81c6bac708cb95c4f8b27e331ac67d95c6908dd86bcb7b15b8941960f2bc4c", size = 113218, upload-time = "2026-04-21T21:30:39.733Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/33/55103ba5ef9975ea54b8d39e69b76eb6e9fded3beae5f01065e26951a3a1/boto3-1.42.89-py3-none-any.whl", hash = "sha256:6204b189f4d0c655535f43d7eaa57ff4e8d965b8463c97e45952291211162932", size = 140556, upload-time = "2026-04-13T19:36:13.894Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2d/fcc35bde9fa47ac463a3023c73838e23e9281cde7f5e86fe7c459c3b72aa/boto3-1.42.93-py3-none-any.whl", hash = "sha256:51e34e30e65bea4df0ff77f91abdcb97297eb74c3b27eb576b2abbd758452967", size = 140554, upload-time = "2026-04-21T21:30:36.581Z" }, ] [[package]] name = "botocore" -version = "1.42.89" +version = "1.42.93" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/cc/e6be943efa9051bd15c2ee14077c2b10d6e27c9e9385fc43a03a5c4ed8b5/botocore-1.42.89.tar.gz", hash = "sha256:95ac52f472dad29942f3088b278ab493044516c16dbf9133c975af16527baa99", size = 15206290, upload-time = "2026-04-13T19:36:02.321Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/d4/eb53f7ed81836696abf7103c9c901a0cace9217328094ca93419016a78c9/botocore-1.42.93.tar.gz", hash = "sha256:9ce49863c50b43f7942edd295fb16bfc6d227264ce6fc32c8f2426ef11b9351b", size = 15239759, upload-time = "2026-04-21T21:30:23.707Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0c/ccc57c9a7bcd4553620bf6f50a3640ba68d189330fc4787dbddb2d851534/botocore-1.42.93-py3-none-any.whl", hash = "sha256:96ae26cd6302a7c7563398517b90a438168a4efdf4f73ab38882cefb8df721cc", size = 14923656, upload-time = "2026-04-21T21:30:17.597Z" }, ] [[package]] @@ -109,11 +109,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -194,15 +194,15 @@ wheels = [ [[package]] name = "datasketch" -version = "1.9.0" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/d1/0b64dbc7626be277413daff298b9f026911389e75562952d5b7c662bbea1/datasketch-1.9.0.tar.gz", hash = "sha256:78d4560e415b0de11f595165887a0e4c9983d07b8f15dce9c53289a86bc12e92", size = 89790, upload-time = "2026-01-18T22:46:46.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699, upload-time = "2026-04-17T23:06:56.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/c8/ef06f4c9a0d7697c14c39475eaccd7d3774c34ecc693691079a21bd0d1f1/datasketch-1.9.0-py3-none-any.whl", hash = "sha256:48c18ae889862793609971c6d6d392d71c86793e838dbddd8aa53af6b1716e8a", size = 96542, upload-time = "2026-01-18T22:46:44.368Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914, upload-time = "2026-04-17T23:06:54.39Z" }, ] [[package]] @@ -483,7 +483,7 @@ wheels = [ [[package]] name = "greedybear" -version = "3.3.2" +version = "3.4.0" source = { virtual = "." } dependencies = [ { name = "certego-saas" }, @@ -509,44 +509,46 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "coverage" }, - { name = "django-test-migrations" }, { name = "django-watchfiles" }, ] lint = [ { name = "ruff" }, ] +test = [ + { name = "coverage" }, + { name = "django-test-migrations" }, +] [package.metadata] requires-dist = [ { name = "certego-saas", specifier = "==0.7.12" }, - { name = "croniter", specifier = "==6.2.2" }, - { name = "datasketch", specifier = "==1.9.0" }, - { name = "django", specifier = "==5.2.13" }, - { name = "django-q2", specifier = "==1.9.0" }, + { name = "croniter", specifier = "~=6.2" }, + { name = "datasketch", specifier = "~=1.10" }, + { name = "django", specifier = "~=5.2.13" }, + { name = "django-q2", specifier = "~=1.9" }, { name = "django-rest-email-auth", specifier = "==5.0.0" }, - { name = "django-ses", specifier = "==4.7.2" }, - { name = "djangorestframework", specifier = "==3.17.1" }, + { name = "django-ses", specifier = "~=4.7" }, + { name = "djangorestframework", specifier = "~=3.17.1" }, { name = "elasticsearch", specifier = "==9.3.0" }, - { name = "feedparser", specifier = "==6.0.12" }, - { name = "gunicorn", specifier = "==25.3.0" }, - { name = "joblib", specifier = "==1.5.3" }, - { name = "numpy", specifier = "==2.4.4" }, - { name = "pandas", specifier = "==3.0.2" }, - { name = "psycopg", extras = ["c"], specifier = "==3.3.3" }, - { name = "requests", specifier = "==2.33.1" }, - { name = "scikit-learn", specifier = "==1.8.0" }, - { name = "slack-sdk", specifier = "==3.41.0" }, - { name = "stix2", specifier = "==3.0.2" }, + { name = "feedparser", specifier = "~=6.0" }, + { name = "gunicorn", specifier = "~=25.3" }, + { name = "joblib", specifier = "~=1.5" }, + { name = "numpy", specifier = "~=2.4" }, + { name = "pandas", specifier = "~=3.0" }, + { name = "psycopg", extras = ["c"], specifier = "~=3.3" }, + { name = "requests", specifier = "~=2.33" }, + { name = "scikit-learn", specifier = "~=1.8.0" }, + { name = "slack-sdk", specifier = "~=3.41" }, + { name = "stix2", specifier = "~=3.0" }, ] [package.metadata.requires-dev] -dev = [ +dev = [{ name = "django-watchfiles", specifier = "~=1.4" }] +lint = [{ name = "ruff", specifier = "==0.15.11" }] +test = [ { name = "coverage", specifier = "==7.13.5" }, { name = "django-test-migrations", specifier = "==1.5.0" }, - { name = "django-watchfiles", specifier = "==1.4.0" }, ] -lint = [{ name = "ruff", specifier = "==0.15.10" }] [[package]] name = "gunicorn" @@ -571,11 +573,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -881,27 +883,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] @@ -981,24 +983,22 @@ sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711 [[package]] name = "simplejson" -version = "3.20.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, - { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, - { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, - { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, - { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, - { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, - { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, - { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, - { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, - { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/91/b0e7a38d63706dde006d1213f9c394ad7702df841c019fb4cf0e3295c58c/simplejson-4.0.1.tar.gz", hash = "sha256:bc13170567a5c856a0e6c16620c0b0388722f7d6382acd8007857624c3dedf3e", size = 115959, upload-time = "2026-04-18T22:46:17.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/94/afe6285b3b39208473ab9056039cf20cac393d1a7942f644ecb7d424463b/simplejson-4.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f934a918ef7a50698a481e18aa713d3075f94d1402a6d293f755d00118f8a975", size = 110975, upload-time = "2026-04-18T22:44:55.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/2c74fdb851af00c18ae11af978e7191972071e26ffe2f5aaccbd0a96e961/simplejson-4.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2b0211da4be9fcbabe11ff9b65f7e06dd8205197b078fe7f1e9cc268cd0e369", size = 90384, upload-time = "2026-04-18T22:44:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/92/af/f457958ef90935e99e1bdf51c16b2a0448726a4db2881d472d22f0259290/simplejson-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:58c125ee57f2a35081c46ca66d76ee3c109394f4aee0d981cb049c5ee5688e62", size = 90387, upload-time = "2026-04-18T22:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/43/58/da3da211a3b91ff0f8b9a3926e3b97b9224cbb94e4a67b998394c0e984a3/simplejson-4.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:24ca278f0ba7b3aff90ed1f873f5f4192ae1da97de72196b4be021f3b1d407a0", size = 186193, upload-time = "2026-04-18T22:45:00.108Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/5757f7eddea36d55593423c5da72cdb1ffd2ae7755d804da08f598cf277a/simplejson-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a6b3d15ab9c8247f7e0a280b2823eff08bd74e7605701c01d7cd54e80a86ed", size = 183647, upload-time = "2026-04-18T22:45:01.484Z" }, + { url = "https://files.pythonhosted.org/packages/1e/27/eb59ff58a78e57db4120e10bc6b7d6409f9c25180b912593c2cfe5637b5c/simplejson-4.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:534a7d90b86a97cf15ebe42d732c41223612d981d08f226025499177692a977d", size = 191880, upload-time = "2026-04-18T22:45:03.067Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/0650672c0b43b9add2c2e5f13f5b2d3770b23bc10688dfe4ea637ef1a4b4/simplejson-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e2b10899f200b675a8ab0e2abd4c4846d2631d90d226fc3079e8b16073aba31d", size = 180312, upload-time = "2026-04-18T22:45:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/00/b9/4a4042c8f24bb53ca03a2d5d794aeefa78965df1213239cf9fe895b40342/simplejson-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dd525b897132493aa65eecd3c5ddc3602e25c625caafbcc958fc87a74a24a1c1", size = 188278, upload-time = "2026-04-18T22:45:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/17/b4/594be43d55ea5100334bc60013c3b310ecde4135b8288dd8183d21881966/simplejson-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:072a481be83b8c3f9167d0463105e0ddfcf2e00a6289c2ad650c3b35920ce0c8", size = 183928, upload-time = "2026-04-18T22:45:07.298Z" }, + { url = "https://files.pythonhosted.org/packages/0d/26/8c09ba0fcf0b8c284a4f0f57605890c443b59cec070c6918baa7600976d9/simplejson-4.0.1-cp313-cp313-win32.whl", hash = "sha256:91eb4b42ab0a2de89919ed3bfa960fca0b13af0816447c89123c6658c36fb0a1", size = 88305, upload-time = "2026-04-18T22:45:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/996ef6a0bd5b02bb9880901df01621b6c6de599360454a2ee2d06985076f/simplejson-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d2dc202a5e9d07e893c0bc1aa12b88bff98332c52b3ae184defe054d0e2ff35", size = 90273, upload-time = "2026-04-18T22:45:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2d/93a5b862ac29f182a658eb3fc2c98fe28acbb5c05a9076402572c7eb6966/simplejson-4.0.1-py3-none-any.whl", hash = "sha256:dfa6e9923c0ec2880738d09e5ce045741eb6cd4551e261dcd6c3625d26666075", size = 69242, upload-time = "2026-04-18T22:46:16.183Z" }, ] [[package]]