From 48060c47baedf1126fa1e88333c23713298f4ade Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:05:09 +0100 Subject: [PATCH 01/28] fix(ui): improve Resource Inventory cards light mode (#10757) Co-authored-by: alejandrobailo --- .../resources-inventory-card-item.test.tsx | 120 ++++++++++++++++++ .../resources-inventory-card-item.tsx | 53 ++++---- .../resources-inventory-skeleton.tsx | 3 +- .../resource-stats-card.tsx | 42 +++++- 4 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.test.tsx diff --git a/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.test.tsx b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.test.tsx new file mode 100644 index 00000000000..9d6683cdf54 --- /dev/null +++ b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from "@testing-library/react"; +import { Shield } from "lucide-react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { ResourceInventoryItem } from "@/actions/overview"; + +import { ResourcesInventoryCardItem } from "./resources-inventory-card-item"; + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +const baseItem: ResourceInventoryItem = { + id: "security", + label: "Security", + icon: Shield, + totalResources: 616, + totalFindings: 319, + failedFindings: 319, + newFailedFindings: 64, + severity: { + critical: 12, + high: 44, + medium: 108, + low: 155, + informational: 0, + }, +}; + +describe("ResourcesInventoryCardItem", () => { + describe("when the group has resources and failed findings", () => { + it("builds a resources link that forwards current page filters", () => { + render( + , + ); + + const link = screen.getByRole("link"); + + expect(link).toHaveAttribute( + "href", + expect.stringContaining("/resources?"), + ); + expect(link).toHaveAttribute( + "href", + expect.stringContaining("filter%5Bgroups__in%5D=security"), + ); + expect(link).toHaveAttribute( + "href", + expect.stringContaining("filter%5Bprovider__in%5D=aws-provider"), + ); + expect(link).toHaveAttribute( + "href", + expect.stringContaining("filter%5Baccount_id__in%5D=account-1"), + ); + }); + + it("renders a fail accent bar so the card is theme-agnostic", () => { + render(); + + const card = screen.getByText("Security").closest("[data-slot='card']"); + const accent = card?.querySelector( + "[data-slot='resource-stats-card-accent']", + ); + + expect(card).not.toBeNull(); + expect(accent).not.toBeNull(); + }); + }); + + describe("when the group has resources but no failed findings", () => { + it("renders a pass accent bar and the ShieldCheck badge", () => { + render( + , + ); + + const card = screen.getByText("Security").closest("[data-slot='card']"); + const accent = card?.querySelector( + "[data-slot='resource-stats-card-accent']", + ); + + expect(accent).not.toBeNull(); + expect(screen.getByRole("link")).toBeInTheDocument(); + }); + }); + + describe("when the group has no resources", () => { + it("renders the empty state without a link", () => { + render( + , + ); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByText("No Findings to display")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.tsx b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.tsx index e820519308c..af4267c58af 100644 --- a/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.tsx +++ b/ui/app/(prowler)/_overview/resources-inventory/_components/resources-inventory-card-item.tsx @@ -1,8 +1,9 @@ -import { Bell, TriangleAlert } from "lucide-react"; +import { Bell, ShieldCheck, TriangleAlert } from "lucide-react"; import Link from "next/link"; import { ResourceInventoryItem } from "@/actions/overview"; import { CardVariant, ResourceStatsCard, StatItem } from "@/components/shadcn"; +import { cn } from "@/lib/utils"; interface ResourcesInventoryCardItemProps { item: ResourceInventoryItem; @@ -15,6 +16,7 @@ export function ResourcesInventoryCardItem({ }: ResourcesInventoryCardItemProps) { const hasFailedFindings = item.failedFindings > 0; const hasResources = item.totalResources > 0; + const accent = hasFailedFindings ? CardVariant.fail : CardVariant.pass; // Build URL with current filters + resource group specific filters const buildResourcesUrl = () => { @@ -49,52 +51,49 @@ export function ResourcesInventoryCardItem({ }); } - // Empty state when no resources + const header = { + icon: item.icon, + title: item.label, + resourceCount: `${item.totalResources.toLocaleString()} Resources`, + }; + if (!hasResources) { - const cardContent = ( + return ( ); - - return cardContent; } - // Card with findings data const cardContent = ( ); if (resourcesUrl) { return ( - + {cardContent} ); diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx index 461fe1ae5ae..45a3b821159 100644 --- a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx +++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory-skeleton.tsx @@ -3,7 +3,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; function ResourceCardSkeleton() { return ( -
+
+ {/* Header */}
diff --git a/ui/components/shadcn/card/resource-stats-card/resource-stats-card.tsx b/ui/components/shadcn/card/resource-stats-card/resource-stats-card.tsx index f9ccadd2817..c36eda56f5e 100644 --- a/ui/components/shadcn/card/resource-stats-card/resource-stats-card.tsx +++ b/ui/components/shadcn/card/resource-stats-card/resource-stats-card.tsx @@ -38,6 +38,15 @@ const cardVariants = cva("", { }, }); +// Neutral surface + colored top bar; reads well in both light and dark modes. +const accentBarByVariant: Record = { + [CardVariant.default]: "", + [CardVariant.fail]: "bg-bg-fail-primary", + [CardVariant.pass]: "bg-bg-pass-primary", + [CardVariant.warning]: "bg-bg-warning-primary", + [CardVariant.info]: "bg-bg-data-info", +}; + export interface ResourceStatsCardProps extends Omit, "color">, VariantProps { @@ -66,6 +75,11 @@ export interface ResourceStatsCardProps // Vertical accent line color (optional, auto-determined from variant) accentColor?: string; + // Horizontal top accent bar. When set, the card renders on a neutral surface + // with a colored bar across the top using design tokens. Prefer this over + // `variant` when the surface needs to read well in both light and dark modes. + accent?: CardVariant; + // Sub-statistics array (flexible items) stats?: StatItem[]; @@ -82,6 +96,7 @@ export const ResourceStatsCard = ({ badge, label, accentColor, + accent, stats = [], variant = CardVariant.default, size = "md", @@ -93,7 +108,14 @@ export const ResourceStatsCard = ({ // Resolve size to ensure it's not null (CVA can return null but we need a defined value) const resolvedSize = size || "md"; - // If containerless, render without outer wrapper + // `accent` takes precedence: it forces a neutral surface and a colored top bar, + // so the card reads well in both themes regardless of `variant`. + const resolvedVariant = accent ? CardVariant.default : variant; + const accentClassName = accent ? accentBarByVariant[accent] : ""; + + // If containerless, render without outer wrapper. `accent` is ignored in this + // mode because the caller supplies the container; consumers that need the + // accent bar can render it themselves or drop containerless. if (containerless) { return (
+ {accent && ( + + )} {header && } {emptyState ? (
From 1093f6c99bbe3c089fd0ce8854b4621d4f59dc20 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Wed, 22 Apr 2026 12:19:03 +0200 Subject: [PATCH 02/28] fix(api): merge Attack Paths findings on short UIDs for AWS resources (#10839) --- api/CHANGELOG.md | 1 + .../backend/tasks/jobs/attack_paths/aws.py | 13 +++ .../backend/tasks/jobs/attack_paths/config.py | 18 ++++ .../tasks/jobs/attack_paths/findings.py | 26 ++++-- .../tasks/jobs/attack_paths/queries.py | 86 +++++++++++-------- .../tasks/tests/test_attack_paths_scan.py | 79 ++++++++++++++++- 6 files changed, 176 insertions(+), 47 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 729325ca380..2d7342a293d 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Prowler API** are documented in this file. - Finding groups aggregated `status` now treats muted findings as resolved: a group is `FAIL` only while at least one non-muted FAIL remains, otherwise it is `PASS` (including fully-muted groups). The `filter[status]` filter and the `sort=status` ordering share the same semantics, keeping `status` consistent with `fail_count` and the orthogonal `muted` flag [(#10825)](https://github.com/prowler-cloud/prowler/pull/10825) - `aggregate_findings` is now idempotent: it deletes the scan's existing `ScanSummary` rows before `bulk_create`, so re-runs (such as the post-mute reaggregation pipeline) no longer violate the `unique_scan_summary` constraint and no longer abort the downstream `DailySeveritySummary` / `FindingGroupDailySummary` recomputation for the affected scan [(#10827)](https://github.com/prowler-cloud/prowler/pull/10827) +- Attack Paths: Findings on AWS were silently dropped during the Neo4j merge for resources whose Cartography node is keyed by a short identifier (e.g. EC2 instances) rather than the full ARN [(#10839)](https://github.com/prowler-cloud/prowler/pull/10839) --- diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py index 7248cc39e8c..4acb37d6418 100644 --- a/api/src/backend/tasks/jobs/attack_paths/aws.py +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -313,3 +313,16 @@ def sync_aws_account( ) return failed_syncs + + +def extract_short_uid(uid: str) -> str: + """Return the short identifier from an AWS ARN or resource ID. + + Supported inputs end in one of: + - `/` (e.g. `instance/i-xxx`) + - `:` (e.g. `function:name`) + - `` (e.g. `bucket-name` or `i-xxx`) + + If `uid` is already a short resource ID, it is returned unchanged. + """ + return uid.rsplit("/", 1)[-1].rsplit(":", 1)[-1] diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py index 5f5c523ceb5..0816626b67d 100644 --- a/api/src/backend/tasks/jobs/attack_paths/config.py +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -37,6 +37,8 @@ class ProviderConfig: # Label for resources connected to the account node, enabling indexed finding lookups. resource_label: str # e.g., "_AWSResource" ingestion_function: Callable + # Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance). + short_uid_extractor: Callable[[str], str] # Provider Configurations @@ -48,6 +50,7 @@ class ProviderConfig: uid_field="arn", resource_label="_AWSResource", ingestion_function=aws.start_aws_ingestion, + short_uid_extractor=aws.extract_short_uid, ) PROVIDER_CONFIGS: dict[str, ProviderConfig] = { @@ -116,6 +119,21 @@ def get_provider_resource_label(provider_type: str) -> str: return config.resource_label if config else "_UnknownProviderResource" +def _identity_short_uid(uid: str) -> str: + """Fallback short-uid extractor for providers without a custom mapping.""" + return uid + + +def get_short_uid_extractor(provider_type: str) -> Callable[[str], str]: + """Get the short-uid extractor for a provider type. + + Returns an identity function when the provider is unknown, so callers can + rely on a callable always being returned. + """ + config = PROVIDER_CONFIGS.get(provider_type) + return config.short_uid_extractor if config else _identity_short_uid + + # Dynamic Isolation Label Helpers # -------------------------------- diff --git a/api/src/backend/tasks/jobs/attack_paths/findings.py b/api/src/backend/tasks/jobs/attack_paths/findings.py index 0b2ecb4c450..3581f0ca0f0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/findings.py +++ b/api/src/backend/tasks/jobs/attack_paths/findings.py @@ -8,7 +8,7 @@ """ from collections import defaultdict -from typing import Any, Generator +from typing import Any, Callable, Generator from uuid import UUID import neo4j @@ -21,6 +21,7 @@ get_node_uid_field, get_provider_resource_label, get_root_node_label, + get_short_uid_extractor, ) from tasks.jobs.attack_paths.queries import ( ADD_RESOURCE_LABEL_TEMPLATE, @@ -57,7 +58,9 @@ ] -def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]: +def _to_neo4j_dict( + record: dict[str, Any], resource_uid: str, resource_short_uid: str +) -> dict[str, Any]: """Transform a Django `.values()` record into a `dict` ready for Neo4j ingestion.""" return { "id": str(record["id"]), @@ -75,6 +78,7 @@ def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]: "muted": record["muted"], "muted_reason": record["muted_reason"], "resource_uid": resource_uid, + "resource_short_uid": resource_short_uid, } @@ -170,6 +174,8 @@ def load_findings( batch_num = 0 total_records = 0 + edges_merged = 0 + edges_dropped = 0 for batch in findings_batches: batch_num += 1 batch_size = len(batch) @@ -178,9 +184,15 @@ def load_findings( parameters["findings_data"] = batch logger.info(f"Loading findings batch {batch_num} ({batch_size} records)") - neo4j_session.run(query, parameters) + summary = neo4j_session.run(query, parameters).single() + if summary is not None: + edges_merged += summary.get("merged_count", 0) + edges_dropped += summary.get("dropped_count", 0) - logger.info(f"Finished loading {total_records} records in {batch_num} batches") + logger.info( + f"Finished loading {total_records} records in {batch_num} batches " + f"(edges_merged={edges_merged}, edges_dropped={edges_dropped})" + ) return total_records @@ -205,8 +217,9 @@ def stream_findings_with_resources( ) tenant_id = prowler_api_provider.tenant_id + short_uid_extractor = get_short_uid_extractor(prowler_api_provider.provider) for batch in _paginate_findings(tenant_id, scan_id): - enriched = _enrich_batch_with_resources(batch, tenant_id) + enriched = _enrich_batch_with_resources(batch, tenant_id, short_uid_extractor) if enriched: yield enriched @@ -269,6 +282,7 @@ def _fetch_findings_batch( def _enrich_batch_with_resources( findings_batch: list[dict[str, Any]], tenant_id: str, + short_uid_extractor: Callable[[str], str], ) -> list[dict[str, Any]]: """ Enrich findings with their resource UIDs. @@ -280,7 +294,7 @@ def _enrich_batch_with_resources( resource_map = _build_finding_resource_map(finding_ids, tenant_id) return [ - _to_neo4j_dict(finding, resource_uid) + _to_neo4j_dict(finding, resource_uid, short_uid_extractor(resource_uid)) for finding in findings_batch for resource_uid in resource_map.get(finding["id"], []) ] diff --git a/api/src/backend/tasks/jobs/attack_paths/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py index 26ffa32f92b..eb1d82a96ee 100644 --- a/api/src/backend/tasks/jobs/attack_paths/queries.py +++ b/api/src/backend/tasks/jobs/attack_paths/queries.py @@ -35,46 +35,56 @@ def render_cypher_template(template: str, replacements: dict[str, str]) -> str: UNWIND $findings_data AS finding_data OPTIONAL MATCH (resource_by_uid:__RESOURCE_LABEL__ {{__NODE_UID_FIELD__: finding_data.resource_uid}}) - WITH finding_data, resource_by_uid - OPTIONAL MATCH (resource_by_id:__RESOURCE_LABEL__ {{id: finding_data.resource_uid}}) WHERE resource_by_uid IS NULL - WITH finding_data, COALESCE(resource_by_uid, resource_by_id) AS resource - WHERE resource IS NOT NULL - - MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}}) - ON CREATE SET - finding.id = finding_data.id, - finding.uid = finding_data.uid, - finding.inserted_at = finding_data.inserted_at, - finding.updated_at = finding_data.updated_at, - finding.first_seen_at = finding_data.first_seen_at, - finding.scan_id = finding_data.scan_id, - finding.delta = finding_data.delta, - finding.status = finding_data.status, - finding.status_extended = finding_data.status_extended, - finding.severity = finding_data.severity, - finding.check_id = finding_data.check_id, - finding.check_title = finding_data.check_title, - finding.muted = finding_data.muted, - finding.muted_reason = finding_data.muted_reason, - finding.firstseen = timestamp(), - finding.lastupdated = $last_updated, - finding._module_name = 'cartography:prowler', - finding._module_version = $prowler_version - ON MATCH SET - finding.status = finding_data.status, - finding.status_extended = finding_data.status_extended, - finding.lastupdated = $last_updated - - MERGE (resource)-[rel:HAS_FINDING]->(finding) - ON CREATE SET - rel.firstseen = timestamp(), - rel.lastupdated = $last_updated, - rel._module_name = 'cartography:prowler', - rel._module_version = $prowler_version - ON MATCH SET - rel.lastupdated = $last_updated + OPTIONAL MATCH (resource_by_short:__RESOURCE_LABEL__ {{id: finding_data.resource_short_uid}}) + WHERE resource_by_uid IS NULL AND resource_by_id IS NULL + WITH finding_data, + resource_by_uid, + resource_by_id, + head(collect(resource_by_short)) AS resource_by_short + WITH finding_data, + COALESCE(resource_by_uid, resource_by_id, resource_by_short) AS resource + + FOREACH (_ IN CASE WHEN resource IS NOT NULL THEN [1] ELSE [] END | + MERGE (finding:{PROWLER_FINDING_LABEL} {{id: finding_data.id}}) + ON CREATE SET + finding.id = finding_data.id, + finding.uid = finding_data.uid, + finding.inserted_at = finding_data.inserted_at, + finding.updated_at = finding_data.updated_at, + finding.first_seen_at = finding_data.first_seen_at, + finding.scan_id = finding_data.scan_id, + finding.delta = finding_data.delta, + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.severity = finding_data.severity, + finding.check_id = finding_data.check_id, + finding.check_title = finding_data.check_title, + finding.muted = finding_data.muted, + finding.muted_reason = finding_data.muted_reason, + finding.firstseen = timestamp(), + finding.lastupdated = $last_updated, + finding._module_name = 'cartography:prowler', + finding._module_version = $prowler_version + ON MATCH SET + finding.status = finding_data.status, + finding.status_extended = finding_data.status_extended, + finding.lastupdated = $last_updated + MERGE (resource)-[rel:HAS_FINDING]->(finding) + ON CREATE SET + rel.firstseen = timestamp(), + rel.lastupdated = $last_updated, + rel._module_name = 'cartography:prowler', + rel._module_version = $prowler_version + ON MATCH SET + rel.lastupdated = $last_updated + ) + + WITH sum(CASE WHEN resource IS NOT NULL THEN 1 ELSE 0 END) AS merged_count, + sum(CASE WHEN resource IS NULL THEN 1 ELSE 0 END) AS dropped_count + + RETURN merged_count, dropped_count """ # Internet queries (used by internet.py) diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index 283c0650e1d..986a2f5b2c5 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -1285,6 +1285,12 @@ def findings_generator(): config = SimpleNamespace(update_tag=12345) mock_session = MagicMock() + first_result = MagicMock() + first_result.single.return_value = {"merged_count": 1, "dropped_count": 0} + second_result = MagicMock() + second_result.single.return_value = {"merged_count": 0, "dropped_count": 1} + mock_session.run.side_effect = [first_result, second_result] + with ( patch( "tasks.jobs.attack_paths.findings.get_node_uid_field", @@ -1294,6 +1300,7 @@ def findings_generator(): "tasks.jobs.attack_paths.findings.get_provider_resource_label", return_value="_AWSResource", ), + patch("tasks.jobs.attack_paths.findings.logger") as mock_logger, ): findings_module.load_findings( mock_session, findings_generator(), provider, config @@ -1305,6 +1312,14 @@ def findings_generator(): assert params["last_updated"] == config.update_tag assert "findings_data" in params + summary_log = next( + call_args.args[0] + for call_args in mock_logger.info.call_args_list + if call_args.args and "Finished loading" in call_args.args[0] + ) + assert "edges_merged=1" in summary_log + assert "edges_dropped=1" in summary_log + def test_stream_findings_with_resources_returns_latest_scan_data( self, tenants_fixture, @@ -1484,11 +1499,12 @@ def test_enrich_batch_with_resources_single_resource( "default", ): result = findings_module._enrich_batch_with_resources( - [finding_dict], str(tenant.id) + [finding_dict], str(tenant.id), lambda uid: f"short:{uid}" ) assert len(result) == 1 assert result[0]["resource_uid"] == resource.uid + assert result[0]["resource_short_uid"] == f"short:{resource.uid}" assert result[0]["id"] == str(finding.id) assert result[0]["status"] == "FAIL" @@ -1572,7 +1588,7 @@ def test_enrich_batch_with_resources_multiple_resources( "default", ): result = findings_module._enrich_batch_with_resources( - [finding_dict], str(tenant.id) + [finding_dict], str(tenant.id), lambda uid: uid ) assert len(result) == 3 @@ -1646,7 +1662,7 @@ def test_enrich_batch_with_resources_no_resources_skips( patch("tasks.jobs.attack_paths.findings.logger") as mock_logger, ): result = findings_module._enrich_batch_with_resources( - [finding_dict], str(tenant.id) + [finding_dict], str(tenant.id), lambda uid: uid ) assert len(result) == 0 @@ -1693,6 +1709,63 @@ def empty_gen(): mock_session.run.assert_not_called() + @pytest.mark.parametrize( + "uid, expected", + [ + ( + "arn:aws:ec2:us-east-1:552455647653:instance/i-05075b63eb51baacb", + "i-05075b63eb51baacb", + ), + ( + "arn:aws:ec2:us-east-1:123456789012:volume/vol-0abcd1234ef567890", + "vol-0abcd1234ef567890", + ), + ( + "arn:aws:ec2:us-east-1:123456789012:security-group/sg-0123abcd", + "sg-0123abcd", + ), + ("arn:aws:s3:::my-bucket-name", "my-bucket-name"), + ("arn:aws:iam::123456789012:role/MyRole", "MyRole"), + ( + "arn:aws:lambda:us-east-1:123456789012:function:my-function", + "my-function", + ), + ("i-05075b63eb51baacb", "i-05075b63eb51baacb"), + ], + ) + def test_extract_short_uid_aws_variants(self, uid, expected): + from tasks.jobs.attack_paths.aws import extract_short_uid + + assert extract_short_uid(uid) == expected + + def test_insert_finding_template_has_short_id_fallback(self): + from tasks.jobs.attack_paths.queries import ( + INSERT_FINDING_TEMPLATE, + render_cypher_template, + ) + + rendered = render_cypher_template( + INSERT_FINDING_TEMPLATE, + { + "__NODE_UID_FIELD__": "arn", + "__RESOURCE_LABEL__": "_AWSResource", + }, + ) + + assert ( + "resource_by_uid:_AWSResource {arn: finding_data.resource_uid}" in rendered + ) + assert "resource_by_id:_AWSResource {id: finding_data.resource_uid}" in rendered + assert ( + "resource_by_short:_AWSResource {id: finding_data.resource_short_uid}" + in rendered + ) + assert "head(collect(resource_by_short)) AS resource_by_short" in rendered + assert ( + "COALESCE(resource_by_uid, resource_by_id, resource_by_short)" in rendered + ) + assert "RETURN merged_count, dropped_count" in rendered + class TestAddResourceLabel: def test_add_resource_label_applies_private_label(self): From 94ee24071afbfaf13f653e4365f7db057274b3e0 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Wed, 22 Apr 2026 13:11:50 +0200 Subject: [PATCH 03/28] refactor: unify filtering and sorting for finding (#10803) Co-authored-by: alejandrobailo --- .../finding-groups/finding-groups.test.ts | 22 +++ ui/actions/finding-groups/finding-groups.ts | 74 ++++---- .../findings/findings-by-resource.test.ts | 6 + ui/actions/findings/findings-by-resource.ts | 8 +- ui/actions/resources/resources.test.ts | 26 +++ ui/actions/resources/resources.ts | 4 +- .../_components/attack-surface-card-item.tsx | 4 +- .../findings-view/findings-view.ssr.tsx | 3 +- .../risk-plot/risk-plot-client.tsx | 7 +- .../risk-radar-view-client.tsx | 7 +- .../finding-severity-over-time.tsx | 8 +- ui/app/(prowler)/findings/page.test.ts | 4 + ui/app/(prowler)/findings/page.tsx | 25 ++- .../client-accordion-content.tsx | 24 ++- .../custom-checkbox-muted-findings.tsx | 15 +- .../table/inline-resource-container.utils.ts | 50 ++---- ui/components/graphs/sankey-chart.tsx | 7 +- .../link-to-findings/link-to-findings.tsx | 10 +- .../use-finding-group-resource-state.test.ts | 4 + ui/lib/findings-filters.test.ts | 124 ++++++++++--- ui/lib/findings-filters.ts | 117 ++++++++++-- ui/lib/findings-sort.test.ts | 169 ++++++++++++++++++ ui/lib/findings-sort.ts | 130 ++++++++++++++ ui/lib/index.ts | 1 + 24 files changed, 684 insertions(+), 165 deletions(-) create mode 100644 ui/lib/findings-sort.test.ts create mode 100644 ui/lib/findings-sort.ts diff --git a/ui/actions/finding-groups/finding-groups.test.ts b/ui/actions/finding-groups/finding-groups.test.ts index 709469b4164..00c18e128e0 100644 --- a/ui/actions/finding-groups/finding-groups.test.ts +++ b/ui/actions/finding-groups/finding-groups.test.ts @@ -12,9 +12,31 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( }), ); +// Real helpers/constants pulled from submodules that don't import server-only +// code, so the mock factory stays free of top-level variable hoisting issues +// and the vitest runtime doesn't choke on next-auth's `next/server` import. +import { + includesMutedFindings, + splitCsvFilterValues, +} from "@/lib/findings-filters"; +import { + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, +} from "@/lib/findings-sort"; + vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + includesMutedFindings, + splitCsvFilterValues, })); vi.mock("@/lib/provider-filters", () => ({ diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index 30798ea3e64..bf5df80aae9 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -2,7 +2,17 @@ import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { + apiBaseUrl, + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + getAuthHeaders, + includesMutedFindings, + splitCsvFilterValues, +} from "@/lib"; import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { FilterParam } from "@/types/filters"; @@ -24,24 +34,6 @@ function mapSearchFilter( return mapped; } -function splitCsvFilterValues(value: string | string[] | undefined): string[] { - if (Array.isArray(value)) { - return value - .flatMap((item) => item.split(",")) - .map((item) => item.trim()) - .filter(Boolean); - } - - if (typeof value === "string") { - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - } - - return []; -} - /** * Filters that belong to finding-groups but are NOT valid for the * finding-group resources sub-endpoint. These must be stripped before @@ -82,14 +74,34 @@ function normalizeFindingGroupResourceFilters( return normalized; } -const DEFAULT_FINDING_GROUPS_SORT = - "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at"; - -const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED = - "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at"; +// Composite sorts for finding-groups (Family B in lib/findings-sort.ts). +// The `-status,-severity,...,-last_seen_at` shape is required by the API: +// these endpoints map status/severity to weighted integer columns where +// DESC = FAIL/critical first. The intermediate `*_count` tokens are +// finding-group-specific impact tiebreakers and have no Family A analogue. +const DEFAULT_FINDING_GROUPS_SORT = composeSort( + FG_FAIL_FIRST, + FG_SEVERITY_HIGH_FIRST, + "-new_fail_count", + "-changed_fail_count", + "-fail_count", + FG_RECENT_LAST_SEEN, +); + +const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED = composeSort( + FG_FAIL_FIRST, + FG_SEVERITY_HIGH_FIRST, + "-new_fail_count", + "-changed_fail_count", + "-new_fail_muted_count", + "-changed_fail_muted_count", + "-fail_count", + "-fail_muted_count", + FG_RECENT_LAST_SEEN, +); const DEFAULT_FINDING_GROUP_RESOURCES_SORT = - "-status,-severity,-delta,-last_seen_at"; + FINDING_GROUP_RESOURCES_DEFAULT_SORT; interface FetchFindingGroupsParams { page?: number; @@ -98,18 +110,6 @@ interface FetchFindingGroupsParams { filters?: Record; } -function includesMutedFindings( - filters: Record, -): boolean { - const mutedFilter = filters["filter[muted]"]; - - if (Array.isArray(mutedFilter)) { - return mutedFilter.includes("include"); - } - - return mutedFilter === "include"; -} - function getDefaultFindingGroupsSort( filters: Record, ): string { diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts index 7bc2793195b..83406a518fa 100644 --- a/ui/actions/findings/findings-by-resource.test.ts +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -24,9 +24,15 @@ const { getLatestFindingGroupResourcesMock: vi.fn(), })); +// Import the real sort constant directly from its submodule. Going via the +// `@/lib` barrel would pull in server-only code (next-auth) that does not +// resolve in the vitest runtime. +import { RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib/findings-sort"; + vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, })); vi.mock("@/lib/provider-filters", () => ({ diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 74a0bcb6deb..3d1a6859a08 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -4,7 +4,11 @@ import { getFindingGroupResources, getLatestFindingGroupResources, } from "@/actions/finding-groups"; -import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { + apiBaseUrl, + getAuthHeaders, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, +} from "@/lib"; import { runWithConcurrencyLimit } from "@/lib/concurrency"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; @@ -266,7 +270,7 @@ export const getLatestFindingsByResourceUid = async ({ url.searchParams.append("filter[resource_uid]", resourceUid); url.searchParams.append("filter[status]", "FAIL"); url.searchParams.append("filter[muted]", includeMuted ? "include" : "false"); - url.searchParams.append("sort", "severity,-updated_at"); + url.searchParams.append("sort", RESOURCE_DRAWER_OTHER_FINDINGS_SORT); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index b3fc4b257bc..cbdb0b12960 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -8,9 +8,35 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( }), ); +// Pull every constant transitively required by the modules under test +// (resources.ts → findings action → finding-groups action) so the `@/lib` +// mock is a complete surface. Going via the barrel would drag in next-auth. +import { + includesMutedFindings, + splitCsvFilterValues, +} from "@/lib/findings-filters"; +import { + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + FINDINGS_FILTERED_SORT, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, +} from "@/lib/findings-sort"; + vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + composeSort, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + FINDINGS_FILTERED_SORT, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, + includesMutedFindings, + splitCsvFilterValues, })); vi.mock("@/lib/server-actions-helper", () => ({ diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index b0831bdb26f..8af2576852f 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { getLatestFindings } from "@/actions/findings"; import { listOrganizationsSafe } from "@/actions/organizations/organizations"; -import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { OrganizationResource } from "@/types/organizations"; @@ -285,7 +285,7 @@ export const getResourceDrawerData = async ({ page, pageSize, query, - sort: "severity,-inserted_at", + sort: FINDINGS_FILTERED_SORT, filters: { "filter[resource_uid]": resourceUid, "filter[status]": "FAIL", diff --git a/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx b/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx index 46f2b0dd8ee..ef92b7b4b3b 100644 --- a/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx +++ b/ui/app/(prowler)/_overview/attack-surface/_components/attack-surface-card-item.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { AttackSurfaceItem } from "@/actions/overview"; import { Card, CardContent } from "@/components/shadcn"; +import { applyFailNonMutedFilters } from "@/lib"; interface AttackSurfaceCardItemProps { item: AttackSurfaceItem; @@ -18,8 +19,7 @@ export function AttackSurfaceCardItem({ // Add attack surface category filter params.set("filter[category__in]", item.id); - params.set("filter[status__in]", "FAIL"); - params.set("filter[muted]", "false"); + applyFailNonMutedFilters(params); // Add current page filters (provider, account, etc.) Object.entries(filters).forEach(([key, value]) => { diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index cca971578cb..0396795cb95 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -6,6 +6,7 @@ import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; import { CardTitle } from "@/components/shadcn"; import { DataTable } from "@/components/ui/table"; +import { FINDINGS_FILTERED_SORT } from "@/lib"; import { createDict } from "@/lib/helper"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -17,7 +18,7 @@ interface FindingsViewSSRProps { export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { const page = 1; - const sort = "severity,-inserted_at"; + const sort = FINDINGS_FILTERED_SORT; const defaultFilters = { "filter[status]": "FAIL", diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx index 84405fe0f14..f1db021599f 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot-client.tsx @@ -8,6 +8,7 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart"; import { ScatterPlot } from "@/components/graphs/scatter-plot"; import { AlertPill } from "@/components/graphs/shared/alert-pill"; import type { BarDataPoint } from "@/components/graphs/types"; +import { applyFailNonMutedFilters } from "@/lib"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; // Score color thresholds (0-100 scale, higher = better) @@ -50,11 +51,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) { // Add provider filter for the selected point params.set("filter[provider_id__in]", selectedPoint.providerId); - // Add exclude muted findings filter - params.set("filter[muted]", "false"); - - // Filter by FAIL findings - params.set("filter[status__in]", "FAIL"); + applyFailNonMutedFilters(params); // Navigate to findings page router.push(`/findings?${params.toString()}`); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx index 5cd079ed2f2..41a09b48092 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view-client.tsx @@ -7,6 +7,7 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart"; import { RadarChart } from "@/components/graphs/radar-chart"; import type { BarDataPoint, RadarDataPoint } from "@/components/graphs/types"; import { Card } from "@/components/shadcn/card/card"; +import { applyFailNonMutedFilters } from "@/lib"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; import { CategorySelector } from "./category-selector"; @@ -50,11 +51,7 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) { // Add category filter for the selected point params.set("filter[category__in]", selectedPoint.categoryId); - // Add exclude muted findings filter - params.set("filter[muted]", "false"); - - // Filter by FAIL findings - params.set("filter[status__in]", "FAIL"); + applyFailNonMutedFilters(params); // Navigate to findings page router.push(`/findings?${params.toString()}`); diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx index fa127244bdd..9e0802d4ff6 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends"; import { LineChart } from "@/components/graphs/line-chart"; import { LineConfig, LineDataPoint } from "@/components/graphs/types"; +import { applyFailNonMutedFilters } from "@/lib"; import { SEVERITY_LEVELS, SEVERITY_LINE_CONFIGS, @@ -57,11 +58,8 @@ export const FindingSeverityOverTime = ({ }) => { const params = new URLSearchParams(); - // Always filter by FAIL status since this chart shows failed findings - params.set("filter[status__in]", "FAIL"); - - // Exclude muted findings - params.set("filter[muted]", "false"); + // Show active failing findings only for this chart's drill-down. + applyFailNonMutedFilters(params); // Add scan_ids filter if ( diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 2038b922911..419bcc80fb9 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -33,6 +33,10 @@ describe("findings page", () => { expect(source).toContain("getLatestFindingGroups"); }); + it("defaults filter[muted]=false through the shared muted filter helper", () => { + expect(source).toContain("applyDefaultMutedFilter(filtersWithScanDates)"); + }); + it("guards errors array access with a length check", () => { expect(source).toContain("errors?.length > 0"); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index dea6e9b4fbe..e22be8cb43e 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -40,25 +40,22 @@ export default async function Findings({ getScans({ pageSize: 50 }), ]); - const filtersWithScanDates = applyDefaultMutedFilter( - await resolveFindingScanDateFilters({ - filters, - scans: scansData?.data || [], - loadScan: async (scanId: string) => { - const response = await getScan(scanId); - return response?.data; - }, - }), - ); - + const filtersWithScanDates = await resolveFindingScanDateFilters({ + filters, + scans: scansData?.data || [], + loadScan: async (scanId: string) => { + const response = await getScan(scanId); + return response?.data; + }, + }); + const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates); const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates); - const metadataInfoData = await ( hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo )({ query, sort: encodedSort, - filters: filtersWithScanDates, + filters: resolvedFilters, }); // Extract unique regions, services, categories, groups from the new endpoint @@ -102,7 +99,7 @@ export default async function Findings({ }> diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 659d3a83fa0..5239ba9dd55 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -10,7 +10,7 @@ import { } from "@/components/findings/table"; import { Accordion } from "@/components/ui/accordion/Accordion"; import { DataTable } from "@/components/ui/table"; -import { createDict } from "@/lib"; +import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib"; import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { Requirement } from "@/types/compliance"; import { FindingProps, FindingsResponse } from "@/types/components"; @@ -34,12 +34,16 @@ export const ClientAccordionContent = ({ const pageNumber = searchParams.get("page") || "1"; const complianceId = searchParams.get("complianceId"); const openFindingId = searchParams.get("id"); - const defaultSort = "severity,status,-inserted_at"; - const sort = searchParams.get("sort") || defaultSort; + const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT; const loadedPageRef = useRef(null); const loadedSortRef = useRef(null); + const loadedMutedRef = useRef(null); const isExpandedRef = useRef(false); const region = searchParams.get("filter[region__in]") || ""; + // Respect the user's muted preference from the URL; default to EXCLUDE + // so the requirement view stays consistent with every other findings + // surface in the app (findings page, resource drawer, overview widgets). + const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE; useEffect(() => { async function loadFindings() { @@ -49,10 +53,12 @@ export const ClientAccordionContent = ({ requirement.status !== "No findings" && (loadedPageRef.current !== pageNumber || loadedSortRef.current !== sort || + loadedMutedRef.current !== mutedFilter || !isExpandedRef.current) ) { loadedPageRef.current = pageNumber; loadedSortRef.current = sort; + loadedMutedRef.current = mutedFilter; isExpandedRef.current = true; try { @@ -62,7 +68,7 @@ export const ClientAccordionContent = ({ filters: { "filter[check_id__in]": checkIds.join(","), "filter[scan]": scanId, - "filter[muted]": "false", + "filter[muted]": mutedFilter, ...(region && { "filter[region__in]": region }), }, page: parseInt(pageNumber, 10), @@ -101,7 +107,15 @@ export const ClientAccordionContent = ({ } loadFindings(); - }, [requirement, scanId, pageNumber, sort, region, disableFindings]); + }, [ + requirement, + scanId, + pageNumber, + sort, + region, + mutedFilter, + disableFindings, + ]); const renderDetails = () => { if (!complianceId) { diff --git a/ui/components/filters/custom-checkbox-muted-findings.tsx b/ui/components/filters/custom-checkbox-muted-findings.tsx index 779b060b463..47c267a8e7a 100644 --- a/ui/components/filters/custom-checkbox-muted-findings.tsx +++ b/ui/components/filters/custom-checkbox-muted-findings.tsx @@ -4,12 +4,7 @@ import { useSearchParams } from "next/navigation"; import { Checkbox } from "@/components/shadcn"; import { useUrlFilters } from "@/hooks/use-url-filters"; - -// Constants for muted filter URL values -const MUTED_FILTER_VALUES = { - EXCLUDE: "false", - INCLUDE: "include", -} as const; +import { MUTED_FILTER } from "@/lib"; /** Batch mode: caller controls both the checked state and the notification callback (all-or-nothing). */ interface CustomCheckboxMutedFindingsBatchProps { @@ -53,7 +48,7 @@ export const CustomCheckboxMutedFindings = ({ const includeMuted = checkedProp !== undefined ? checkedProp - : mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE; + : mutedFilterValue === MUTED_FILTER.INCLUDE; const handleMutedChange = (checked: boolean | "indeterminate") => { const isChecked = checked === true; @@ -62,7 +57,7 @@ export const CustomCheckboxMutedFindings = ({ // Batch mode: notify caller instead of navigating onBatchChange( "muted", - isChecked ? MUTED_FILTER_VALUES.INCLUDE : MUTED_FILTER_VALUES.EXCLUDE, + isChecked ? MUTED_FILTER.INCLUDE : MUTED_FILTER.EXCLUDE, ); return; } @@ -71,10 +66,10 @@ export const CustomCheckboxMutedFindings = ({ navigateWithParams((params) => { if (isChecked) { // Include muted: set special value (API will ignore invalid value and show all) - params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE); + params.set("filter[muted]", MUTED_FILTER.INCLUDE); } else { // Exclude muted: apply filter to show only non-muted - params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE); + params.set("filter[muted]", MUTED_FILTER.EXCLUDE); } }); }; diff --git a/ui/components/findings/table/inline-resource-container.utils.ts b/ui/components/findings/table/inline-resource-container.utils.ts index 274304e03f7..c1c5f816d51 100644 --- a/ui/components/findings/table/inline-resource-container.utils.ts +++ b/ui/components/findings/table/inline-resource-container.utils.ts @@ -1,48 +1,26 @@ +import { + FAIL_FILTER_VALUE, + includesMutedFindings, + splitCsvFilterValues, +} from "@/lib/findings-filters"; import { FindingGroupRow } from "@/types"; -function parseStatusFilterValue(statusFilterValue?: string): string[] { - if (!statusFilterValue) { - return []; - } - - return statusFilterValue - .split(",") - .map((status) => status.trim().toUpperCase()) - .filter(Boolean); -} - export function isFailOnlyStatusFilter( filters: Record, ): boolean { - const directStatusValues = parseStatusFilterValue( - typeof filters["filter[status]"] === "string" - ? filters["filter[status]"] - : undefined, + // Normalise both `filter[status]` and `filter[status__in]` CSV forms + // and uppercase so "fail", "Fail" etc. still match the wire value. + const direct = splitCsvFilterValues(filters["filter[status]"]).map((s) => + s.toUpperCase(), ); - - if (directStatusValues.length > 0) { - return directStatusValues.length === 1 && directStatusValues[0] === "FAIL"; + if (direct.length > 0) { + return direct.length === 1 && direct[0] === FAIL_FILTER_VALUE; } - const multiStatusValues = parseStatusFilterValue( - typeof filters["filter[status__in]"] === "string" - ? filters["filter[status__in]"] - : undefined, + const multi = splitCsvFilterValues(filters["filter[status__in]"]).map((s) => + s.toUpperCase(), ); - - return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL"; -} - -function includesMutedFindings( - filters: Record, -): boolean { - const mutedFilter = filters["filter[muted]"]; - - if (Array.isArray(mutedFilter)) { - return mutedFilter.includes("include"); - } - - return mutedFilter === "include"; + return multi.length === 1 && multi[0] === FAIL_FILTER_VALUE; } export function getFilteredFindingGroupResourceCount( diff --git a/ui/components/graphs/sankey-chart.tsx b/ui/components/graphs/sankey-chart.tsx index fc7a9134444..0a41d0e4e7e 100644 --- a/ui/components/graphs/sankey-chart.tsx +++ b/ui/components/graphs/sankey-chart.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts"; import { PROVIDER_BADGE_BY_NAME } from "@/components/icons/providers-badge"; +import { applyFailNonMutedFilters } from "@/lib"; import { initializeChartColors } from "@/lib/charts/colors"; import { PROVIDER_DISPLAY_NAMES } from "@/types/providers"; import { SEVERITY_FILTER_MAP } from "@/types/severities"; @@ -463,8 +464,7 @@ export function SankeyChart({ if (severityFilter) { const params = new URLSearchParams(searchParams.toString()); params.set("filter[severity__in]", severityFilter); - params.set("filter[status__in]", "FAIL"); - params.set("filter[muted]", "false"); + applyFailNonMutedFilters(params); router.push(`/findings?${params.toString()}`); } }; @@ -484,8 +484,7 @@ export function SankeyChart({ } params.set("filter[severity__in]", severityFilter); - params.set("filter[status__in]", "FAIL"); - params.set("filter[muted]", "false"); + applyFailNonMutedFilters(params); router.push(`/findings?${params.toString()}`); } }; diff --git a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx index 41b9f69e752..e54b4aa2a0d 100644 --- a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx +++ b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx @@ -1,9 +1,17 @@ import Link from "next/link"; +import { + FAIL_FILTER_VALUE, + NEW_DELTA_FILTER_VALUE, +} from "@/lib/findings-filters"; +import { FINDING_GROUPS_FILTERED_SORT } from "@/lib/findings-sort"; + +const FINDINGS_LINK_HREF = `/findings?sort=${FINDING_GROUPS_FILTERED_SORT}&filter[status__in]=${FAIL_FILTER_VALUE}&filter[delta]=${NEW_DELTA_FILTER_VALUE}`; + export const LinkToFindings = () => { return ( diff --git a/ui/hooks/use-finding-group-resource-state.test.ts b/ui/hooks/use-finding-group-resource-state.test.ts index c0ed5a4abe1..48fc5199972 100644 --- a/ui/hooks/use-finding-group-resource-state.test.ts +++ b/ui/hooks/use-finding-group-resource-state.test.ts @@ -9,6 +9,10 @@ describe("useFindingGroupResourceState", () => { const filePath = path.join(currentDir, "use-finding-group-resource-state.ts"); const source = readFileSync(filePath, "utf8"); + it("defaults drill-down resource loading through the shared muted filter helper", () => { + expect(source).toContain("applyDefaultMutedFilter(filters)"); + }); + it("enables muted findings only for the finding-group resource drawer", () => { expect(source).toContain("includeMutedInOtherFindings: true"); }); diff --git a/ui/lib/findings-filters.test.ts b/ui/lib/findings-filters.test.ts index 3f1143b4d75..e0e905c493e 100644 --- a/ui/lib/findings-filters.test.ts +++ b/ui/lib/findings-filters.test.ts @@ -1,42 +1,120 @@ import { describe, expect, it } from "vitest"; -import { applyDefaultMutedFilter, MUTED_FILTER } from "./findings-filters"; +import { + applyDefaultMutedFilter, + applyFailNonMutedFilters, + FAIL_FILTER_VALUE, + includesMutedFindings, + MUTED_FILTER, + NEW_DELTA_FILTER_VALUE, + splitCsvFilterValues, +} from "./findings-filters"; -describe("applyDefaultMutedFilter", () => { - it("injects filter[muted]=false when the caller has not set it", () => { - const input: Record = { "filter[status__in]": "FAIL" }; - const result = applyDefaultMutedFilter(input); +describe("filter value constants", () => { + it("exposes wire-format values exactly as the API expects", () => { + expect(FAIL_FILTER_VALUE).toBe("FAIL"); + expect(NEW_DELTA_FILTER_VALUE).toBe("new"); + expect(MUTED_FILTER.EXCLUDE).toBe("false"); + expect(MUTED_FILTER.INCLUDE).toBe("include"); + }); +}); + +describe("applyFailNonMutedFilters", () => { + it("sets filter[status__in]=FAIL and filter[muted]=false", () => { + const params = new URLSearchParams(); - expect(result["filter[muted]"]).toBe(MUTED_FILTER.EXCLUDE); - expect(result["filter[status__in]"]).toBe("FAIL"); + applyFailNonMutedFilters(params); + + expect(params.get("filter[status__in]")).toBe("FAIL"); + expect(params.get("filter[muted]")).toBe("false"); }); - it("preserves an explicit filter[muted]=include opt-in from the checkbox", () => { - const result = applyDefaultMutedFilter({ - "filter[muted]": MUTED_FILTER.INCLUDE, - }); + it("overrides pre-existing values so the drill-down is idempotent", () => { + const params = new URLSearchParams( + "filter[status__in]=PASS&filter[muted]=include", + ); + + applyFailNonMutedFilters(params); - expect(result["filter[muted]"]).toBe(MUTED_FILTER.INCLUDE); + expect(params.get("filter[status__in]")).toBe("FAIL"); + expect(params.get("filter[muted]")).toBe("false"); }); - it("preserves an explicit filter[muted]=false (no silent overwrite)", () => { - const result = applyDefaultMutedFilter({ - "filter[muted]": MUTED_FILTER.EXCLUDE, + it("preserves unrelated params", () => { + const params = new URLSearchParams( + "filter[provider_id__in]=abc&sort=-severity", + ); + + applyFailNonMutedFilters(params); + + expect(params.get("filter[provider_id__in]")).toBe("abc"); + expect(params.get("sort")).toBe("-severity"); + }); +}); + +describe("applyDefaultMutedFilter", () => { + it("adds filter[muted]=false when the filter is absent", () => { + expect(applyDefaultMutedFilter({ "filter[status__in]": "FAIL" })).toEqual({ + "filter[muted]": "false", + "filter[status__in]": "FAIL", }); + }); - expect(result["filter[muted]"]).toBe(MUTED_FILTER.EXCLUDE); + it("preserves an explicit include value from the caller", () => { + expect( + applyDefaultMutedFilter({ + "filter[muted]": "include", + "filter[status__in]": "FAIL", + }), + ).toEqual({ + "filter[muted]": "include", + "filter[status__in]": "FAIL", + }); }); +}); - it("does not mutate the input object", () => { - const input = { "filter[status__in]": "FAIL" }; - applyDefaultMutedFilter(input); +describe("splitCsvFilterValues", () => { + it("returns an empty array when the value is undefined", () => { + expect(splitCsvFilterValues(undefined)).toEqual([]); + }); - expect(input).not.toHaveProperty("filter[muted]"); + it("splits a CSV string and trims whitespace", () => { + expect(splitCsvFilterValues("FAIL, PASS ,MANUAL")).toEqual([ + "FAIL", + "PASS", + "MANUAL", + ]); }); - it("returns a default-filled object when called with no caller filters", () => { - const result = applyDefaultMutedFilter({} as Record); + it("flattens repeated array values (Next.js can surface them this way)", () => { + expect(splitCsvFilterValues(["FAIL", "PASS,MANUAL"])).toEqual([ + "FAIL", + "PASS", + "MANUAL", + ]); + }); + + it("drops empty tokens produced by stray commas", () => { + expect(splitCsvFilterValues("FAIL,,PASS,")).toEqual(["FAIL", "PASS"]); + }); +}); + +describe("includesMutedFindings", () => { + it("returns false when filter[muted] is absent", () => { + expect(includesMutedFindings({})).toBe(false); + }); + + it("returns true for the literal 'include' sentinel", () => { + expect(includesMutedFindings({ "filter[muted]": "include" })).toBe(true); + }); + + it("returns false for 'false' (the exclude value)", () => { + expect(includesMutedFindings({ "filter[muted]": "false" })).toBe(false); + }); - expect(result["filter[muted]"]).toBe(MUTED_FILTER.EXCLUDE); + it("returns true when 'include' appears anywhere in an array value", () => { + expect( + includesMutedFindings({ "filter[muted]": ["false", "include"] }), + ).toBe(true); }); }); diff --git a/ui/lib/findings-filters.ts b/ui/lib/findings-filters.ts index 9a2aae1d809..5222d86be61 100644 --- a/ui/lib/findings-filters.ts +++ b/ui/lib/findings-filters.ts @@ -1,30 +1,67 @@ /** - * Shared helpers for findings filter handling. + * Shared filter constants and helpers for findings-shaped endpoints. * - * The `/findings` SSR page and the finding-group resource drill-down both - * need to hide muted findings by default — unless the user has opted in via - * the "include muted findings" checkbox. Keeping that default in one place - * prevents surfaces from drifting. + * Pairs with `lib/findings-sort.ts` (sort tokens). This module covers the + * filter side of the same query language. */ +// --------------------------------------------------------------------------- +// Filter values +// --------------------------------------------------------------------------- + +/** + * The "FAIL" status value as it crosses the wire to the API. Used in both + * `filter[status]` (single) and `filter[status__in]` (CSV) form. + * + * NOTE: this is a bare value, not a full enum. The broader Status/Delta + * enum migration is intentionally out of scope here — see PR follow-up. + */ +export const FAIL_FILTER_VALUE = "FAIL"; + +/** + * The "new" delta value. Used in `filter[delta]` and `filter[delta__in]`. + */ +export const NEW_DELTA_FILTER_VALUE = "new"; + +/** + * Values accepted by `filter[muted]`. + * + * - `EXCLUDE` ("false"): the API hides muted findings (default UI behaviour). + * - `INCLUDE` ("include"): a sentinel that the API treats as "show all + * regardless of muted state". This is NOT the literal string "true" — the + * server route ignores invalid values which conveniently bypasses the + * filter. + */ export const MUTED_FILTER = { - /** Wire value sent to the API to exclude muted findings. */ EXCLUDE: "false", - /** - * Sentinel value that tells the API to return both muted and non-muted - * findings. The checkbox writes this to the URL when the user opts in. - */ INCLUDE: "include", } as const; export type MutedFilterValue = (typeof MUTED_FILTER)[keyof typeof MUTED_FILTER]; +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +/** + * Drill-down preset: "FAIL findings, hide muted". Mutates `params` in place. + * + * Repeated 6+ times across overview widgets that link to /findings + * (attack-surface card, sankey, severity-over-time, risk-radar, risk-plot, + * etc). Centralising avoids drift if product later adds, say, `delta=new` + * to all drill-downs. + */ +export function applyFailNonMutedFilters(params: URLSearchParams): void { + params.set("filter[status__in]", FAIL_FILTER_VALUE); + params.set("filter[muted]", MUTED_FILTER.EXCLUDE); +} + /** - * Returns a new filter object with the default muted behaviour applied: + * Returns a new filter object with the default findings behaviour applied: * hide muted findings unless the caller already set `filter[muted]`. * - * The default is spread BEFORE the caller filters so any explicit value - * (including `"false"` or the `"include"` opt-in) wins. + * Used by both the grouped findings SSR path and the resource drill-down so + * they stay aligned with the checkbox default on `/findings`. */ export function applyDefaultMutedFilter< T extends Record, @@ -34,3 +71,57 @@ export function applyDefaultMutedFilter< ...filters, }; } + +// --------------------------------------------------------------------------- +// Filter parsing +// --------------------------------------------------------------------------- + +/** + * Splits a JSON:API CSV filter value into clean string tokens. + * + * Accepts both string and string[] inputs because Next.js `searchParams` + * surface either form depending on whether the key appears once or multiple + * times in the URL. Returns trimmed, non-empty tokens in input order. + * + * Previously duplicated in three call sites + * (actions/finding-groups, components/findings/table/inline-resource-container, + * implicitly inside lib/findings-groups). Single source now. + */ +export function splitCsvFilterValues( + value: string | string[] | undefined, +): string[] { + if (Array.isArray(value)) { + return value + .flatMap((item) => item.split(",")) + .map((item) => item.trim()) + .filter(Boolean); + } + + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } + + return []; +} + +/** + * True when the caller has opted into seeing muted findings via either the + * `filter[muted]=include` shorthand or a multi-value variant. + * + * Previously duplicated in actions/finding-groups and + * components/findings/table/inline-resource-container. + */ +export function includesMutedFindings( + filters: Record, +): boolean { + const mutedFilter = filters["filter[muted]"]; + + if (Array.isArray(mutedFilter)) { + return mutedFilter.includes(MUTED_FILTER.INCLUDE); + } + + return mutedFilter === MUTED_FILTER.INCLUDE; +} diff --git a/ui/lib/findings-sort.test.ts b/ui/lib/findings-sort.test.ts new file mode 100644 index 00000000000..22d08072755 --- /dev/null +++ b/ui/lib/findings-sort.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; + +import { + composeSort, + FG_DELTA_NEW_FIRST, + FG_FAIL_FIRST, + FG_RECENT_LAST_SEEN, + FG_SEVERITY_HIGH_FIRST, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + FINDING_GROUPS_DEFAULT_SORT, + FINDING_GROUPS_FILTERED_SORT, + FINDINGS_DEFAULT_SORT, + FINDINGS_FAIL_FIRST, + FINDINGS_FILTERED_SORT, + FINDINGS_RECENT_INSERT, + FINDINGS_RECENT_UPDATE, + FINDINGS_SEVERITY_HIGH_FIRST, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, +} from "./findings-sort"; + +// --------------------------------------------------------------------------- +// Family A — plain findings (Postgres ENUM, ASC = critical/FAIL first) +// --------------------------------------------------------------------------- + +describe("plain findings tokens (Family A)", () => { + it("uses bare keys so ASC = declaration order = FAIL/critical first", () => { + // Postgres ENUM contract for the Finding model: + // severity declared as: critical, high, medium, low, informational + // status declared as: FAIL, PASS, MANUAL + expect(FINDINGS_FAIL_FIRST).toBe("status"); + expect(FINDINGS_SEVERITY_HIGH_FIRST).toBe("severity"); + }); + + it("never prefixes status or severity with a minus", () => { + expect(FINDINGS_FAIL_FIRST.startsWith("-")).toBe(false); + expect(FINDINGS_SEVERITY_HIGH_FIRST.startsWith("-")).toBe(false); + }); + + it("flips inserted_at and updated_at to DESC for recency", () => { + expect(FINDINGS_RECENT_INSERT).toBe("-inserted_at"); + expect(FINDINGS_RECENT_UPDATE).toBe("-updated_at"); + }); +}); + +// --------------------------------------------------------------------------- +// Family B — finding-groups (computed integer, DESC = FAIL/critical/new first) +// --------------------------------------------------------------------------- + +describe("finding-groups tokens (Family B)", () => { + it("uses minus prefixes because the API maps the keys to integer-weighted columns", () => { + // _FINDING_GROUP_SORT_MAP / _RESOURCE_SORT_MAP remap: + // status -> status_order (3=FAIL, 2=PASS, 1=MANUAL) + // severity -> severity_order (5=critical … 1=informational) + // delta -> delta_order (2=new, 1=changed, 0=otherwise) + // Higher integer = more important, so DESC puts FAIL/critical/new first. + expect(FG_FAIL_FIRST).toBe("-status"); + expect(FG_SEVERITY_HIGH_FIRST).toBe("-severity"); + expect(FG_DELTA_NEW_FIRST).toBe("-delta"); + }); + + it("uses -last_seen_at for recency on aggregated rows", () => { + expect(FG_RECENT_LAST_SEEN).toBe("-last_seen_at"); + }); +}); + +// --------------------------------------------------------------------------- +// Composition +// --------------------------------------------------------------------------- + +describe("composeSort", () => { + it("joins tokens with commas in the given order", () => { + expect( + composeSort( + FINDINGS_FAIL_FIRST, + FINDINGS_SEVERITY_HIGH_FIRST, + FINDINGS_RECENT_INSERT, + ), + ).toBe("status,severity,-inserted_at"); + }); + + it("returns an empty string when no tokens are passed", () => { + expect(composeSort()).toBe(""); + }); + + it("preserves token order so left-most has highest precedence (JSON:API rule)", () => { + expect(composeSort(FG_FAIL_FIRST, FG_SEVERITY_HIGH_FIRST)).toBe( + "-status,-severity", + ); + expect(composeSort(FG_SEVERITY_HIGH_FIRST, FG_FAIL_FIRST)).toBe( + "-severity,-status", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +describe("findings presets (Family A)", () => { + it("FINDINGS_DEFAULT_SORT puts FAIL first, then severity, then recency — no delta (unsupported)", () => { + expect(FINDINGS_DEFAULT_SORT).toBe("status,severity,-inserted_at"); + expect(FINDINGS_DEFAULT_SORT).not.toMatch(/\bdelta\b/); + }); + + it("FINDINGS_FILTERED_SORT omits status because the API call already applies filter[status]", () => { + expect(FINDINGS_FILTERED_SORT).toBe("severity,-inserted_at"); + }); + + it("RESOURCE_DRAWER_OTHER_FINDINGS_SORT uses updated_at since /findings/latest exposes it", () => { + expect(RESOURCE_DRAWER_OTHER_FINDINGS_SORT).toBe("severity,-updated_at"); + }); +}); + +describe("finding-groups presets (Family B)", () => { + it("FINDING_GROUPS_DEFAULT_SORT puts FAIL → critical → new → recent", () => { + expect(FINDING_GROUPS_DEFAULT_SORT).toBe( + "-status,-severity,-delta,-last_seen_at", + ); + }); + + it("FINDING_GROUP_RESOURCES_DEFAULT_SORT uses the same shape as the groups list", () => { + expect(FINDING_GROUP_RESOURCES_DEFAULT_SORT).toBe( + "-status,-severity,-delta,-last_seen_at", + ); + }); + + it("FINDING_GROUPS_FILTERED_SORT omits status/delta and uses last_seen_at (NOT inserted_at, which is invalid here)", () => { + expect(FINDING_GROUPS_FILTERED_SORT).toBe("-severity,-last_seen_at"); + // Regression guard for the latent /findings link bug: + // _FINDING_GROUP_SORT_MAP does not expose `inserted_at`, so the API + // returns "invalid sort parameter: inserted_at" if we send it. + expect(FINDING_GROUPS_FILTERED_SORT).not.toMatch(/inserted_at/); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-family invariants — these would have prevented the original bug +// --------------------------------------------------------------------------- + +describe("cross-family invariants", () => { + it("Family A presets never minus-prefix status or severity", () => { + const familyA = [ + FINDINGS_DEFAULT_SORT, + FINDINGS_FILTERED_SORT, + RESOURCE_DRAWER_OTHER_FINDINGS_SORT, + ]; + + for (const preset of familyA) { + expect(preset).not.toMatch(/-severity\b/); + expect(preset).not.toMatch(/-status\b/); + } + }); + + it("Family B presets always minus-prefix status, severity and delta", () => { + const familyB = [ + FINDING_GROUPS_DEFAULT_SORT, + FINDING_GROUP_RESOURCES_DEFAULT_SORT, + ]; + + for (const preset of familyB) { + expect(preset).toMatch(/-status\b/); + expect(preset).toMatch(/-severity\b/); + // status must precede severity (FAIL-first dominates severity-high-first) + expect(preset.indexOf("-status")).toBeLessThan( + preset.indexOf("-severity"), + ); + } + }); +}); diff --git a/ui/lib/findings-sort.ts b/ui/lib/findings-sort.ts new file mode 100644 index 00000000000..7dffeaf3a28 --- /dev/null +++ b/ui/lib/findings-sort.ts @@ -0,0 +1,130 @@ +/** + * Sort presets for findings-shaped endpoints. + * + * The Prowler API exposes two families of findings endpoints with INVERTED + * sort semantics for the same human intent. Reading them wrong inverts the + * triage order silently — a bug that has shipped more than once. + * + * ─── Family A: plain findings ───────────────────────────────────────────── + * `/findings`, `/findings/latest` + * `FindingViewSet.ordering_fields` (api/v1/views.py) maps `status` and + * `severity` straight to the Postgres ENUM columns. Postgres sorts ENUMs + * by DECLARATION order: + * severity: critical, high, medium, low, informational → ASC = critical first + * status: FAIL, PASS, MANUAL → ASC = FAIL first + * Use the bare token. NO minus prefix on `status` or `severity`. + * `delta` is NOT in `ordering_fields` — sorting by delta is unsupported. + * + * ─── Family B: finding-groups ───────────────────────────────────────────── + * `/finding-groups`, `/finding-groups/latest`, `/finding-groups/{id}/resources` + * `_FINDING_GROUP_SORT_MAP` and `_RESOURCE_SORT_MAP` (api/v1/views.py) + * REMAP the public sort keys to computed integer columns: + * severity → severity_order (5=critical … 1=informational) + * status → status_order (3=FAIL, 2=PASS, 1=MANUAL) + * delta → delta_order (2=new, 1=changed, 0=otherwise) + * Higher integer = more important. PREFIX with `-` to put FAIL/critical/new first. + * + * The two families look identical from the outside (`sort=...`) but require + * opposite tokens. Always import from this file. Never hard-code. + */ + +// --------------------------------------------------------------------------- +// Family A: plain findings (Postgres ENUM — no minus on status/severity) +// --------------------------------------------------------------------------- + +export const FINDINGS_FAIL_FIRST = "status"; +export const FINDINGS_SEVERITY_HIGH_FIRST = "severity"; +export const FINDINGS_RECENT_INSERT = "-inserted_at"; +export const FINDINGS_RECENT_UPDATE = "-updated_at"; + +// --------------------------------------------------------------------------- +// Family B: finding-groups (computed integer — minus on status/severity/delta) +// --------------------------------------------------------------------------- + +export const FG_FAIL_FIRST = "-status"; +export const FG_SEVERITY_HIGH_FIRST = "-severity"; +export const FG_DELTA_NEW_FIRST = "-delta"; +export const FG_RECENT_LAST_SEEN = "-last_seen_at"; + +// --------------------------------------------------------------------------- +// Composition +// --------------------------------------------------------------------------- + +export const composeSort = (...tokens: string[]): string => tokens.join(","); + +// --------------------------------------------------------------------------- +// Presets — Family A +// --------------------------------------------------------------------------- + +/** + * Default for plain-findings tables WITHOUT a server-side `filter[status]`. + * FAIL rows first, then critical→informational, then most recent. + * Delta is intentionally omitted — `/findings` does not accept `delta` as a + * sort field (see FindingViewSet.ordering_fields). + */ +export const FINDINGS_DEFAULT_SORT = composeSort( + FINDINGS_FAIL_FIRST, + FINDINGS_SEVERITY_HIGH_FIRST, + FINDINGS_RECENT_INSERT, +); + +/** + * Default for plain-findings tables that ALREADY apply `filter[status]=FAIL` + * (or equivalent) server-side. Status sort would be redundant. + */ +export const FINDINGS_FILTERED_SORT = composeSort( + FINDINGS_SEVERITY_HIGH_FIRST, + FINDINGS_RECENT_INSERT, +); + +/** + * Resource-detail drawer "other findings" tab. Pairs with a server-side + * `filter[status]=FAIL`, so status is omitted. Uses `-updated_at` because + * `/findings/latest` exposes `updated_at`, not `inserted_at`. + */ +export const RESOURCE_DRAWER_OTHER_FINDINGS_SORT = composeSort( + FINDINGS_SEVERITY_HIGH_FIRST, + FINDINGS_RECENT_UPDATE, +); + +// --------------------------------------------------------------------------- +// Presets — Family B +// --------------------------------------------------------------------------- + +/** + * Default for finding-groups list endpoints. FAIL groups first, then by + * severity, then by `new` deltas (deltas matter on group endpoints since + * `delta_order` is a real ordering column). + */ +export const FINDING_GROUPS_DEFAULT_SORT = composeSort( + FG_FAIL_FIRST, + FG_SEVERITY_HIGH_FIRST, + FG_DELTA_NEW_FIRST, + FG_RECENT_LAST_SEEN, +); + +/** + * Default for the per-group resources sub-endpoint + * (`/finding-groups/{id}/resources`). Same shape as the groups list because + * `_RESOURCE_SORT_MAP` exposes the same computed columns. + */ +export const FINDING_GROUP_RESOURCES_DEFAULT_SORT = composeSort( + FG_FAIL_FIRST, + FG_SEVERITY_HIGH_FIRST, + FG_DELTA_NEW_FIRST, + FG_RECENT_LAST_SEEN, +); + +/** + * Default for the `/findings` PAGE (which renders finding-groups, NOT plain + * findings) when the URL already constrains `filter[status__in]` and/or + * `filter[delta__in]`. Status and delta sort would be redundant. + * + * IMPORTANT: do NOT pass `inserted_at` here — `_FINDING_GROUP_SORT_MAP` + * does not expose it; valid recency keys are `last_seen_at`, `first_seen_at`, + * and `failing_since`. + */ +export const FINDING_GROUPS_FILTERED_SORT = composeSort( + FG_SEVERITY_HIGH_FIRST, + FG_RECENT_LAST_SEEN, +); diff --git a/ui/lib/index.ts b/ui/lib/index.ts index 75250e5c688..64bb574353f 100644 --- a/ui/lib/index.ts +++ b/ui/lib/index.ts @@ -1,6 +1,7 @@ export * from "./error-mappings"; export * from "./external-urls"; export * from "./findings-filters"; +export * from "./findings-sort"; export * from "./helper"; export * from "./helper-filters"; export * from "./menu-list"; From c27cb28a2a0e1efc66a8c0314ee2410d705f847a Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Wed, 22 Apr 2026 13:28:59 +0200 Subject: [PATCH 04/28] chore(safety): define policy for high and critical (#10845) --- .github/workflows/api-security.yml | 7 ++-- .github/workflows/sdk-security.yml | 3 +- .pre-commit-config.yaml | 14 ++++---- .safety-policy.yml | 58 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 .safety-policy.yml diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index 9323df97c34..7b8dc72cb1f 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -60,6 +60,7 @@ jobs: files: | api/** .github/workflows/api-security.yml + .safety-policy.yml files_ignore: | api/docs/** api/README.md @@ -80,10 +81,8 @@ jobs: - name: Safety if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check --ignore 79023,79027,86217,71600 - # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - # TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0` - # TODO: 71600 CVE-2024-1135 false positive - fixed in gunicorn 22.0.0, project uses 23.0.0 + # Accepted CVEs, severity threshold, and ignore expirations live in ../.safety-policy.yml + run: poetry run safety check --policy-file ../.safety-policy.yml - name: Vulture if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index c13061ff1b9..ceb6b1db1c7 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -83,7 +83,8 @@ jobs: - name: Security scan with Safety if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check -r pyproject.toml + # Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml + run: poetry run safety check -r pyproject.toml --policy-file .safety-policy.yml - name: Dead code detection with Vulture if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9d2503af604..b980e3a6c24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -152,17 +152,19 @@ repos: - id: safety name: safety description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" - # TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X - # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - # TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0` - # TODO: 71600 CVE-2024-1135 false positive - fixed in gunicorn 22.0.0, project uses 23.0.0 - entry: safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,86217,71600 + # Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml + entry: safety check --policy-file .safety-policy.yml language: system pass_filenames: false files: { glob: - ["**/pyproject.toml", "**/poetry.lock", "**/requirements*.txt"], + [ + "**/pyproject.toml", + "**/poetry.lock", + "**/requirements*.txt", + ".safety-policy.yml", + ], } - id: vulture diff --git a/.safety-policy.yml b/.safety-policy.yml new file mode 100644 index 00000000000..fec97e2fb9d --- /dev/null +++ b/.safety-policy.yml @@ -0,0 +1,58 @@ +# Safety policy for `safety check` (Safety CLI 3.x, v2 schema). +# Applied in: .pre-commit-config.yaml, .github/workflows/api-security.yml, +# .github/workflows/sdk-security.yml via `--policy-file`. +# +# Validate: poetry run safety validate policy_file --path .safety-policy.yml + +security: + # Scan unpinned requirements too. Prowler pins via poetry.lock, so this is + # defensive against accidental unpinned entries. + ignore-unpinned-requirements: False + + # CVSS severity filter. 7 = report only HIGH (7.0–8.9) and CRITICAL (9.0–10.0). + # Reference: 9=CRITICAL only, 7=CRITICAL+HIGH, 4=CRITICAL+HIGH+MEDIUM. + ignore-cvss-severity-below: 7 + + # Unknown severity is unrated, not safe. Keep False so unrated CVEs still fail + # the build and get a human eye. Flip to True only if noise is unmanageable. + ignore-cvss-unknown-severity: False + + # Fail the build when a non-ignored vulnerability is found. + continue-on-vulnerability-error: False + + # Explicit accepted vulnerabilities. Each entry MUST have a reason and an + # expiry. Expired entries fail the scan, forcing re-audit. + ignore-vulnerabilities: + 77744: + reason: "Botocore requires urllib3 1.X. Remove once upgraded to urllib3 2.X." + expires: '2026-10-22' + 77745: + reason: "Botocore requires urllib3 1.X. Remove once upgraded to urllib3 2.X." + expires: '2026-10-22' + 79023: + reason: "knack ReDoS; blocked until azure-cli-core (via cartography) allows knack >=0.13.0." + expires: '2026-10-22' + 79027: + reason: "knack ReDoS; blocked until azure-cli-core (via cartography) allows knack >=0.13.0." + expires: '2026-10-22' + 86217: + reason: "alibabacloud-tea-openapi==0.4.3 blocks upgrade to cryptography >=46.0.0." + expires: '2026-10-22' + 71600: + reason: "CVE-2024-1135 false positive. Fixed in gunicorn 22.0.0; project uses 23.0.0." + expires: '2026-10-22' + 70612: + reason: "TBD - audit required. Reason not documented in prior --ignore list." + expires: '2026-07-22' + 66963: + reason: "TBD - audit required. Reason not documented in prior --ignore list." + expires: '2026-07-22' + 74429: + reason: "TBD - audit required. Reason not documented in prior --ignore list." + expires: '2026-07-22' + 76352: + reason: "TBD - audit required. Reason not documented in prior --ignore list." + expires: '2026-07-22' + 76353: + reason: "TBD - audit required. Reason not documented in prior --ignore list." + expires: '2026-07-22' From 927be17fb7adb39a00ab6a6e7c806474be8f2972 Mon Sep 17 00:00:00 2001 From: Mathisdjango Date: Wed, 22 Apr 2026 16:14:10 +0200 Subject: [PATCH 05/28] feat(github): add check for dismissing stale PR approvals on default branch (CIS 1.1.4) (#10569) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + prowler/compliance/github/cis_1.0_github.json | 4 +- .../__init__.py | 0 ...anch_dismisses_stale_reviews.metadata.json | 37 +++ ..._default_branch_dismisses_stale_reviews.py | 44 ++++ .../services/repository/repository_service.py | 169 ++++++++++++++ ...ult_branch_dismisses_stale_reviews_test.py | 218 ++++++++++++++++++ .../repository/repository_service_test.py | 150 +++++++++++- 8 files changed, 619 insertions(+), 4 deletions(-) create mode 100644 prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/__init__.py create mode 100644 prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.metadata.json create mode 100644 prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.py create mode 100644 tests/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 0d709f2dd5a..e165641f70b 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added - SARIF output format for the IaC provider, enabling GitHub Code Scanning integration via `--output-formats sarif` [(#10626)](https://github.com/prowler-cloud/prowler/pull/10626) +- `repository_default_branch_dismisses_stale_reviews` check for GitHub provider to ensure stale pull request approvals are dismissed when new commits are pushed [(#10569)](https://github.com/prowler-cloud/prowler/pull/10569) --- diff --git a/prowler/compliance/github/cis_1.0_github.json b/prowler/compliance/github/cis_1.0_github.json index e9dbccc7f65..2a60df6bcd3 100644 --- a/prowler/compliance/github/cis_1.0_github.json +++ b/prowler/compliance/github/cis_1.0_github.json @@ -73,7 +73,9 @@ { "Id": "1.1.4", "Description": "Ensure that when a proposed code change is updated, previous approvals are declined, and new approvals are required.", - "Checks": [], + "Checks": [ + "repository_default_branch_dismisses_stale_reviews" + ], "Attributes": [ { "Section": "1 Source Code", diff --git a/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/__init__.py b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.metadata.json new file mode 100644 index 00000000000..f2cb69a16ee --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "github", + "CheckID": "repository_default_branch_dismisses_stale_reviews", + "CheckTitle": "Repository default branch dismisses stale pull request approvals", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "devops", + "Description": "GitHub repository default branch ensures that when new commits are pushed to an open pull request, any previously granted approvals are automatically dismissed, requiring fresh reviews before the pull request can be merged.", + "Risk": "Without this setting, a contributor can receive approvals on a clean version of their code, then push malicious or unauthorized changes afterward. The pull request retains its approvals and can be merged without anyone reviewing the new changes, bypassing the entire code review process.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#dismiss-stale-pull-request-approvals-when-new-commits-are-pushed" + ], + "Remediation": { + "Code": { + "CLI": "gh api -X PATCH /repos///branches//protection/required_pull_request_reviews -f dismiss_stale_reviews=true", + "NativeIaC": "", + "Other": "Using Rulesets (recommended): 1. Go to repository Settings > Rules > Rulesets. 2. Click 'New ruleset' > 'New branch ruleset' (or edit an existing one targeting the default branch). 3. Under 'Require a pull request before merging', enable 'Dismiss stale pull request approvals when new commits are pushed'. 4. Click 'Create' or 'Save changes'. Using classic branch protection: 1. Go to repository Settings > Branches. 2. Edit or add a branch protection rule for the default branch. 3. Under 'Require a pull request before merging', enable 'Dismiss stale pull request approvals when new commits are pushed'. 4. Click 'Save changes'.", + "Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"\" {\n repository = \"\"\n branch = \"\"\n\n required_pull_request_reviews {\n dismiss_stale_reviews = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable \"Dismiss stale pull request approvals when new commits are pushed\" in your branch protection rules. This ensures every code change undergoes a fresh review before merging.", + "Url": "https://hub.prowler.com/check/repository_default_branch_dismisses_stale_reviews" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.py b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.py new file mode 100644 index 00000000000..3b3fc6d3dbd --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews.py @@ -0,0 +1,44 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_branch_dismisses_stale_reviews(Check): + """Check if a repository dismisses stale pull request approvals when new commits are pushed. + + This class verifies whether each repository is configured to automatically + invalidate existing approvals when new commits are pushed to an open pull request. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the check. + + Returns: + List[CheckReportGithub]: A list of reports for each repository. + """ + findings = [] + + for repo in repository_client.repositories.values(): + + if repo.default_branch.dismiss_stale_reviews is not None: + + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not dismiss stale pull request approvals when new commits are pushed." + if ( + repo.default_branch.dismiss_stale_reviews_source + == "ruleset_not_active" + ): + report.status_extended = f"Repository {repo.name} has dismiss stale pull request approvals configured in a ruleset, but the ruleset is not active." + + if repo.default_branch.dismiss_stale_reviews: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} does dismiss stale pull request approvals when new commits are pushed." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index deb6864cb21..0201d199e4a 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -1,4 +1,5 @@ from datetime import datetime +from fnmatch import fnmatch from typing import Optional import github @@ -100,6 +101,145 @@ def _get_accessible_repos_graphql(self) -> list[str]: ) return [] + def _default_branch_matches_rule_pattern( + self, pattern: str, default_branch: str + ) -> bool: + """Check whether a ruleset ref pattern applies to the default branch.""" + branch_ref = f"refs/heads/{default_branch}" + + if pattern in {"~ALL", "~DEFAULT_BRANCH"}: + return True + + return fnmatch(branch_ref, pattern) + + def _ruleset_targets_default_branch( + self, ruleset: dict, default_branch: str + ) -> bool: + """Check whether a ruleset targets the repository default branch.""" + ref_name_conditions = (ruleset.get("conditions") or {}).get("ref_name") + if not ref_name_conditions: + return False + + include_patterns = ref_name_conditions.get("include") or [] + exclude_patterns = ref_name_conditions.get("exclude") or [] + + if not include_patterns: + return False + + if not any( + self._default_branch_matches_rule_pattern(pattern, default_branch) + for pattern in include_patterns + ): + return False + + return not any( + self._default_branch_matches_rule_pattern(pattern, default_branch) + for pattern in exclude_patterns + ) + + def _get_repository_rulesets(self, repo) -> Optional[list[dict]]: + """Fetch repository and parent branch rulesets with full rule details.""" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + try: + rulesets = [] + page = 1 + + while True: + _, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/repos/{repo.full_name}/rulesets?includes_parents=true&targets=branch&per_page=100&page={page}", + headers=headers, + ) + + if not isinstance(response, list): + break + + rulesets.extend(response) + + if len(response) < 100: + break + + page += 1 + + detailed_rulesets = [] + for ruleset in rulesets: + ruleset_id = ruleset.get("id") + if ruleset_id is None: + continue + + _, ruleset_details = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/repos/{repo.full_name}/rulesets/{ruleset_id}?includes_parents=true", + headers=headers, + ) + if isinstance(ruleset_details, dict): + detailed_rulesets.append(ruleset_details) + + return detailed_rulesets + except github.GithubException as error: + status_code = getattr(error, "status", None) + if status_code == 404: + logger.info( + f"{repo.full_name}: rulesets endpoint not available for this repository." + ) + return None + if status_code == 403: + logger.warning( + f"{repo.full_name}: insufficient permissions to query repository rulesets." + ) + return None + self._handle_github_api_error( + error, "fetching repository rulesets", repo.full_name + ) + except Exception as error: + logger.error( + f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return None + + def _get_dismiss_stale_reviews_from_rulesets( + self, repo, default_branch: str + ) -> tuple[Optional[bool], Optional[str]]: + """Evaluate dismiss-stale-review coverage from repository and parent rulesets.""" + rulesets = self._get_repository_rulesets(repo) + if rulesets is None: + return None, None + + has_inactive_ruleset = False + + for ruleset in rulesets: + if ruleset.get("target") != "branch": + continue + + if not self._ruleset_targets_default_branch(ruleset, default_branch): + continue + + for rule in ruleset.get("rules") or []: + if rule.get("type") != "pull_request": + continue + + dismiss_stale_reviews = (rule.get("parameters") or {}).get( + "dismiss_stale_reviews_on_push" + ) + if dismiss_stale_reviews is not True: + continue + + enforcement = ruleset.get("enforcement") + if enforcement in {"active", "enabled"}: + return True, "ruleset" + if enforcement in {"disabled", "evaluate"}: + has_inactive_ruleset = True + + if has_inactive_ruleset: + return False, "ruleset_not_active" + + return None, None + def _list_repositories(self): """ List repositories based on provider scoping configuration. @@ -256,6 +396,8 @@ def _process_repository(self, repo, repos): status_checks = False enforce_admins = False conversation_resolution = False + dismiss_stale_reviews = False + dismiss_stale_reviews_source = None try: branch = repo.get_branch(default_branch) if branch.protected: @@ -283,6 +425,13 @@ def _process_repository(self, repo, repos): if require_pr else False ) + dismiss_stale_reviews = ( + protection.required_pull_request_reviews.dismiss_stale_reviews + if require_pr + else False + ) + if dismiss_stale_reviews: + dismiss_stale_reviews_source = "classic" require_signed_commits = branch.get_required_signatures() except Exception as error: # If the branch is not found, it is not protected @@ -303,10 +452,26 @@ def _process_repository(self, repo, repos): status_checks = None enforce_admins = None conversation_resolution = None + dismiss_stale_reviews = None + dismiss_stale_reviews_source = None logger.error( f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + if dismiss_stale_reviews is not None: + ( + ruleset_dismiss_stale_reviews, + ruleset_source, + ) = self._get_dismiss_stale_reviews_from_rulesets(repo, default_branch) + if ruleset_dismiss_stale_reviews: + dismiss_stale_reviews = True + dismiss_stale_reviews_source = "ruleset" + elif ( + ruleset_source == "ruleset_not_active" and not dismiss_stale_reviews + ): + dismiss_stale_reviews = False + dismiss_stale_reviews_source = "ruleset_not_active" + secret_scanning_enabled = False dependabot_alerts_enabled = False try: @@ -363,6 +528,8 @@ def _process_repository(self, repo, repos): conversation_resolution=conversation_resolution, require_code_owner_reviews=require_code_owner_reviews, require_signed_commits=require_signed_commits, + dismiss_stale_reviews=dismiss_stale_reviews, + dismiss_stale_reviews_source=dismiss_stale_reviews_source, ), private=repo.private, archived=repo.archived, @@ -443,6 +610,8 @@ class Branch(BaseModel): require_code_owner_reviews: Optional[bool] require_signed_commits: Optional[bool] conversation_resolution: Optional[bool] + dismiss_stale_reviews: Optional[bool] + dismiss_stale_reviews_source: Optional[str] = None class Repo(BaseModel): diff --git a/tests/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews_test.py b/tests/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews_test.py new file mode 100644 index 00000000000..38c0025c8ff --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_branch_dismisses_stale_reviews/repository_default_branch_dismisses_stale_reviews_test.py @@ -0,0 +1,218 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_default_branch_dismisses_stale_reviews: + + def test_no_repositories(self): + """Cas limite : aucun repo → aucun résultat attendu.""" + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 0 + + def test_dismiss_stale_reviews_disabled(self): + """FAIL : le repo ne révoque pas les approbations obsolètes.""" + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=False, + allow_force_pushes=False, + branch_deletion=False, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + dismiss_stale_reviews=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not dismiss stale pull request approvals when new commits are pushed." + ) + + def test_dismiss_stale_reviews_enabled(self): + """PASS : le repo révoque bien les approbations obsolètes.""" + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + dismiss_stale_reviews=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} does dismiss stale pull request approvals when new commits are pushed." + ) + + def test_dismiss_stale_reviews_ruleset_not_active(self): + """FAIL : le ruleset existe mais n'est pas actif.""" + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=False, + branch_deletion=False, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + dismiss_stale_reviews=False, + dismiss_stale_reviews_source="ruleset_not_active", + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_dismisses_stale_reviews.repository_default_branch_dismisses_stale_reviews import ( + repository_default_branch_dismisses_stale_reviews, + ) + + check = repository_default_branch_dismisses_stale_reviews() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} has dismiss stale pull request approvals configured in a ruleset, but the ruleset is not active." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 932bf2c766a..97924cbbd81 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -137,7 +137,7 @@ def test_no_scoping_uses_graphql(self): provider.repositories = [] provider.organizations = [] - with patch.object(Repository, "__init__", lambda x, y: None): + with patch.object(Repository, "__init__", lambda *_: None): repository_service = Repository(provider) mock_client = MagicMock() repository_service.clients = [mock_client] @@ -165,7 +165,7 @@ def test_graphql_call_api_error(self): provider.repositories = [] provider.organizations = [] - with patch.object(Repository, "__init__", lambda x, y: None): + with patch.object(Repository, "__init__", lambda *_: None): repository_service = Repository(provider) repository_service.clients = [MagicMock()] repository_service.provider = provider @@ -193,7 +193,7 @@ def test_graphql_returns_empty_list(self): provider.repositories = [] provider.organizations = [] - with patch.object(Repository, "__init__", lambda x, y: None): + with patch.object(Repository, "__init__", lambda *_: None): repository_service = Repository(provider) repository_service.clients = [MagicMock()] repository_service.provider = provider @@ -462,3 +462,147 @@ def test_rate_limit_error_handling(self): # Should log rate limit error mock_logger.error.assert_called() assert "Rate limit exceeded" in str(mock_logger.error.call_args) + + +class Test_Repository_DismissStaleReviewsRulesets: + def setup_method(self): + self.repository_service = Repository.__new__(Repository) + self.repository_service.provider = set_mocked_github_provider() + self.repository_service.clients = [] + self.repository_service.audit_config = None + self.repository_service.fixer_config = None + + def _build_repo( + self, + *, + branch_protected: bool, + dismiss_stale_reviews: bool = False, + ruleset_details: list[dict] | None = None, + ): + repo = MagicMock() + repo.id = 1 + repo.name = "repo1" + repo.owner.login = "owner1" + repo.full_name = "owner1/repo1" + repo.default_branch = "main" + repo.private = False + repo.archived = False + repo.pushed_at = datetime.now(timezone.utc) + repo.delete_branch_on_merge = False + repo.security_and_analysis = None + repo.get_contents.side_effect = [None, None, None, None] + repo.get_dependabot_alerts.side_effect = Exception( + "403 Forbidden: Dependabot alerts are disabled for this repository." + ) + + branch = MagicMock() + branch.protected = branch_protected + branch.get_required_signatures.return_value = False + + protection = MagicMock() + protection.required_linear_history = False + protection.allow_force_pushes = False + protection.allow_deletions = False + protection.required_status_checks = None + protection.enforce_admins = False + protection.required_conversation_resolution = False + + if branch_protected: + protection.required_pull_request_reviews = MagicMock( + required_approving_review_count=1, + require_code_owner_reviews=False, + dismiss_stale_reviews=dismiss_stale_reviews, + ) + else: + protection.required_pull_request_reviews = None + + branch.get_protection.return_value = protection + repo.get_branch.return_value = branch + + ruleset_details = ruleset_details or [] + repo._requester.requestJsonAndCheck.side_effect = [ + (None, [{"id": ruleset["id"]} for ruleset in ruleset_details]), + *[(None, ruleset) for ruleset in ruleset_details], + ] + + return repo + + def _build_pull_request_ruleset(self, *, enforcement: str, include: list[str]): + return { + "id": 101, + "name": "Dismiss stale reviews", + "target": "branch", + "source_type": "Repository", + "source": "owner1/repo1", + "enforcement": enforcement, + "conditions": {"ref_name": {"include": include, "exclude": []}}, + "rules": [ + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": True, + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_approving_review_count": 1, + "required_review_thread_resolution": False, + }, + } + ], + } + + def test_process_repository_uses_classic_branch_protection(self): + repo = self._build_repo(branch_protected=True, dismiss_stale_reviews=True) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is True + assert repos[1].default_branch.dismiss_stale_reviews_source == "classic" + + def test_process_repository_uses_active_ruleset_for_default_branch(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_pull_request_ruleset( + enforcement="active", include=["~DEFAULT_BRANCH"] + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is True + assert repos[1].default_branch.dismiss_stale_reviews_source == "ruleset" + + def test_process_repository_treats_all_branches_ruleset_as_default_branch(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_pull_request_ruleset(enforcement="active", include=["~ALL"]) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is True + assert repos[1].default_branch.dismiss_stale_reviews_source == "ruleset" + + def test_process_repository_marks_inactive_ruleset_as_fail_signal(self): + repo = self._build_repo( + branch_protected=False, + ruleset_details=[ + self._build_pull_request_ruleset( + enforcement="disabled", include=["~DEFAULT_BRANCH"] + ) + ], + ) + repos = {} + + self.repository_service._process_repository(repo, repos) + + assert repos[1].default_branch.dismiss_stale_reviews is False + assert ( + repos[1].default_branch.dismiss_stale_reviews_source == "ruleset_not_active" + ) From dff5541e11a632e4b84e30ee78441ef50b355837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 22 Apr 2026 16:31:05 +0200 Subject: [PATCH 06/28] fix(ci): improve compliance check action (#10850) --- .github/workflows/pr-check-compliance-mapping.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-check-compliance-mapping.yml b/.github/workflows/pr-check-compliance-mapping.yml index 1ed78995bb2..be934d59836 100644 --- a/.github/workflows/pr-check-compliance-mapping.yml +++ b/.github/workflows/pr-check-compliance-mapping.yml @@ -20,7 +20,13 @@ permissions: {} jobs: check-compliance-mapping: - if: contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false + if: >- + github.event.pull_request.state == 'open' && + contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false && + ( + (github.event.action != 'labeled' && github.event.action != 'unlabeled') + || github.event.label.name == 'no-compliance-check' + ) runs-on: ubuntu-latest timeout-minutes: 15 permissions: From f4b0f8fa22fd3e6d45ddf847d3ecfdc3454d55b6 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:45:16 +0200 Subject: [PATCH 07/28] fix(ui): prevent rescheduling scans during credential update (#10851) --- ui/CHANGELOG.md | 4 ++++ .../use-provider-wizard-controller.test.tsx | 9 ++++---- .../hooks/use-provider-wizard-controller.ts | 7 ++++++ .../wizard/provider-wizard-modal.tsx | 22 ++++++++++++++++--- .../providers/wizard/wizard-stepper.tsx | 20 ++++++++++------- 5 files changed, 47 insertions(+), 15 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index bedc6d819ee..dad0b20922f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to the **Prowler UI** are documented in this file. - Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) +### 🐞 Fixed + +- Provider wizard no longer advances to the Launch Scan step when rotating credentials: the modal closes after a successful connection test, preventing inadvertent scan rescheduling during credential updates [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851) + ### ❌ Removed - Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767) diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx index 187c1e02519..9688c3d6843 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.test.tsx @@ -153,7 +153,7 @@ describe("useProviderWizardController", () => { expect(onOpenChange).not.toHaveBeenCalled(); }); - it("moves to launch step after a successful connection test in update mode", async () => { + it("closes the wizard after a successful connection test in update mode", async () => { // Given const onOpenChange = vi.fn(); const { result } = renderHook(() => @@ -181,9 +181,10 @@ describe("useProviderWizardController", () => { result.current.handleTestSuccess(); }); - // Then - expect(result.current.currentStep).toBe(PROVIDER_WIZARD_STEP.LAUNCH); - expect(onOpenChange).not.toHaveBeenCalled(); + // Then: credential rotation never surfaces the launch/schedule step + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(result.current.currentStep).not.toBe(PROVIDER_WIZARD_STEP.LAUNCH); }); it("does not override launch footer config in the controller", () => { diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 21fce019aa5..40644450c23 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -197,6 +197,12 @@ export function useProviderWizardController({ }; const handleTestSuccess = () => { + if ( + useProviderWizardStore.getState().mode === PROVIDER_WIZARD_MODE.UPDATE + ) { + handleClose(); + return; + } setCurrentStep(PROVIDER_WIZARD_STEP.LAUNCH); }; @@ -234,6 +240,7 @@ export function useProviderWizardController({ handleTestSuccess, isOrgDirectEntry, isProviderFlow, + mode, modalTitle, openOrganizationsFlow, orgCurrentStep, diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 9157ca74ceb..706045a829b 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -10,7 +10,10 @@ import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog"; import { Modal } from "@/components/shadcn/modal"; import { useScrollHint } from "@/hooks/use-scroll-hint"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; -import { PROVIDER_WIZARD_STEP } from "@/types/provider-wizard"; +import { + PROVIDER_WIZARD_MODE, + PROVIDER_WIZARD_STEP, +} from "@/types/provider-wizard"; import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { @@ -23,7 +26,12 @@ import { WIZARD_FOOTER_ACTION_TYPE } from "./steps/footer-controls"; import { LaunchStep } from "./steps/launch-step"; import { TestConnectionStep } from "./steps/test-connection-step"; import type { OrgWizardInitialData, ProviderWizardInitialData } from "./types"; -import { WizardStepper } from "./wizard-stepper"; +import { PROVIDER_WIZARD_STEPS, WizardStepper } from "./wizard-stepper"; + +const UPDATE_MODE_WIZARD_STEPS = PROVIDER_WIZARD_STEPS.slice( + 0, + PROVIDER_WIZARD_STEP.LAUNCH, +); interface ProviderWizardModalProps { open: boolean; @@ -47,6 +55,7 @@ export function ProviderWizardModal({ handleTestSuccess, isOrgDirectEntry, isProviderFlow, + mode, modalTitle, openOrganizationsFlow, orgCurrentStep, @@ -97,7 +106,14 @@ export function ProviderWizardModal({
{isProviderFlow ? ( - + ) : ( - {STEPS.map((step, index) => { + {steps.map((step, index) => { const isComplete = index < activeVisualStep; const isActive = index === activeVisualStep; const isInactive = index > activeVisualStep; @@ -67,7 +71,7 @@ export function WizardStepper({ isActive={isActive} icon={step.icon} /> - {index < STEPS.length - 1 && ( + {index < steps.length - 1 && ( )}
From db2f92e6d51946991626256b5b6baac3f72f2980 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:52:17 +0200 Subject: [PATCH 08/28] chore: add prowler-openspec-opensource as git submodule (#10680) Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.6 (1M context) --- .gitmodules | 3 +++ openspec | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 openspec diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000000..0f83e0c03e7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "openspec"] + path = openspec + url = https://github.com/prowler-cloud/prowler-openspec-opensource.git diff --git a/openspec b/openspec new file mode 160000 index 00000000000..abf67069fa0 --- /dev/null +++ b/openspec @@ -0,0 +1 @@ +Subproject commit abf67069fa092259f99f66c49fe546203f764e58 From e9731f53ad4b1e7a0f27fc0cf5c5d1524dae5e8f Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:22:32 +0200 Subject: [PATCH 09/28] chore(ui): reorganize changelog and open 1.24.4 section (#10866) --- ui/CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index dad0b20922f..afc93eee638 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,16 +6,17 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed +- Redesign compliance page, client-side search for compliance frameworks, compact scan selector trigger, enhanced compliance cards [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767) - Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) +- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797) -### 🐞 Fixed +--- -- Provider wizard no longer advances to the Launch Scan step when rotating credentials: the modal closes after a successful connection test, preventing inadvertent scan rescheduling during credential updates [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851) +## [1.24.4] (Prowler UNRELEASED) -### ❌ Removed +### 🐞 Fixed -- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767) -- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797) +- Provider wizard no longer advances to the Launch Scan step when rotating credentials [(#10851)](https://github.com/prowler-cloud/prowler/pull/10851) --- From 6ae129fcc09037960abee1166147e8f1998452f1 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:13:35 +0200 Subject: [PATCH 10/28] chore: remove unused submodule (#10869) Co-authored-by: Pablo F.G --- .gitignore | 2 ++ .gitmodules | 3 --- openspec | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 .gitmodules delete mode 160000 openspec diff --git a/.gitignore b/.gitignore index d959c0e524f..9e0e4da8491 100644 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,8 @@ node_modules # Persistent data _data/ +/openspec/ +/.gitmodules # AI Instructions (generated by skills/setup.sh from AGENTS.md) CLAUDE.md diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 0f83e0c03e7..00000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "openspec"] - path = openspec - url = https://github.com/prowler-cloud/prowler-openspec-opensource.git diff --git a/openspec b/openspec deleted file mode 160000 index abf67069fa0..00000000000 --- a/openspec +++ /dev/null @@ -1 +0,0 @@ -Subproject commit abf67069fa092259f99f66c49fe546203f764e58 From 2ca74102a99b7236ed6ff282ea5390b2b4ca6104 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Thu, 23 Apr 2026 12:30:14 +0200 Subject: [PATCH 11/28] chore(poetry): lock poetry with 2.3.4 and install git as required (#10868) --- api/Dockerfile | 1 + api/poetry.lock | 20 ++++++++++---------- poetry.lock | 11 ++++++----- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 07f69d0b0f2..1bcffc479ec 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -22,6 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libtool \ libxslt1-dev \ python3-dev \ + git \ && rm -rf /var/lib/apt/lists/* # Install PowerShell diff --git a/api/poetry.lock b/api/poetry.lock index b74417c745e..f93e0d21e66 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "about-time" @@ -2974,7 +2974,7 @@ files = [ [package.dependencies] autopep8 = "*" Django = ">=4.2" -gprof2dot = ">=2017.09.19" +gprof2dot = ">=2017.9.19" sqlparse = "*" [[package]] @@ -4582,7 +4582,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -4790,7 +4790,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] mongodb = ["pymongo (==4.15.3)"] msgpack = ["msgpack (==1.1.2)"] pyro = ["pyro4 (==4.82)"] -qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"] +qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"] redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"] slmq = ["softlayer_messaging (>=1.0.3)"] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] @@ -4811,7 +4811,7 @@ files = [ ] [package.dependencies] -certifi = ">=14.05.14" +certifi = ">=14.5.14" durationpy = ">=0.7" google-auth = ">=1.0.1" oauthlib = ">=3.2.2" @@ -6964,11 +6964,11 @@ description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main", "dev"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] -markers = {main = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} [[package]] name = "pydantic" @@ -7194,7 +7194,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.2.2,<=3.3.0-dev0" +astroid = ">=3.2.2,<=3.3.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, @@ -7216,7 +7216,7 @@ description = "The MSALRuntime Python Interop Package" optional = false python-versions = ">=3.6" groups = ["main"] -markers = "(platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\") and sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" and (platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\")" files = [ {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0c22e2e83faa10de422bbfaacc1bb2887c9025ee8a53f0fc2e4f7db01c4a7b66"}, {file = "pymsalruntime-0.18.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8ce2944a0f944833d047bb121396091e00287e2b6373716106da86ea99abf379"}, @@ -8209,10 +8209,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "safety" diff --git a/poetry.lock b/poetry.lock index b9a48f1c3b7..06941386e5a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1934,6 +1934,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "contextlib2" @@ -3102,7 +3103,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -3182,7 +3183,7 @@ files = [ ] [package.dependencies] -certifi = ">=14.05.14" +certifi = ">=14.5.14" durationpy = ">=0.7" google-auth = ">=1.0.1" oauthlib = ">=3.2.2" @@ -4988,7 +4989,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.3.8,<=3.4.0-dev0" +astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, @@ -5834,10 +5835,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "safety" From 2304bf0093aec09019ac5714f93d9de03755a9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 23 Apr 2026 13:28:30 +0200 Subject: [PATCH 12/28] feat(compliance): add CIS pdf reporting (#10650) --- api/CHANGELOG.md | 6 +- api/src/backend/api/tests/test_views.py | 45 ++ api/src/backend/api/v1/views.py | 63 ++ api/src/backend/tasks/assets/img/cis_logo.png | Bin 0 -> 134333 bytes api/src/backend/tasks/jobs/report.py | 233 +++++- .../backend/tasks/jobs/reports/__init__.py | 11 +- api/src/backend/tasks/jobs/reports/cis.py | 755 ++++++++++++++++++ .../backend/tasks/jobs/reports/components.py | 46 ++ api/src/backend/tasks/jobs/reports/config.py | 28 + api/src/backend/tasks/tasks.py | 7 +- api/src/backend/tasks/tests/test_reports.py | 266 +++++- .../backend/tasks/tests/test_reports_cis.py | 532 ++++++++++++ ui/CHANGELOG.md | 4 + ui/actions/scans/scans.ts | 123 ++- .../compliance/[compliancetitle]/page.tsx | 31 +- ui/app/(prowler)/compliance/page.tsx | 12 + ui/components/compliance/compliance-card.tsx | 16 +- .../compliance-download-container.test.tsx | 13 +- .../compliance-download-container.tsx | 7 +- .../compliance/compliance-overview-grid.tsx | 8 + .../compliance-report-types.test.ts | 156 ++++ ui/lib/compliance/compliance-report-types.ts | 124 ++- ui/lib/helper.ts | 29 +- 23 files changed, 2411 insertions(+), 104 deletions(-) create mode 100644 api/src/backend/tasks/assets/img/cis_logo.png create mode 100644 api/src/backend/tasks/jobs/reports/cis.py create mode 100644 api/src/backend/tasks/tests/test_reports_cis.py create mode 100644 ui/lib/compliance/compliance-report-types.test.ts diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2d7342a293d..0b3a9ffff84 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.26.0] (Prowler UNRELEASED) +### 🚀 Added + +- CIS Benchmark PDF report generation for scans, exposing the latest CIS version per provider via `GET /scans/{id}/cis/{name}/` and picking the variant dynamically via `_pick_latest_cis_variant` (no hard-coded provider → version mapping) [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650) + ### 🔄 Changed - Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) @@ -736,4 +740,4 @@ All notable changes to the **Prowler API** are documented in this file. - Findings metadata endpoint received a performance improvement [(#6863)](https://github.com/prowler-cloud/prowler/pull/6863) - Increased the allowed length of the provider UID for Kubernetes providers [(#6869)](https://github.com/prowler-cloud/prowler/pull/6869) ---- +--- \ No newline at end of file diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index b81ae0c677f..736cbbd56ec 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -4113,6 +4113,51 @@ def test_compliance_local_file( assert cd.startswith('attachment; filename="') assert cd.endswith(f'filename="{fname.name}"') + def test_cis_no_output(self, authenticated_client, scans_fixture): + """CIS PDF endpoint must 404 when the scan has no output_location.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + scan.output_location = "" + scan.save() + + url = reverse("scan-cis", kwargs={"pk": scan.id}) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_404_NOT_FOUND + assert ( + resp.json()["errors"]["detail"] + == "The scan has no reports, or the CIS report generation task has not started yet." + ) + + def test_cis_local_file(self, authenticated_client, scans_fixture, monkeypatch): + """CIS PDF endpoint must serve the latest generated PDF.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + base = tmp_path / "reports" + cis_dir = base / "cis" + cis_dir.mkdir(parents=True, exist_ok=True) + fname = cis_dir / "prowler-output-aws-20260101000000_cis_report.pdf" + fname.write_bytes(b"%PDF-1.4 fake pdf") + + scan.output_location = str(base / "scan.zip") + scan.save() + + monkeypatch.setattr( + glob, + "glob", + lambda p: [str(fname)] if p.endswith("*_cis_report.pdf") else [], + ) + + url = reverse("scan-cis", kwargs={"pk": scan.id}) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_200_OK + assert resp["Content-Type"] == "application/pdf" + cd = resp["Content-Disposition"] + assert cd.startswith('attachment; filename="') + assert cd.endswith(f'filename="{fname.name}"') + @patch("api.v1.views.Task.objects.get") @patch("api.v1.views.TaskSerializer") def test__get_task_status_returns_none_if_task_not_executing( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 28b5fd49f04..99813576f5d 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1926,6 +1926,27 @@ def destroy(self, request, *args, pk=None, **kwargs): ), }, ), + cis=extend_schema( + tags=["Scan"], + summary="Retrieve CIS Benchmark compliance report", + description="Download the CIS Benchmark compliance report as a PDF file. " + "When a provider ships multiple CIS versions, the report is generated " + "for the highest available version.", + request=None, + responses={ + 200: OpenApiResponse( + description="PDF file containing the CIS compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 401: OpenApiResponse( + description="API key missing or user not Authenticated" + ), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="The scan has no CIS reports, or the CIS report generation task has not started yet" + ), + }, + ), ) @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="retrieve") @@ -1994,6 +2015,9 @@ def get_serializer_class(self): elif self.action == "csa": if hasattr(self, "response_serializer_class"): return self.response_serializer_class + elif self.action == "cis": + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -2236,6 +2260,45 @@ def compliance(self, request, pk=None, name=None): content, filename = loader return self._serve_file(content, filename, "text/csv") + @action( + detail=True, + methods=["get"], + url_name="cis", + ) + def cis(self, request, pk=None): + scan = self.get_object() + running_resp = self._get_task_status(scan) + if running_resp: + return running_resp + + if not scan.output_location: + return Response( + { + "detail": "The scan has no reports, or the CIS report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, + ) + + if scan.output_location.startswith("s3://"): + bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") + key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") + prefix = os.path.join( + os.path.dirname(key_prefix), + "cis", + "*_cis_report.pdf", + ) + loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True) + else: + base = os.path.dirname(scan.output_location) + pattern = os.path.join(base, "cis", "*_cis_report.pdf") + loader = self._load_file(pattern, s3=False) + + if isinstance(loader, Response): + return loader + + content, filename = loader + return self._serve_file(content, filename, "application/pdf") + @action( detail=True, methods=["get"], diff --git a/api/src/backend/tasks/assets/img/cis_logo.png b/api/src/backend/tasks/assets/img/cis_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7c1568da5ec877a9ca7d9238c153a2be92d4f8ae GIT binary patch literal 134333 zcma&Oc{r5O-#AK81tQ;=c&)<_uuba7x#6|JuNwC{^uc(&}R_H7VD$X1qj6Z5(GlFfsJH(hol^mf8;D~ulMI`XliP194FSVXz%}nI&@6)@Okf(4LwvVurCMJ}GZm0mHwm;geb+360XbN; z5-A>oM>KxI|A1?>+^3gfUZ;KTXkBM|cYA2eyVk~G7TuDl$0qwPcVi=iMZ|vY`3GR| z(4X~(m2J2R@bf0uTx>@@vM|Y9h-8xK+)6(45Me#hv?ibvzv=^+o0|B1ZND;q5$t*0 z&}tVSL-B`+lUfR#^C!CTpYyg_m4=vEOt%3hMJ#==ay{{+5u7L|URI9KQ8xJO*)4>O zitlt=U>;p(_IGnhH$w-yrP65#^BW&&t*)jPJowFYa`+nFPXe(5`m% zSy7~=Fksc2jvq%7f0$gCdELKc$H~T^2%nUpTa3Fhp6y{;E#0#jCG|F@u$_;-jT5IHyGohEk10cX$PmN%>UueozJ`7e0;RfH zE&Tf4zw0PHxXs27IaNqDv?9=CK%OlIIaAZXv~cY~21?g^deGy2G~kjp*qrEedo^o9 zC-RES!aP?&*siyHBae#MWj0KNT{Jz`v2kJuNp z7jA(B^3ujU5|}QECnuf+EE!g^hVXfVB6o>3-)G0|3QkFMZ~jN%1}^aOr&>6m~hn#(}&MQB=U$0owi(zSk07PLtiiRUu~HmVRp4~<)*`At>t8<^P` z*DGC}Qe(ytnQ`$29MtM&=gt?J^WG9b6+tD$#uPVVJ4WZYqXO;RA^w)hSu?o9S`Ld!n}+ zA3*=JiP#q`UJku|m&2esUhXU2=`krZ4`@tzSl^xiJk=K)xrU^?0#6)#BnV# zPsCLmy66%@V){zJ88GDxGP$R`$B@nDs<*#mMHGgT0F=58FQcu1#+y;O^xDDLB>0$# zPsmq~Nuu<$Z}o{dukC5CR`Fv_Wf5_53VaCINEp|Ye1iY3UBM-)9FocDKXpSvn@C}3 z@7$@9%enL)ou`}y;erh6$M2DVy?u7KxV3@q2d0UKezgcgCKqVij@ts}Qt?(p4u@An z?~3em%+G4W8-=OnvLiv1>2Cg>tXA};^=To1Ll4P?)|S$Pr`j&t)z82|SeG$>;XtAD z3rr+5@vd~d+30zbjCN(kGmGa`(~C>?fM(4tvX&Rt8|Df<4JCq3$`$@}GG|Pisv~d{%^7$WQm|slFn=bc$VswEEntmBQkcyM`%epmV}b7Vf;s{ZF45GMF-1$O{wWxC4N#*pr6)E3p| z5eNL7uGJt~&lia1RPeVAm%ca4dWdrx^|}fIo5t5d?lFts8~HYeTpJvC9VGSep{I8N zdFL_461G&0o^QO*E%J8u5dB!B#Khphvo9lRTnUxuOXMcWmE~$fjp{PTixv zw@>~3PYOd(nJjV%7L376yi;ss?Il1s+P*^77rY^3Tr(-wJvoAJD`@xR5HhZAG%kb^ z=U&#B6=c$&`jD%1O)x#C+H9cL0^kT(Qj;7h9RFw@rU4-Z?EQh>zv)ilq!c}*Vc|e&AQ5%Z_ji!e?EWLl({h)Cr%P^e zn2d_AIX}uIHIP4zXoWpXH8gP@{_Ih?iWo0as(2MH=xhx3+jR;6t;-Jd!FNFatMVPa zu!k_o0GXcQ`NGp=^M7a|1a%@i1u`%*TsBiwA6_=Dt@?a<&5TESCaiT3?0Sjdh@bl6`*m^T6DyZj=Ki~lZgdCV27OEwTA z|5(C|dcDNR1@&DUo_~~004P%~^GZK7Y83sO^yJ zpJum+{au~EYxwwMjv|6Nto=A@xWaKU4j_FeH-z%}WL%EwA9yZXYgEHhSnm-a_h!wB z-DFaOsHy7&hsT3 zr?&&mTMivZPxqXfgqGSY4WmoCwl^by%GSyqebv3Y89cIAB`Z@1)A#~YWMYH}a(l`zy#MOI70FNcARy1<#<)8P_f_p(+@F)g zxT~uxQF!MP5f0DVNO#}d?vxB+UxfsTO2@Pk^)NnWVFr0R1~DBlPK${^h786&WV#gP z7oO!43g5d|Ft!2ah;e-We&Au$i!+nbe@ycKjf@+AY=TMAWUR??4P>L4Q~i)0ur2Ra zKY0Ui33+kp=`lGaX)oBk$wlITO-RrK`e-T;7hoD(x;R(tZqsY6W1EuO@q)%1di%@> z)d7n8J1>Oy&>!->w4v0!xjO9;3(ed3qHVNYa7M2+3b}eM%3a2_g?oJ)`wH){ZkU+v zC`)|=x}mk#m!|;XAQxFZcniN}Rz3z@!lk6l@o!Sdsds{8wqS_*F`Vb!8hpuC;dr`z z{qjeEDfiW*5s7TP|B5wn@MvcEg>IUFq9eU)6Nu|FX=(mIlTol_E)^_JN3KU-DF2V^ z0LeMR5?3kz%RFf5&aD2uph8W#yn7yr{|wS|A1A}_TaL;*4UTX6)P#dS7GB1t(u3KNu$dOQRrPx z;QeN${0|JMpjEl_J76H@jAqtBxKTqiNfKug{3wn@u`}A8_~OxyYiKX9_f=Pu_|NGn zp|T-dN@F_hEbkPTwN*HlVpIPV`hv&OUe*R*#=O$81Wr!wWn9mGu4|9Z?Ajz^0Lvey zEn6w17(K*1PmG7+if_&vmH(dcLA*KB7@~Bksm~y|J;BhAKsNvA#n*?D3@-42@C0u*bi0(WIV$+hduX46V1{@LJ(?-w_rrN{2_{s-EL|^(M^NmCN>l9;gkxZSO>7+BJ0l%zM6Ns z*viScPW~i$Fs|Dd@kgO7IDv_lRXdJZq;KtCRzfq`|49x6QXq%2VpEr8VZ*GCF7tp4 z>8hE9Mg%8!ZBO9mWS1Nf4!v7BnS0TG=(-rv4-6n#>O>#O1wx8vih5v4+#7R8gKu1@ z`!8NR6}%A2amro1u6wu@M$jUVfqyC2CU5@>G~5i;9fu6);}?kjVb zxH^`icW+Sy2$^!A7Xi!!31mpa0KTNySC!aa{_?-c40)o;;=&x33n8?pJcnG}t8Y}@ z#Si2c+;l3IM0&a&^rt&HR0mk0B`?Yj(Le?wT}QOIcFw48CGWuYt)$BwhX0`|B!`D3 zaUW8Nv)$Ykh9=%c6LY}0V?lFVZOw%DOH8HCl(TuefvZ@xzWABI^o!UJw73vtdQ%T= z2=2`gI6NnG^3Cff|HXn3T|*y+9UJ6!n4$4jtyy75@k=;DM#u(>zgx9oJdIx#np)a< zQmlWW$s_DxS$U5{Q_?t*_6iLgl_}rZ5C^lGSSP2QnIN%HwEw?|8yc_1iGX)6_GGc;wSyvotYynXRiUeTNUs=QQ1|iowt^Q`|{nQH#$1 zO^5ettkW~mV>w_Mh6d|nBj(6M*T2$o41nYm+jn0Kjuxrv7hnfsy1qlp%s3@+dNXTS zp#Ov`{li|cQ1EVrje=Y7>>g=o*}ySB)!1t$fm8o*hJO5GDRgcfGttIN|CoAyA6(5d zam8hFc!q4+&by|=?sDs(%ox=;Qe~}?=EMlCUqh;0=(X*Awtc#H-f(4KtNHZRys!i8 zu|f7)flJo_S;-5W;_I@Y_Yk89qz*H$N~bqVTf%PIw&1oK4Z+J`);Bq@+Z9-GDp)#_ zi=rivgYUJLBd~_dkR1K|7JO8EgyU|lcq@U?GXh_VUl2{A9{)%CQ+_P{XmrmxOpwB~ z#(sMUs`^$rPk%{vXIX)Pn0t2-!D@p0lf-AQdzZA@fZ}Tx)d#=nY znFqhwHMB<8pcXNU0iveq{Utal%b>EW{X^gDRSKiM6N>7Jh66!KgKK&xofZYR5^fYQ zyC}@Yb*5;yhPNEjxAT;Fa6GA_)O&M#ktu-`VA>gH&`VHb_#gKexF>xW@gF9?(nyXP zhA62yL#f+=ZvV&wS!Yom(ps*XTA~6&=Q}n}(0IMy%HB)O+`{-*nwC2AuYISTRI^<- z)S8~49e_PoDC{U{ReX7=^*#)t|11=xeqRI)pfsd~Tt>-oFi)Jz9M(=#RGJiq?*HNNfoFN>crWzJzV$kg8V+Tf(XRc

R%z)#1 zvu354rRqYp!DH)@aX^;*Fd<)rc4hO>7FH!F9x&op%xB8z)>NjHir6sUTFYv|+koow z9n&yNJ#<0!%xzGbXvAL>Tl_8BDDgS<&c7W<-DYv$tLKKiZ}JgEDD)w3by;i<>B|G8 zoaKo%n%eA1H48!%Q?LCim!i=P`99jb4v?@JuzJ83k{t_Swxam;Wtk>F)+?6E<1oxf zKwD@b_{D}FJoD_~iWFKyboCbg!<%qyhkG`Y8 z^s9;;HCfBl-d+9t(s~AhxDZg~;rG(_zNqp)m^m<>sy(klp=!gwF6djVk4=aoH+ARZ zJkh*YoZs)SyqBQzN(B>4h~kps8wUl&m)X2`>6)q`3F+is(TT+uPl`X52CK5qX;duj zmSSR#B8knD9>p$5&IM1x+&y!gN*7R^J#}kcs>)1EZc*!)V3wFF{r>-CsJ1I5>hNpRbR0ND{0wSOp2@hS^wL)0O-*s~K~3d#EHQ&lIj zJ4)Uks&Q`VNj5LyGrMIJoyCO|T`xa@6CvCf;dMFpy@~#V92$J5Er#=sZfi$T4-qk5 ztc_dM0oY?|CJe%YCId`(eox^yeSlF_uZP~3Dcqprv@?L{%NRj0i>$X_0JtC?POiQfhvac=xy5yKHA-@2~it%ik)#_W~I;e8*{I1ejj%T(7-h zXjzR<@lVIJ2ZG;P9UGMbng3FJr(Rz_bufnwmQet?@PivuvnvKx*5c*Yg20{4rqN+f z`=UDITxW~*Z^z>h)W#{6R+qQmbdw&Qt0f~gqrzu)=)vF<7cXB%cJI3< ztUAE|jSojTUp(HY&LMU*rMqsTtAKZnKo4h zDEdZSO1}@+#_bnD4)5I9Boz&5;`V^+w@qAITS(<2fWnrskHWQ&o!XDp#N-y{+3oi_ z(y^u=#0*1O7|LkSxSO$d49?}%8co?d?m2QQ7 zT&Ae6Do1je>Akq1X?S?0RX@x?SiuDg_5yJuL+h;ptDi^MXn0=195GeS+XI@Z#r|nF z^{LSPwNnI-Y*VjhlsP69jtMXGG%(n&q9W$swoF^B-DUtIU38 zxcWK7Z|5rgCp|=9rH9m&4@326@wfQvgAkj$?`bqOgbeCSqj`~6!(i;=On$WFt}1hS zyPg#1W^%FdL!$zRA@}`BXvq~qW*#eudMbT$W<%WjSYGBH86CFUU`xZKz+u~CUhN{G zKlC^|wWEFMll;Z{pXl3H!2WTg;5=%g8@jGqyR{OdnmsAPVf>d!kXNjFhZ0nG_Jz=s z&l1sj5eA&clB^yrA%@Lm)7YCCKUEYW1^E5&4pwhJ86C6II7(I_`r81Wal+6 zt8nNUl(TI;p%O6dtDXod$807eOf65Yecv-+)%iQ1&Jhk`dl8CD*7VU3Af2*pzY?9~ z61V@Vjk?)i457Ef_aIJj6j*fzoir!6xqMs(8tuheqFAn5O`%M(4JL=ZBXSO!b2<9HMs8T2=*Kw&n1tXXi}zgiHM9amWY6 z#|{Khn?mgbrei7s(HzE#6Hc<8vFGJab-bL2jU;*X{1a|<$rEk+_T=mVezJ`g5o<>K zX?`#h^Ku+!oY6G8cXCl&MY$(M6d`QyLXQns-=V^&8?%`(*;A=WO?%qKQa_DJ*G|m% zfkBN%Jj?-)pCW*)+O4%)~8jDw>|kzzdQ9`AlPvR#*rsa! zKNT3dFPe20k@}yG>>4h@EL?WNNeSa7D~~cTed9?=@TV%vdxEbQ$BWlp5VEF!Oa{L5 zn*_^#z~hyMY6l+AC9p{Dr9l4gXL{PauY)|9BFCL{ zcHUwlQIG@&_0KQ9$6xhk90SAbOfH^utY6{>6F>wp(AK!htd^2XCvTUVabRH*@l<>4 zTC*NDX4i1`p8B-nER|@6ks)2Aca5;X-O9 zrvdQRYG>l$71J2GRV&hJ5-yn2Bf7>pi^|Zh(sbVs1XyCwA>OlbKx@hadgc@-E{LUx zr@AC$p8Kouo&Trcr1rDy?421{-46*aBtE@HJ|lwEIf;}pBPPERV!t4o5ituhlO3`% zoyzP8KhST?f8yKZ4S9VyXS|-OGsojh4*tn5mL_>evWPHo-A=-&84e7d>zKUkWfe&H zMMp3@@G4;`BnDF8Wlc(9=4pXqZF`a`(8+1Mn;)$-gjS+Y=K}YL6=uy5dCV{P`(as~ zaq+3hdv4m~ZQM?9F6E~{^`&#ij{Bd)2OByr)TjIc>bK;jbY=}!UIC*=z3QRWNyVPl zroFYR5HQXEM&rVE<>!hdi!ixm{oe%DRZ5Xc*^&hED!Lq~&bAFyet8J7Fr65F@niq@ zZ(PWb6leP2E+D*sP2Ww%bRws?Cn>t!RMEc@q~!Qb>3Xb^xceh`aCec#_$(Bx95YR8 zNx-aXG>_&O&EAcKN?0gN((+yd)0QU}`A=Np0SxV7vy25PbLaYcXz7NwXyPQgd?+Lw z(|7#vN_3xrw;pkRSvcy}d*u9tWBm>~_eOqekR3e&N}98DuD_f54myQ5%i2?z;DU(K z&ww?YUJxyKJ=UA`c@*fat#X!|#Pmu$jLTw?zwR-=;G>#f8f z=OcU#<_qq_)nkSo#2rq1snC4|!10>e6jp+)*gv4Q|I6coR* z`vHQP&tv<3v&MN{>6uW`QQd+&12HnU2o;xdRXDD2p~b2LwLlEHYOOT<_qHmB$GzbT zj(wkjtLqI20&j5VIg^ytZ|iMhYw9l>xH$w9!W+Qlhoicx%07CWW!|;r&iO2FUewri zHxtvJjz}g4WM)IxlKOBY5A6apI-c5(-fG$%Akxk$xVfde(Qy_LFA5LOrDyg##8dy2 z(*JjS;Nt8Ez5bIYB|=Wu=wWdzW;z>z(`iRDGpC&O#SV9KFId~F-;}v&^O+N%uDD`z z!#lQSZ%}=<^)LrtGNB)!`fLy|BX+*91<^9i=fic{gGM*XYg zFTg#54mlvGYncEc+5>m*{fHkb%vQNr$p0 z*;F_%YR-cdbMkJGefRP$)v6A8oegr)um&!W#p`I}7rt|es) zCOH-=?$!PM$WuwCeys)k-`s_6>KmWmV;+jl!E%M6W#C0+#pr#fP4XqZc_aw)){oPu zpK+#rH#%vYK`Q5CD$V?!kgmYY%4C~cxVqIWW)}!}0 zx)}Wx6IQX97T~OtYrLGtwC6jKoe-c%bv8oq3GKM|hb<$D09Y2~t_r>KF9`egat|Vd z#|tt}@gl7a@hTe@@P3bF`IlP5dzBA$@?~a!i69qv_8J1|JB6LighmBU*SNDaBFI!% zqR~p0rkGk`hF#@0G8qn^b)DG3tG?2EnL7VseMd9si$_`b5e69z;?Nn=^8(=Lccfve z0g)p+16tU&ee%U4`=RQ&tjsHNnw3js(e6Qq$|rO1=hh1tcI7JCBM#|K;CBKsS zU^#eGtlYRr1_~fu_P|>vy9>d4C&Ss z_k#O@zZ=lL$Fd62KY7kJ_6d@!UauyTtmRBC#{2P# z14)aa6tYRbVD%BxOqCjF(KUVLj@e11+9G3Q{*uP;FjkK0^I6en+G6^7d#ZD{jdvd< zlJfhFG_eD;MmK~&;Y^X3iP|y40UgcEJ-m%c5kJ>4_UaRJwMH9*^M1vj0U^pN%BCK} zN-CPx?%#Tp_x1w+&_0uv0+pJ`7^7XELJYm{wQbwxiNElb)>(OSM{Ne%w;EMMfAd$` z4<4Yj_KOz^a>5ZdV1v`WMznDf%bM4(^Q@-T&NCv4b7If7ueH#iTOCC5x)80I^jg4V zg*c4|A1F(Pv}JkuUqEMmFQFh>gU#r%B+*2pzR^z}qp9BJyf;LZb_k_U4K%gDjrYjy z;tczTqTnm7>WQpMOtQI`8{NRf?x9x2giSf=^-;v;qWvhgc7LVTrbu;`d`kz22tl*89pV7om=EdOuYs#t6)&4L zLp#Jzc@xS!fgxR0*oFZcZ7$t7+S#rCHA-~@t!!{Rr_WfK``ih)!{O(B<`(FFXpYht z;Rp1m(68Mgzg%OBsl9LhrG1so++ySBg$fUNbDjo^EE?(Rppdtf&-Qq8GAApOTH>s` z{w;m|`bGIqv`Jrt1mO5Y9xjHwOQYo};AX@pbV|tjqm{^7Z9DvfZKIV!5gN+k_}X{% zH^(C85Orf7N#Q2~s9IqERBg&m9_dL~gYxhm2(%=0EthJ&*?c6o{JJTJanQB$6IX~H zIndIGX4lbssx({Ni%Z#jLXLGKS$s{q`I*j^gPY6Q<0hIqY-U(J>OQaGe;MXiJRC;$ z@s2C^<1;&Q7(V4qn(1-7=>e4o9G{=227^8|ffk%EabySj{I99y1?|Ij72F94L1Cmk zRBXr+8rXJq9LZA)qlVv4|+mE>!pucQ##w(d(P9_n!6hS zL;BflnYb8ybjrh%VIk9cspH^8Y#UZtapl;pEk=^{<@H10fh~C8U5aR>R?H(@%XEjYpwjYIw(BuUkeVg^L%YQIOVPP}~2-CBB486)G`3z_i8d9Vx=sX!k}R-kD`K^4)orx{J-N z4T?{Iz5Ue_daB2%B~MD$hv}C*t)3mP7`7>QWH+rCh5jhRN~+P_egpvlhwu{~=3tH8UWOm}LI+j7Fnoq#q}nsZ$RTZ$ z`3!!$q2aC!MX{6=pm_8 zi8bS1w$EFI_1cnZ)SP8b)3icOxZENmZrg4E6t?5&9$}R*h6h5Y6|Lz-;cxsk;NS9? zuEsvzRdO>7XhuZ=Hs}M<<_xG|YP`}q;E@=B*yb5BW2j7vrRMA2ewLYHxMEK)f)1`E z+0lCylawfmvnq2?Xy0F`N<}PDb5Q0)o$*TfN+Q1RiYDO^CU=+ZWXJ5_!S12#oH2{xXDk(U7X&~`v5Qv!C+_R0J&6$ zQ7qmqSNrq_#+@2=ZpG>pP7x^s%#V=X{maA@i8=|YZVWb*MsGPUhX*%K_f zihG6-htLse6tA|jt`;`Q8cRrapNgczr<&F1svX~I-^PRCV})=kV-}@{8OE*lI2ay_du$Cxxud{nRNGv%czHc@rFKh zuvfKX%N$_B-|9WSKN1Aw7iRqEGe^7+GgO9MLZXX)I`+DQ;{@x4v)q@f3}zElXVH^V zhW=rOLV*vlL_0vM#I9b$65MC%hc;t$md%hKz<|`XJkr!rgqvYI!HZR7lWcfO;k9T)!RJ&|y=-YZ^I!CGZ)^RlEn~uLovMB0b%H zy3U|P=JqkJJ6WXV3cmO$0(&^=*-Yw*Y95auN*FW$8VATPR=r=BWsAQ^G&$z6JI*WE zU^7uWQuMn+Ffmh6hev0;9{$dbA&n*)w|G|hHp;_)N2CVpGQ9(C_Cl!wHeU3 z!M*k9=B)41An(9*A@A;38sJ8?Gp5z}20-Kf5?`rVCp0X61~yj5ZjZ_saV{fuen|<8@=FO;veTu6NP~pCTjD zAHI9^B5yPN$$jI)sDe^|BF$VOIwGWM=Z7#TEs0$+PIq-#hL z$wIJUi7rU3bZ6|F@UKpKGrb?-p&En^357Iw>vudxiG7tj>Kulfg_Q(OcBCQWn00WO zr<%ilNT}mYF){IBYMF$X;bXcD%tWCmYC6fWe)=`gwc=9$83f)b3s}g$BUny2vV)h! z%LVh6)18JFDXG5zm`VTdR*|fy!Ak!&Fq^^7zXu)Et`T((NaU_KksYloYw6VR(6`AW zRb===mM&c6q~b6wOApCx>}?f+^EvWoo{4x!R{E)_fW!?v9zq%#+wROyo>H;Hx7BwP zq!bAxR#)wi9l`BuRe|@YZ=XD2$y>o$h?0uFWh{x3nHhL!WNW;_L`#sjkoWomkMUvX zf`G%PPENFuPek|?3-@Z@ZD+;`z)`jH4-kV7CZasPIpdlV9q+ z*uh364l(UX)(MtOmN|ZLxJI zZJ%vahx5~mZ!I#Njn}_zHkev4!bk!E4v#{6x+E)luTBPc{&_r+xgm>;V6 zy1@zFliEAbC;p-Py5-QhMhLFv^ofDgU#X<`%}8P92al26spa<^kte=#Kypq~Hzz1o zNrmf;8u6mL)^tT~kxAP$nZ^vRiqmhIIFht@UC7b_5H0@UkZ;vN-<@y1LVvM790}o9 z>X~?qf>}s6PSA9{HLrg^1G>*9$dxCQbVv|&?u1>E*fO=CKPO=ljl#KbW)WwgxYIww zuXyPD5xyb7p{epfUEJ!$LneP-suk)>G>LDp(!&INDMEOx-y^*YAd+L(O~MRU=$SvE zkl4Yd=?j~31 z)@JGnYuQS66W70}V^wsQUV7#T!Djs6N0N#%=8kyZzU~VL%?z!+duQCe?pDwqhMMtV z-3kyvYK?wQx;W7u`Q#mW(Yv3}YVmk1v=`Z>SusIX0Vj%^MsrN;4*Ir9=aV~0+a@i< z5s#f$fUt03a`SR;M5Z_!#G#Y&5@!9W<65ifE)@ytNmid&i#>lGvCt)Qcto{T$W_)r zRjai!_vBn+(y6AY_gf-=VZPET;=6XL_oi6W*c9zF_pHaRCQyG=${Zi@)-zS8D#g>X zrpf9km@myz3C8;s_MXm zQiN@~E7nH~6xbM8XHSqth4K#%T))s2(0pcL;63e!C9=|$BT*5GJCjuTO$R(6 zX!G-;H*G5**LU02zqN#i4H9Y}Hby>GPcQ;KcPmfOA7kR&q^$?SjHV*@oKrNuffM1v z8F1nXaRIelimuR#zcxYL^4)qm2k#urqP|bS_WxN^9Z7N zlLx2Kg{D1_8RpT2=m;<$T_LXx&oc?8YAkRgQ{AVwa<6VHfsarkvm!`l zl3Mek#KQBxbx)-&R@u`#i`|yxx4xVOx|mj2iZRe@)>Vw@zIMmtK2>Q#QM?MpI1AQ_ zVu$p!zZcTm?29i0`Kb@E?fwALfEBABj&F=WuYdc-GTg;VKgXJpt%^4~0$_bn%tze}3I=57aRZumskrs-_FA~8^xXUV2W0k1XJF8{yS zAP%8>5W_Es2R^SVddD|x>w?jW6sXKuQ+&7}Lc6Bsw{Ik=(4@?FWmOp92~1E+_x4}4 znBE^|$Y`l*vS}i*Z5qwh4DBt8Gh+3KX!Y7oxMG{b<^6ASsvD-vYP>DnRJ+wv$-3vZ zp0|EPCMR9y*&|zWj?& z>3v)Y`(gW;CQbD;_*6v`tCGP|ka2(1aX&8CBLc+v_AwYQ?N2xBfOWUVkNJ};N^o9L82P{P*g{8Q4#ghYtK+pyPys2 zMJaMSWtkNrrk*<;IpDBt3IEx2iy9#+Ej9NdYA65kChG_aLCWwVci6I5NFS}|3^GP< z)|(>SGgvwcU%Sf5=E2KbqNw zbB-rTBpXi^L<>w$FHE+)n>tj?lDmxdLi)|eOWzT`0YfwojoMK&w`*nZ4J^cDp$X#U z9^7(NR;VRHPR4}2kl1#1>I_pHCEc7Yy4dS7d_;_j;yReyVQ*d~p~ zC{=cV78c~q;KS7L7$tOG+g?MjKJGp#i28Zg`vvN5|ARS;q*E#pg*j2$e$qG`X_@Tj zoi1cjp3RgRScF1C*Rt17aKBp;nAjYNeKtRd!T#6_=G~KeY%1vvuQ<}2`lGZTbre_G zq@;STzn<6#4`W^D``?k1kr8oF)lql;46D_3Ljrt+Gbuy+C6ur$ttch&=p)l?u{yq> zz~;!hUOdZ-JPwcbQeF4M5%VSy!YfwTwZ#A7wwqc5VTNUK?|hZk?llYZjDMibZ6jGR z-^!y1F_xw~wOqg6`Lc-a<_1<$%THV#r%|`1%mLrVn>fV=785ov4MZWSA)_AeKKMybE!4?t#;#{MsUa%QGfXz}2IL1fcV!l<7<&&%ZPttO zEXi|PgX0`m9?mTliO^j`zIe3BOgT@m^`vNDI^EQ2AnbrmhjzJ6_j+vXr*0gSJpfTU zGgo@(*1}6^za|pF1ou^y(t2FfagWSxtEjz_uFuxXgv)B1X3l_z6o0xB7l{8dH0m6D zR?;38c4y&rpZ(u^^yC4CAvW(q+zE@jxJvCOd=kkE)87$Yj{8465=_cJumKSTaii-vQ=jS1WDPu&ays(o?iOV)=(JfI=wESiYL=C|PU3_>N^Vi^ zl(8)H;mS!WE0#VpvS_$SGnjHd(tL6WkSHd1u6lK}k6N1EpoCA1Y{A3k|DN04$UP;u z?{a2LP;e><2fg141_2}&Rix>1_Tn1?TN|v;o-QuUH-#h*dyO^#w^pUi(|E4KI{i5p zKT0&wpk$g1ULGChAmFe+t%mB@um?j475?a$8;&Yz>Y%xBEBHkM0o}$^%YqEbAqd1~ zzEq}KzignIT-Bf@3RY~IxK_QB5poNXwl>f=?>k4nA7D{NI%=XQ?nzP8sIt&ML4p58 z8oI?*^qyPyDN)3}b{pw7z?T2V6Z{#sz%sQTTjgSNMXXNR+hGY-e-t3)pwjHCFORRY znAO$|6mqR>a?HZi{d7#gd$)nhfNGc_=neVxaTO7R?e-|`C_eZn;!<*PV6;D?)Qek5 zrn}GwHvvv31#+0GbZYqCgE(9n^c*0;D^YHr&tqO&pv3s0fb^Um^y(D94B8~)Eh4Eu<)}7VF8>2g0GSe6?l*E z2FVnzwjX}r-N~CPlyqK{@8uDUDqid78hP-HOGI`q!_O3O?biT z{9ts@~ zbX`fSJ)ddyJIswR!Yx!_1^mL%lY<{9|hgNzkR<(TVw}{Kh}~wWAZ7Fy{sNOz|V=az0y`?57P7 zo088p{1ks8=~g3un=?8um{jJl#QDPUVv}G&G^SRA)h=yGohy5)s;wH`AhHLoj?UvT zKYFqJL;#*NA>2HUA$#MV`>><}5}CDr$vXw=oAJha2!Ru3^==*nU-n8r7P66(h(G4K z|E70?_&X~s%h|5TMsV%3Z@4{u=dXmg^N5RdCi2ZTp zAcZQFb7eofO|^C#aHPYBOm5_49stefFR<(~iUsJoGOTGnx<)=Xne?imDK%XDpzlo= z^lI$LUm?i2Cf0WM>eS6+4zY#dJaJVn5S9bRpfQzIYJWllm0;@E^DQ*&(6B z`RnoBWL#=CkMTS!)m9q*#Nt==d3#`{(^K`DQxHN;)n8bqkH+sJP-R-Q2uPS}|HqSK zT;6M)R6uY0{M;OwetjI@b+Ms?bwCHs8AM(o+SEhc`+~pFUSDq5y^Oit%UL&=XrV^D z&@PcPwk!k6;cAvvtNw7~qoa{BAFLL%q9j?qD%F_L`n54Nx#f8WtMz?A+kt_UuS&nQ z5e{%V7A!~{Dqq?zVEqv{V;TD0Gd|f^UbchgTS-+b*9Xbi5NmUsY3h)G zU9W$VpC*{J^0^fm(MSwnbznac=;s#6=TXCyB5}tbV$BsnUG7@~-Cx+!|JeR(EWR6a zQ@BUC8rHBgJ1MWjxsvq=KEg_^n!)+cJF2&Xiq$EXi6M3VsZYtnEWYOTHA!C`=}IkS z-NOwDC&l(n^&Um5Q%iASRlyrM;p*}Mrr<5XBRfnWUFBaZqD>d?v&D9J!I#Hr`~fBw z8}CT&t(Qbu%oe@5>tEJP`mOgTH;U!62vfB;j9FnV;9q&iXO_TfSNM_O z@G12ddB8glrh?MW~DzNgJyTu1ww+GNef|saCKFQ7l*EEQ28Z3OBNrNh7`__ z2aEw3xbx_WlpEdj{4d#mC)S?LZorNXn$-*sm#<&A2Np}#^z7itUY}29tu$W%x{8hx zQ`nnHVs6fMUx{XZ-WlQH^?L9gRD()2x_eGpvemeZ)Jm5GO+GY}W>7+Tlplo1kNGBEHX~q`t81J1T08*Z#zkH-wm$s(nXLcu2a~I7-MxZ1A z?A7y^JVx9vWqxEB)>TG?Z^%7($5QLv+_G<3W&rn;;Kpg3HVs}e^CLk$#hQw~oq|Ie z1ClUB{1zI*j#%0rP^bp~$GMeDlCB{NKp<^j{I#EDX3Yn>VmE9+N(de?VScpApbXlX zNO_*CQgYu#=0x^(t4BCyHBNdGiiyj)f&D9sQkPh^o!4SELFU=a?W0IHeKxK9pyp&z z|4;3wbD_mRkQH_`4p6M#$aC`wlhr|4EUq`)OYhq7^y)l~!E@oni(7vuzCy&XuIGAj zDxBcXJ=#s6nDDW*gpwGaNii?@WozGLu|@}%piSA3qt$QI&1-kSc?Jtchetgw`L4_<0=sHTaP;$3fjiT(I%HvH1(A)84D zEFlcQMQ^$dsB_^aD69yU;TBIyMGcHtzB@>JFo%0`sHycUfTQv;9-c6Km)0 zSXi+BO~$_WriXk|OZ5lzJ<3RM@le#f0T?#!)NoMp`#D47f{;TM@_Ll)2USzVAJ;Y1 zME>ei2;uoAPvD1lhHZM*uj`+(_VkTPJA|xECwLu(IvlwTdJ~O6F0VG*-k^pXUEFsP zd*&~y`%g}+QoY5$x_N>DedSADb!Gb<3zQA7Cz!5(>kqDwC$WK+RGMpIA+h@OXOP8P zvDd1m7Qz>NDBvpMwz)M{&k_ut7xjB&CaRZeRpAi?-Xn-mRyL8~p&_qP6JbQu`oEO9 zx*E$qis4oh?E2e*7*+??YF61#p0{Vgu4JQ=Wty6+ErT#V3v@*zibFnUz7V`({`z#7 zVS*mQQctbAQva(=kU7=oPs{}QVEjx4F}k4u{t_ey#0fPgH?HITysvN6NC4>%vC+`? zCvPYdsU&_&N(AY0pYZ=NRA?3Jf>t52k=#m2#mM>y|M6qO58m;cO)%768m!dZQ;m&lK{qZ4O(WTlrJ*b=f;^eM78|37#>l#;?}x->Hpp#>*n-v($FmT53Ofn`2w3mO++L^+aN_WSN!TN43W1iK|5#Imh zBqI5J2gI$2sY}o#acJGx1g#P9{1ED+6Hj>l`~x#j&&yuq+FpDk4;B%7v`o94$?$KS zw?wP=qM=mgPtOrPOj`57rZQd6GQ&I7*I)$NRcx?K_sH{l2bj#z_5_#82>(k?Cb-eDrxogkTHWn62%Adcz_11-mKUf3v%5Irl*8b!cM_< z7%y+UL#+9gr!>(+(77~mLDP)&98&mh(?!hg7lu8rS0>k7J;ux)Jxu2)8+4mek31^V|jRk;Joqjh|+*FfaU=J&~0I3 z3-pM`1Sjggfa)Dif8T^*B7H1+!~cyL+kn~&mR*?tN{^9n@dY{Uor$yx`GQ75dK!+aRLrMf zhXqJ_+_1yk0wbxEtw-Q%oy>O6zmq8SmIbZMzil<7M*#0nbVN?xz?=U2b==+uirGZU z{V17I!qxkbrYZjw#%|nMSLCoPY7}TDzsF(oQ0UQrbxY{C95(Tv-mMpKqNAi>J_eY{efbflQ<+X{J|x$2cN4Pnn~$hrdP`o%{aB37bF zce!usc~_ZA{A-CGFnz^$#XG0Hj1QudYPGd|j_B>$T-lP2nB1-utTIivK?jX^*2`fQ z#j#75|C=&_%RP6sZ%{+SfqKHO6iciNd>o92 zEh0ROt7iMtb0oDJB`4|_S9zp73{(!2E$4@16m zrKCA>qj~)(5Ss~(wh3J5Poh4pGZEP*QO)lKhDK={y#L?~<)_LK@O=NN359R8NK@#) z)L*$4(_^$(w6f@(zIoHZXXyoh^&Nw~B{UBP{;u|K9uH=k2Q3?>0Mt_dyN1}om8b66 zeQZ8>l^gn@Jof$w%NDtO7ytIcLlX#)0QJc4sd7AsdR+Ty(qzQ0G_+f zS)^m5GYZJw#Bk)9{ZB4=s-pKwwZu~0I3t^mzup@l6Q|n%l0pQOqhDZbkl`@5bk7tC z1jF<%=q!p?(QY>N|zwk9EQL3)WU#fy)Ue+mc;$n~vHTbQ&kp#11QP*f$C>L94%rLl5v?}&f^O`2Qw z?)+KvX;?DoU)mnpv@(1N+g1`sIJeOjjf!D~m*>+l0rkP<2g`bx#>A}HFO-mJtxRG3 zosZcIiL-Qu&bsgyXIU+{-RMaisSAUa75X6_-DzM~iCl9H}9Rw-qJ(!++=*r=`jq zB2xZvO?uA-uc@HwcU_P>=lh4?nL;tN1WwGB~)7w8QwyFN*kdC+Y!2SVs zz`nvf?Mf)Sb1t?>7x;eG2eY~TQB?@c)_K+eIdC1NuDGBDKeKk8(!ov0X)8?-LX9la z{a9VKLTvw%i6gP5I)>__7kyfq8f!^X!SkitzS}N_^r>Mjh@@WBuVBisyuyDI@+FJi z5W4u-oq;L7aTP%OU1Fd*=T3JG7@Rh;C?_@0jG%Lcu5Y{*)Fvo_B&=dHxwS8m30Q~d zjig-j;=}mee*|t2Ywo8N#6i#|FN}B63-C`+1r!SKiJ_7ADdS?VJw-p1F>6VDYmoX; z8EwS8&s(32y9C;|YUnX$)8bu^|CZ~C%P=d#yLy|Dl$Q`*bFvm(U~qz5QAx%LbJt;* z$Djq`m`<0=5%Lz(2DmsV+SLYF7ADgfDVL!sTxcMj<_GRMh3KA z>GH#vne>w_x^@}DtA(Th_2#34*m!lcW`j-Aef&=p?_URG->1+sZuwOa?c)?vq1}8NaXg{+74BbkQz~6Cx}N7&M*nXfk!f`VT`?%PWaSHmy%%C4kn=zpltn8BtG22cSfOe4Z05 zSv)NvcOM7nE|pLMo)|yoC`6{_@iSZbzyKD zS+@(Vw)bozB?Flvv#ucc$sm%_ebZ}PW=v6BZ@x4pX-4|;Gy7M~2Posp+nxAvf67;0 zUjOUlilT+J-ps&T3;F*yM;P5ukP2PR>A9O565oQdTWD2nJBG%vbC=GU3KbqKv%5W{ ze$CSOLj|Y6dQ*d{7|5`^IgE41Ka2{pjWW4qtQ{CipzN(Mq zPNTGE__?u=!9Y3uCn<=OcbooqBYYQJI@b~^w)eTrPixc^kh2T8OrZmNJPD`sH!gu1 zS$wW7wGY1hZwizhbJ@hh$#PH^G?>-vpl-@E{sNi&rVfoucQN*kXufLB3(=p7hUnN0 zOyz>J)icSHkbVxM-JOxoXG5w<5#rF8xDk4zf6$|Zo%Q`q^zccLdYK}?Tgs+aRxM^W z1vge11TM46wNdKwu8rP#6+eoQ3-{Q5>f#QT%Dbo|K3I)zSm7;>UJ(nNTLO4xh2hXAIcPwnngLSWQ{gKtYj6Tsb{@E`pGF0B(?^T*; zQ)^$^o@OEx&FZh(v|`*y`u7jWx~U5w{3n>b(s-}i-b@>qM|KCqZF|f$(_i%7TYZ)G zn=#Oih(e&qUG#6`itqHcnsDJyfh!qQz@F5~b$AZGK&cIVDVfSfnPQ)fz6io!)q|w9 zl%4Z|E1t3TI2^DiY>rc(fAF+>FdClz3}e2kkIy-ZDkY4Q0e=-G%GS06A6}9W&B9%x zOK%iSd9o{i&7T6f?XgT0YwgznXT0t3U3JTewOFogkNuUbp>*2Hpm*GT+*A$zH_-Ie z;wgg#pdj*G`q8YT=KFYeG)-cO3oTDPx1P4}sQ_P-J>h)J9A`2L`GU`tGFF2^c89`p z#ijGUJWHPzAo7`Zh6w?8l|Jl>g>&e%8=zU6BKXo8VjxohU*KA#jA&DJeT$f{`&yG8 zUwi>D+Iq3|(+p2-;$UIe-l-(9{{iS=0qar975sEM6p?DxcwZ9F1IsQpsNwtl2)oCJ z%`B+EkQ{F*ZzT#9RxFoH=rulIX1^fXAd@zDgZS5H4urlMq`r~4sQQ6vVerM#6!e>? z|7ew|uyteFuuL=23fZtR-*aqxdVh^-sGYWYNLQQi>F3$e8|uHN!+D$>80lMvQO!Y* zn<)BbO2A{7y0JX&NyE>G+~F2+?gtX@QwpJyyKPm}GsNyw=6<9MJC`C!3rA#hM>XaNXs6wn^z>S?Tr8zX)x~($e?DWFZ!&FQo}xMR zQtNi!GfSV%&h4uUoCn!f|FAOdiPg|{sXL!Y4NJH}Fs3}99|J>qoa1az7P zJg021X^mj4arQTN>M_u(WU@~qvWz5Njaigh-`4+r!wtBrh)biYX!(5Upg}(qHBwZq zh2B6lHyzGa26RJBuHgGFM zQ){zDhG{R_l<#P5kmEP?@(T4@{W1S9Y$AphVFuD;dmpp2?VBA#RwB1|$7gu)P~ZCV zd9l&RAaAB5W~=e!B^pGmcWAHvFtC(>OT*G<@^kv!VSBaZU+Pe)GglU4`Qa=^zAuWp zWjB|{jO~~MN`-Aemqdp>PY3?zhJ#0Eg0wBbHe0RtQ6N{1S7!g_GW7MZJ`1r6N5O?$ zWY_%Mwn09aOx2hPDTq#gpE)~VtV`lxy<+1I{n!Cgkf@>9(=WkEZYk@Q0kqBF9w?JH>V0?O;ZH8==Y zfHKmZ8?#)V^v`E1Z9VV9bFfWMalbqNn_Az~r(2y)SoPjKj(4ew08j&1_>m$Nb*BkjtdR7cPkXpFafh0SxBK^V@dMjnm-?g0 zAEc(cj>w>wd{bpu*}_J#fg@=ZNV@0(+q)P9+oU*#yjDh1Xf{&J<{S3a?^xc~IvqJ$ z*$_h_)3&=g*-kxRmRBD(b}gi1qB83P;owuEinho|z$s79dI!k(-#<8OrL@nGgeDOd zxGFJV%2+%e?1T)jSR89_HqOZqb?cbrb;Rzhj5M`!e|gVGIWBn3?WKR|Fscx+yMWZc z^w27kDx3GTe~H5+k{ZS#mW@5Q18O|`dW+gZx;=adsB&OcG@^RUHKlR! z7{nyQ3W%D;_5<7ExcoF3*)e9x8K2LwpZ``of--I z5MXcu{yDKigAu+jeh^8;u)BLnRAOj~`1`qz8Hy#tz?NG+g}=lY=3q|-dAF(B{N_S` z^Z6k0*?zZxWNc}yn8mvCa{T@W^B6#rG*kIP4`0O|ZgGmO$oTsad>19>@rQ-~ZSQ#R zceQMq;A9$awfwX`enTz6dyjbFWp8g);8w6(Q34vtI@-FH~(){vu5T`M%aDHWyv1R zq-ai4*I8bV48gVYVTZqo1C~{K;*c@Qw@SgOI*h+1zD~fi4`hGgY(A8t1PBTCuggz! zueT1Qb`fK@Pr_b$#f@Noia-14FzFy`Xy3(LwKJ8F`>OSu`{(;R>#$ik*1InnW#?*} z>Q!FRcOca}!b-`32%npME%7bfxsA z#r`q_t|7?Mu}aK^%R|VLRA1?gtn6izrP)cylV1FrlD(E~;OQ}##Q|4!9N@RpchOQU z=F`+@6++6K3VUVSahKUdvzQn4e!$AL!7f%|*;V&%d8*at69Fgo%_N)8phB_FldRH2 zbg@htp}BUJ><07!?IT*W0?p>7Thfl(@{NoTevk-JYS?q=7XIx!>2&7#IS%aacc!0P z_FfQA0&()+mG#2GGSmPIR;r!Yx@+ThsBJ(jG6KS@W{`7gSxl3AF9U;ZB?x zGnMmZC{8sSn@63FPDX^zF?1kq##W7U>h+uc3Z2g~_6;3F{jQg9Q+1|HiL?^iXv$(@ z{@dD@m53a5{1T)CV?F9yk(ODnz%G4_97y6ST1PrcjqUgT2e1UE=VUMBh5qSBtyOrZ zoCXE$ZYAm*#+v=_HCOUo4j9ta9!LwjASc{eA=nCcaLOs4$ZCc>&hHheepjM;;KSVT zil3ZobBGOc*%#Gq{)JF-NHR)1Z{Wa3RUgIXWlsDQl~#aWN?qT-=O1NJ)w{qS^jj|^ zQ?b9ob%>G1r8M6sKx7=EsL_!g17RCyj`^a=Bh)J(oB#p2SM=feEbR(|w! zI(cS^S5`GM4lwBK>W|OZ4URe7v84X{GIIAYsfC+@o*LNXww6%^RJ`n64P1d@i^n1ypQo@kS;(l4brm9q2tHi^A+l z>fi>vMf{pa#M6E*eoWiDcLZz70v3WkYhx@+oWzY}AJedYDO`=wk5GjIuL$ZysYqJC zHB(8QYBbB7dX&C$rx{10;*5gxbjX?Wrn}`A9`gs7^?F<@qhZ| z&@s*CgOJsp^@$cV=?LPxwVQ1Y7a9X*4c6u^^{dzT!EA1sWhLKHZ*ykG|*h%5=DH>fogTFN4UAuI4#-#&H zFPqz|B=w$L7CwT4St0?8=v`~%VgL$T!EhbcHI2*C~Rro)-WE#+-h=Tc10 z(Bp1RiDLIc#Kg9D)*txrPVF!k-b$D4O(}rdOfl*^S5vGTZBkE{|wGt4OWHyVG69aGHwEiA$-v!^h$Sukx+K5 zCUp7H060KyTiA5Ull*|_y=dZ>4~}1ctebvUXbk(K**H(ih;Npj2C;+@o5Qp30iPOr zSXop%2|l@Nl$+h^y4&U{4th@C(&9_Hp6jybKkh*Rh%uYn`rEPsPs{$2voble1c)1d zcg5C6XU@AdU3QBaNgc^9tx6^pzJyYTPvi-^+BdETQa4U2719;h;P`{(M}~`3HMuKQ z_jsxU=b(5Z`S_(M zg*tEX{1OeMR+q1NBZGq1FMNCphg7@Pb)BzBqj;H4dsa#)K|5J;ss6&B_fB#@np9NR zE`aI3(Ei-jV6fNR`>v%|e0lgUx)uVT_9MAIK{-JKEer2pU0s{YAf2-&!d;AV3wLUm4`tI+0fxuR{QQYzs`eN(=6?WZ0qdC3y;zpY>3RtQHPww@~Bd|Q@^STe$XN0(Q7>_(bNfwcIQBe#2GPBQIoxRYv-FEka-DFGB zNs<-pCS%xrCLHLjPG|cgOR~X_BkH;;mf~MkR44ObdPh!JF|jsV$=WMNNe%%y*A5-Z z+GaPx>c_9Y;W2WN*BfbOIOM>>M&PeVX@H)NvR4;4t&6`e{NRJm%j@n%HuF*E6*=&@6$(7wGPo%RLuUOr%9x3?S@p+n~?(UGT? zwvuhBLC|Kpeun+%LnZ=FwQtm*7G8M?u>;~=uGwZY$&yJ4DOZ0L{H{{@A(J?;HoK%0 zS3f7PO~0?;)@+u#&;d%zX-8d>tlF8)zAUNSxvNN^G3+bv;}=m_brWAD^OU{kqXvU? zBC)Dmk>ABT=N|qhDw%wwQ8i+agDfxS#*05^YAn(4&9k_Lx+gp8Spf?^lX=Rvv3h-{ z(2R5Z@J0}L&Hpu{<;{&#kB3j=m$!q>bmDMvCQ-h)wyKz_<`y_@Xxba7&G+t9e+ovE zj)R3i))te{HqaET>OKOXQFD0e0DgS6g1h7Dn2L-Ef7CJbr+c7`Mi5CDA|&^dmMP_f z43}|F>9Ziex@<8SPC4nMNCs9$k+q3&!(>yp>tCE zP->A>7J_gf6Fb@c44iPIl#ENSRq(>VSi75%wyVHtB)rP@q{LIZoWs@WmL2S&fW8exYQ-}AYYauvEdnMU-wvS0i&pY; zC4{rWN9rmPcT%%Ix>R5;c6y6nGz%;vS-P3@=*>68HJb((@NMq`2RV>XP1d$~u@=`o zL*cz4u_#0lR}`$7$9nLfi9`@l-22a8(Td7Hl^^VMgZ|m^>H}VzrGr0z6r;vV;~f>! zZ;X)u?Vl#5IU0$4p5IRMK!pf7Y{hUrChFQXyU|p+_a#4&tEdzvZk9 zLgv=)m^=wG=FnZLVOKOR-v|+W);Pj}8szmHX<+AA%-Loa5-gedC5Sevp!kyk5%|Ng z-+`ufu=9pija^HX-F=jJPrGaTiB6G%OG?8*KJc$m6V73akIzTHfEV7@UP6!DbaGI~ zvkd%h|4i>o%3X>{5`U}gU()hBAI+sk3~a5!X&=|z@b&A$u%wbRO|<*MR$|nRdl+&_ zdCr_M-!Il_Umxu-+Ay%jEBdD+8u~qt1?|-hFdV+ioqUnBz(all`)aVzy42|fRWj&! z$8Vs!QX$m4L&pe49(x%MtF)y1bdDT-!>4~_9VZFJn_LO`xi6R*8q2wdtCn*LnH({_ z@85!9n7`77Yx&?^#*JbP)sxJnFFOe`u&Y)@{-1q2-+lvqlrG*SKQ&jpzZUX{Lr0SA z(X}d9(H?Z9tE6}4j<8Y7N?nHVk~O-F!YCb%5iSS1wkDwa>mSrLc{$KBGXV{||4#Nqv3gKco<-H4= zUmY2;gPu5_B$Aid!qz+=zSA5t&NZ;KQ6n^Y2-;#-)^IULRyFOqohGp;@kY2J-y&i!hyKDW6(IlftTo- zNn=c#OJ>3NQ5g*fqOL>$McH^S_+)`5C^+n;wpD8#&bvY0J=|For9yodc1(F@8{=*_Yi36_OZY20S@~}K^lRk+QJ_D zkR5ZvCqG53eynb2E4`e40-S7;GxygVH~LL%#K(|XAG}tqK=sOP!^gv02j@NTFQvU1 zVPc=aMbzR6eYwvf1KV!{LW~wXj_nK~QaTVwm z{`JS_#6hI!0jJg(KJ}$JkF&znMvmSH$s#@BH?b(oW(x@9pWfJI+*1JJzb7cLT)UP5 z6kr-Q^vTNOAM1Bs62$y0j21crbzf;WA(_powP-m7aBRTV&H}^ zIQ~8abc6lg{&N|x=baT`z9ih~4gSe4ru?S&3fEAcKMH4wp~ov&6~(5yBP$iipY8d(6# z#8$4j|oSk~@Q+EM=uabmrh)aT$epjdist|r+! z@Wv$a&Uv1pa|z~DodH%eIGoc|el3@TTh@=!Z2}5LSlnXQ_c=ggsU`j!?6&TP%4%P| zb1!(9CoP~x)e@!$pS?9J0?bnUZiz6Ldn}j!cPD0cAGg8~230+>p))=YiF@);BXMQJ zf}Z{{?B3WTFy|djx?T&WM^Ng!kwq>`?3h29Yvg)S60qs+-Tht0H%SvU5*i$(87xHs zcFh*B(iO4Oi>)3nm%a&yWu#mszrscFDC_8pW+x6cUfv7TqH;NSCYZi9D87)@DRgAH zxMUcAW{bb!7)INP7>wceQQo`24Mr^4q9%`(WGX@kT%kMKirc}?6}=s)CUK2+rg&oE zcWJmZjL3M+3??w#B8L%(VUGkm!q%dpR{iu5z+xF!%hew4+Nf{2JKrVFCKOmqQjjqv zy4Tr`$h&QKT5)~u#L*>=Y&JX?c0r}uV8z#2$+-uos$Upopr^jR5I8tM)&EpBlK^VU z%yCQb3IWE8yOK>)t_Dk5E|*H%VBLKfGhM#28-O8w8^kUSyy!?kh{5J&fn%f59o$p z!-KQz-`*NGUM^x?(&AirldK|Xe25%hIVTU0HJ_#!s|A{gJP*0!5TT>vRDvcra#?nW zrtNclaUM4{vGhmUTeov9>W2QbfgSfz|I$a7Du#ATP-17f_+hhG>Q9sgx}VT_`urTu z{qj;S_pkiXD}k_ z(XoPDf$%faz=>XVrOi;umI+$)6~K!gdtne{vBd!|zVqAzv(@3i1u?N#n04kh-hEwn zY|AcEQH8ODID5O7fM=R;g6IAdMuX3HRmvn(hrkoQ)mN@BFB(6#WoZ1^F+d&lxy9 zr5}`Rac5myAvWMVF_@aJ--n1+jO#YPfTBQt{Zc|40TlYWb|90mv zC39lia?svOyX;LAR1h7#M|{E~ zzJaxCU>Vx-=O}(4OhxJD)%g>6XP?tOgzd=>!6=Fk!!`b*=pp@{8i)dH;R+Ct->JX6 z-g*ihu_b{v;#uwZrag4-yhV1u<{&i0X;~{}A)Ao^SYrsw1-c=_O?u{<02P8wh6k9x zkc^Wg?>*#i(DV+q)%tj*bsq^Uk^|i{AU;DI+wDz0JI;y7n5_`|-MB7@ zmk0A71ufDa_N~?uVAgpUpFW+pKMgK;pkNBF6^yApp0%rwKdVkX0#znYp4bPFL%&}l z0U&n|eC)St0jTk7OI6qCl`k>Vh8TlJB}@EjS9a6b(upJa4%2aP-MT$ll<6j?*eRc@ z@9O_3s&U>~_BVs;41(Uds2>LgFZ8Vb)KRBMo*iQOh3q$zzP5=*cBb?xtmWs48-YcS z$E#0&5Q+V0zfeWrx#01Xe(y$lCH>>ZRN3DG{Myj!g?2900eH6>x9esWX-Ur1RsL$5 z^V49ZXKy??PQX4_NBF0k0Ro>hlHZv;rPQJ-M=$)Ft=e?UXZj`8LHyjs{-e71mP?qU zHY{^(IB|Da!Un3-NW2wdgWyiD97qs9J`kU{bo<*lG#A3yYD*&q;bb;09Hw-Z*9TAx z)Rml&?r^v5H{Sx6-?-L8Oi!-!J@cHZaxR}Lweb!tV6hWl6>qc*Ml(T);TC}RU!R|J z5?GYTLxEaUpTBeS19R`UCtg^IXWjT=79g7N2_n!Ix*=W@=XfHHTAVL$fbUpsRrb7M z=69u4T$_{KufpuhFiW3OhE)C}4q_Hj_fz8#%4OrOsHjb~8a=Dx`qzl`|AYh9UZ4-X+%Vy-tCAA z*uA}OH^>X^B`_93;h4o4Ge7;nH@59xRg_L%)r><_a((p`CTqp#OpPRy0}W&q{%-@m zS)ZZXw+eQXJ6s<`jU%`-?%K^n{C>{WvX0J7hV(XQAgb9BeI|Y5psro zM~|%z{?Wwyrg35~pY-GW-D05gx_Fw0hk%$57V6Wt+*P4xU>->em?kTnf zJ1;-xTD$56#(I3(J*WgrDAK1Og; zTulhbBxNv-eAkclJY8ot>878-pOXdjZI`t=&sZ7Y0@>3{mgmxMqACxHxV3LdrBCk= zikB0}QC0R0B}zB?)OwlrwW#iv9|}8%Bx8Auk{poB!p~$W!7^bPA-?-G7h4h3t$BWF z32GTIl;&#i*z1@n6^5VOpjT-0OMUIH^i~?U#UN*;Ts}zky6yCa)XDCoAUYHycF6T;zPn*_iA{Khj?K~y#icb zi|4z4OrbygWoIsXJ#~IxA3?e~104@pzB)wVFJvFTFJo-zoHW~EYFd2fl|jj>qG+={ zHlJ#&B6*gqp2?UfN!i#Pp$(ivx!94p5b_rndShBTcBsW1^~Sq7;pvjK87yT^{Mtr6 z298t9Nc+1a(GNV2$J=(BRf>WBjFSpC>e%Fg8}Nn` z<$TdRUC-rRknRZlotKk`h`#)~m*C2J27B!&tvw$9El0zG-a*N)z#fg&kzfRt#@Aq5 zGJ6bFo~GH9SY%eR9OOBl}BIc2lR5`cn<%T>( z!6HgV%rpy&-l|#9{GR8ClSc=$Grd#Zy_858ZE6Po^kVtvGid(_`8R8v!8@CVy3G+l#b zR(e_NI}2-r6fH4eqJ$qh%F1_x zx(zaS1hJW7^>H^cg$(wfAvnjTNp-5F%~mP40gx$NEzk(|V#;%T-#6Imvo3H&{ zY8laI^~OeQsO zL()pJ@;o0y`IV=nQs+&D?zVfTSEwjS<*269-a#`5cjt`Btw|mZfm8{--i|Ua$T1tR zN*n0(;znAz4e~<(*e6^$FP!koi$Q@&ncHtAbAA0@sx}}vzUZMYCeO?#biLmWcLlII0Q-4eu&}}K;0J*DBXvGVU|>nFK9C-Wx#=}kJ)Qj*LluSpC^*=3X}J;rHF(N( z>ww;^ZH#K6o%HEHjsDpA#%wb*&=;A_upBz*Ox*ehi%xm}%&EjbY$_F=g4c5om+m zQfg{k#w7|Isp;D=EpYmj)fLefIg}6R%7z^a?u5I%?tz9Fv8_%9oB) zUZ6~>aK2@T`z4BQwFT_l#F@n+@JV$C_KQxhE3QQ&zt_&Vgq9CPj$vVg9*yHSe8Vn9 zX_4@tQ!v4Sm;1f6y{TJMmUfr-R4(ks_BSd@25Sc>$hktM$ZWw~5pd$t&cV}EkC41r zMqKHdenY58)`b?6Vy{0Md%Kja<|oTAEAZK_nG$Arz+UN!WAw&|qWqNSR8Vr9iBJ|F zG7cU_=oTw$F-aea?WbV&Momg6W9yaKTw9BnB0=hg~yPsTzyoHs*ehvtY zzs*-s;v?XH>EcIavN*2AxHmH7T0V0z7^$;b{QA?$jFwDK9O>5-WPWK{Nr)OCGuM*> zG~Uh=LQ&BV0-$;(!lOI>w#505Kq}GGCr|zJ%c;58>73W-t8%fw{_|r{&RYDyk zh{dylh4D^w78YmG`(7a2ueTo+P6Va13;}D8QIh6$iJTOt>9TU4V{&e=ivk}4fPw%vrX)q%c1@jP&*^beW$PB zp`)mgKj6V( zKEnvXTaVIxRC|Ygc@C^{^#%_zXWM|oZwA8XZ#bpDu;1*yy~8vwid7R(fvzV;>X)_y zrvZerJ-nx)pR=PzjS+ugG}lcyR6LB-rCb6Joh)$rmd!Ua8Qn)Q8Zu11)gdUq?6Y=E z?&Tcl+EPLI`{kozNinIWy#(sFgG+@tvj|K``s~&9jE)BG+kd&-N~wQ0zn{jZ5Ckah zK4skeJJJtV)@I807~}1p=00Og%Ba7u#E`$`OK{WZZQVYKZuW)SAwzvhGv1%(*|{G! zepBL^<$pUhm!GRz*5_a#8LW>>8zp}*{F~8n-~#a8hcs)owTWD$YwwbYTNz`$CEEIa zwW@aV3RC#1o!+d>4}N-I(hAFcZ=6Rq*~W@v0C|XBSdX{graIoBBH!=QIoe4eSSxd_ zz$A1CeG59oX)S)2fH8{q4cZBO#h{vUxour#m#c`~3jabY{O3Cr$%i&iSh@j0%XJgm zQX#a^GkOtl&F`ib9{D7k0%o!ES-Q0Xa*DCH90oz70QJxv;S@Ytpt1Ueoa zr=|ea4L3*dgGclTWia89H+*SqcS1`P@j#L|nIU)cj?KzaXW$PuN1QL z8FBxCOH^5jI6|a;yRCLH&BU;$|CtZE8#7Zle;H3V*j#Z6vh&Ljf~#FWv~2a+?<2`T z*l5?UO2)iE=DBefh7mTdQ_SjddSFVFZ}$}Bwr*H%u5E~6E6e4P&n{1%Ka9Os^oT0s z7Q%P{-G_%0%zW+yJXHH`==i$cy|C>Sf0uuz9@R@xD?RxI`IskbtQ)gCRD9DE zCrH8Br+PGJprt5xry~8&-{Ww%^;cLvYYQ9)^2!`pFB4QA!k~;cR%9yzJVVxjhsl6Q z)$=%kXGzOdW0_`X|8ke(-G!F6+Rk3hL7vBBRu0+2_^9GF{fq!2AND|gTWNRlAF0TC4DN;F3XWAJ8u)?I8n-Phy^+etvREGH z)QvWT_zSlxieP(5jJS3%#W;8qg9v)?O53n@4&29>W2C`qgZ!2IbTpis?Dt%UCjgb} z(Yc^0mqOJH-|N1rn>FpI5bm`KjctYoJwhgix9StlVIWf=5kNPxw{cr8wV4JW?sC0E znea|ifOx`2-ZO8+RoMUWsd@B%DrkQR9W7%=)xR_rtTT2z7qH@qzv}wt-n+>q!<8CU zIW1`El!uKowcv5`FpfUgQ#p)-ef9rf^4XR77Kr0SVIpPgix(>Mf#YKLn|<+UXkIbU z0Xi;$G)ZUUVSTBZI$Z;fejrhktt)21@ z_=m9Y1LUlm|zx33EyE*2_JnrGQZFoG$E(%pYFR3O9FbO5+X0lKL+>TvRil& z7I))`0j0hs&_wCs`zFnM@yra{Oa~&UqV@VHRgxM!1)MG3joW=yyo$v>O}_&V&O26j z)EBv3Qf=b}?dUxBMM7u5{!sFkun94X9)G15X!5&ZYDU@jIFY`g;wIuRS2cNpN39nS zOAh^-B*sR?)rQ>+N@~7r4(O;j+{RSi2St)D%p28d44++W$~0gYD>gOS_S-gPT-YB! zwmdHCtN3C~XlB3^?G!u|1Pq?fZRx((1QhNNYqL*8KiJNAL#I#kRN8SLo=(asU>j=# zPVWUe`0Z{{;_egC>?>;!Q=O`=^8kt#O{`BHvot7bOY0+ikubysK-cOkAxC{ZPIWNB zK?pf~>NHmlK!4ukNYG!x8+znF{3!iGx z##P*i({Ggss~%Y;_%zrKn*#CB z0ITmuPiWB*UmgNcIIzzLIxpH(*^bze6-)gbb1rD4l~!79y#lKF&!|!nDoEx~o_LWv zc;Xas;3wtP6{%z;C#AVq6{RO9YkWD3)PXSYdcWJG`8b#V!;;P?QN!cwLFPp>SI!}Z zU4oD1#_BCa9ma|j6mxwU?$_?ec-~Vcs9R9V+ zS98gVz|o{y?#R0NbsszyxhwX%q^L=OIj@1A@2!^aH9zUVB#6h4ZU*ADbUyENnNNfK z5A5NR(=o@MmaTL;6!grcExtk{%ta3or#uWGIS+e=c}ITC%rb?Lm3;zR~?3Sdz&T2>G$#*(;^o} zD!)raF&~+QfSOt@n_ZegRN868M2dUaqcPTOixpd>pr6vXH~1XV?@tiu@aAmg-awRo zH!?XYVbimbl`*sj4A$FwE_Z7JXZm#7&-0JX@6HFE_lg{2gz>3~M}-hq<94YrjoT{b=?@nDelW<}{}*)r(T1W~DTJ4wG(^4ZLi-01KAA#O z8n9dD zZPOeC*i-(pp9TV-h2m}8sXLGwx`2%uhFLC@E zA_3|frFAhZO$2{c7-GEkgg>=guD4iqet>{r-NvmywKJRxbNfplC!QJToPPmn&tl=1 zje4xhh9SS1(0$aVSXAkTY zR|9=@$GmpUBFr zx4M38^k#6w@klUzVa-_=Ah}CL10B9a=imZcN87#>=;>jcpqPkm3ZT;s`tl?z{w$xu zAP#-`{et`7vPHQUEt~WEI7B6D9=VZ~*yrQE>VPHG7rP(5_^EtFwC>HU=0~&B-Zzbt z6#u(wIRZf|j2Ss2(kin;I84@5^L%X=h&`CUti_ZPUcuhO3$c$;$r7#71SnD*#mBG2A@Np$t!F!pzt_MeiQ+Dm~lte-K4$FX*>`2ML6D11)g4l ze><%urgSiZvssJDmgk$xpnn57cQUviT1QnfecPtDV2rdEcQyCTsdB7_cnAZ&208T> z`W%o+zcx^mZru2q&%d3%LF3=iV%Gi%0?L7%B*sSA;}(90MZP`USkLydemY3N`{Bma zX<*mpHuH!ePv=It&*c>r=vTPEd~HB(K)l^Lvk=Au#9ugY;A>yV>TV#UkoQM%Nt9tb z`BSzc?*167U7c;^{RW;I1{%hvl``UjO+{>_>OuMod4UJNYQnOdAoRFJf zIpkykk_K=B{zyEqCiNz`8o=q>OUXj_|KaHG{A&24Gl{fvyiAn6XIFu+@tpH4Cug~& z=;>Dp!`9`_ymDzxB{E+30?~QPB$YzaG+XipqP2T2})oCSU;Tf>f z!C%>{m-Otv@p+vyuJfN^KToEl`+tb^+ugZGW+H}lJowuV4OPT8If2txmxq+ zztb$&>$kB6^FOO6uVy+(ux2zlo$7wEng1 zYBPTK$^eCF&2<0-PJBj59Ut*S0LP!;aUJDO&a={}_MvL1F(_t;mj}ni+kkI9_$YgB z(-(pxdfD3FyAi=!Ue|^3Knd=CbZJx}ei3R-`{cktY*&lR3lPnJlq47Af)doYkM9GYapQn*cQj459fPkMfnY0-JW(m= zGfhu=hLEt!wtqfgT;%>nLioRCrJAUZz;Nv#yCFQ04?RoXF-t!ggae*glR(j5%=j^r zRv(=ecY_tJ-bQ|b3`2TtgB$*sxCr}m{`BzZ=h-CBO1V??pU#0AaaZ9RKwr07p{BKI zV)3nZ^G~GZ>E?YK_cdIzDSr3z%i1|F41%Nw+UR!vl~gh9Rv6|8lu6GE{Iz)q+XQ@) z7%!Ba^Y!Ys*C$X}<9^MZdK|$}b19)WsG(TWdHE9h?BxIo1B6E*o)9d@qp?LgZ`BQFV|LOseRA1;*ank5bpMzZ}|h!q}BF!-sG295SqZ{zT$1Y)BWVm-UIgH z4kS2lC#_gJ{nyyPqaR$DA{#bw;zC9yD`IpOa;sG4#brh=vv@L)URNx@K+?33dA@H{ zt%no>oUc|5IgyYZKR&?HyzY{!)Sw|^I_Y&n(*XC~W#cCqxUaoZt)-$F1Oys1v*HcnUhAZ8zz0^`oSQD*hi-_~EJ z0?cW=yR?0ox8LjXMVjfKo`^~<1u|HuIepJ)ytji>eQY~u#RLgICeig)1h18T zwN`e^!@G+`326;~AEIK61mFRnG-RJ9-p$t0V0z@*M(f4dFtFOZufancdgu1D|K*l) zVRg9^WDs{Ga6ze7{V45&iMIuL-*z9HzT|sT+)Ov(1(0C(N)uVi5FjJ~O=R8?a=rdJ~`MaA->->>#iy)I$uvdMf_PDSjzs%yolm*0r!P2n{M$D6SoYXJ=tg?!Xej zmFv6c`^V@7EU>uHC0H)&AhC8qcjGb8FE-+i8ZOJxBnyVA!c~e_NkZ9C#p(!VbFS-Cn9V0NN;U7dHZ}g|$O0Lasg7wi;HB zv5bJ({Ou{<@WA+ldaaPsPZBpyNO$o+_i;3sonaG*8Sa(SoT`)K(I<3Xqy;LR6L&MRW&xb{{T^oPNIgelkcJkx1#kTBmhF?8A< zq=itcF0gwO@%cm)^pTb?bxC4Sji4c3VpzYL>7j?eZOOZsFAICzA?vLu_bF4t7>%VK zyCn)>UE~S^;8?9T#5~Th-u|old6Vj91t^U=a>8^L6r1&Hd4c;Z!iA_#gbEC<`nd+2 z;x{U)z6Q1SMC5#~e#g;QZUElTlf}&qwCO z5J|pd^2Rb63m&26C!wI$30@L`7T#XVTbue@z%PZCWf#q}$jejxB5=2Q4w!N5TVZY) z;&lTuyH@b`vj!K0n&&k z!%a<~ZhasDN6n72RXP%caW99Vbt;!lABR0e`7XV! zW8_P$DS^fwiF);M=OXhyCh~$2Z-Xy%fjvzGD5V}Op~P27mkdZeg17A~6wr6h+_(+i z(wlJzI8)># zy%>F$X5Lxyx>*{xPKR|Kf5uX#S4`$yfkK0f?w5%H{t4FAr}a^H7JXFvy6lR?H(M_c zfelYrZZS9TvKdubni103X!zqHcL^G;jXiUhCdxmPD= z#VixZwOroTSypd&Kiu0}AkWI!@Oi00VL`ac(@|d9T4zA$#Vw#mK8Ns^t2hb6HVL1n z7)N!Oby<|*lMyglG|uMD)^%)-PnZL%<=zhJ+%2fNoMLRcl(OrA)zxN4EZQ6h4XpSr4|+L(ob1@EQBdqR zlBrs}YEY2m=Rt)4J!Bxp3Aw#ASH3dyZ`>~UELo6kBjA~*A zG`4i1k0FM=mXhBUB2B|(6biF2QjV_T3KQ^K&9kARL{fy>Pdz);1oHQe?MAT6DLq!l z1mcgvXF)HA^kFx`fT6t4{BW{LV(zxhXnmR(N9fN@EvDSbkY4vc(BP@1ePLa~)z7eC zSMkvk8$e8Ms{;|4247^=HMmL~$QRl67WXPcOjw*t&-#`uFGzae;2OjV>#)G8frJqE zDzyHUDsO%-KHAjWvHLL(rie_s9t0TP2%lEqdU19`c5wQ1K5skw(64T!HiNijGsGEU zVO&LV5RR1DAIC6RHX_q$BzAf>$$kJ5I9K*pX&%zTa%3g-)5y zIAji@m#g0ebv)J0T>hl^>6*5-6VCkNCN!4LEwsG?_CVAIhP~RC1~T^YZ0^5-GkJb9 z_bMkhEB2zpTmA!(!I*<%1L2|9Aq|NxGPJ0eW&Mg6RvbP00vh$aaJVI>vks`!a&0{` z)1C~J^~TB5FLyn5$CTCaYMx!adHJI-iJT6B&wxo7VmKo3a*~Xg0#;h04pI5X^ax{q0$@hz@DB6LR#g}!FF)<2{XvFeLFJisc9D(?AUxP zHdGpLUl%;Ui^M?(?!z|EX0rnae%~m}aW5!5RMvDD;VoNiPC_Rgp?HD{R zTdqq{l*9jmeH{sp8`khdIu}@Ut&JBFOXhooUU#(=Wpjm|J}A9x=oOMbQ3u}gHd5<6(gV@-VtoO=FEF)fGcI!XtKS`XNvHkcUgcT1Tb68Xxu1j>!k%=e6N0PIP&?DT8Vw+u`d+>6l_Yyd}|<=gRCKF#*gMkRuWPGC0~ ziSjnBCfy!l^kjQK4W^JMQvKFrkmhjsBOLk#uWIrti<^?! z`G=LI-DIB~eKUvo8Ec--@M_D~Ca|p}K_sP%2vl4d^HkK56mhPy1lL4ewW`29o?)bb zHNwAM(m?tBxZXnpN5YZ&?pLKf%L@|Kg53?0R6Yq#X`MW$#zZT>-Mr-kDaI>KIY5u4 z0bd35y>@8cX-ABo7-OFn{vvLUVMv+9X=STjQAyThc-1d6tGM?;?qY|BnnwN-U?S>Y z2RG0!w>p^cC8nSpytG5EQ0?v0YK8AVg@Cl~p;&Mv+f+#!mv2!+zv#%>;g!l24bb&1 zEKxM;NDq-Xx%u)DZ`XDW_`_SiE)UD0h6q#U*@zt4j%^{uc<)LN&!ONMm&MX(_*#oW zQ>5WkuvNGKvKp((?jJLME|Io0{h_|ha&`3OZR7oiQJpxO+^_?@EaBV6V$sMwEKe{25%df)NHO4ALCwRa>0zhya-!YBqXlG zOV}wI{GFFEy^uRJUgRbeCk-MZ27M2JOpEeW%I!?$bHphz5`GP($3<179riN|(FoTo zWUoi!QuFf#8hFmz9`fA!<-4JQJJ?=0AG5bDff;?E#VccZ!_yWj&H|cU-`houtK#&z zQPGYQhaa0|L^b*rt^dc*Gz)J0{IU5Hr(QM5`STC&E?tqgZHg^E@o%|*`xB(-ThZ>G zwDsSD8!_H#JQd03QmpFsyhO_(#(sd0>#i{n78*Qcy}}k5N^(V~uxR}Xd3w2h+cF6L zSCK5W1ZeoMb2glbo4Qb&nhtba4IasOH`;pCNYFJbhJ>~d-%DBA-IH!`v8h!sH6_An zsCJG5qF?|ralb6`>kfCMHW$`7V_#xT84(rx?FY6|q{Hky9)csBUqO0^+kXCHVFEz&7OxTCn2Qt!@tZ&{cHapd^u%n?IQWJ6?dG+OY8EC>@ zQS=^7Rw3$15i2rsOn8z5_Lsdn8M)=GQ!d_~;#T0kaaHqIb7*anh6%tNTHXcQ_~#%l zuS-;^*W=E0WIqxEJ=)lABk}jHF&y z4|#LIN?LbZ;a3Pk;F*LiD?U%id>Y348cP0BzQM>kb30l?c0IZ5ZhrL@Hl9i?F1 z;$m{m{>#6QAQ3QgHRWC7w-3*xf}1R;!(##O1%uN9#u6JTFb`^;6}p)PJI<2CRGbXu z+>nJtGt{ZMptuJ{>K=dM6*H)+-7+SUP$2k-6-8O876vo+3QukkhO^KZJJ#*YW2gN$ zYcU5m^LkoTBo3KR`>9Pi>oU$wnW`BMiFx}GTOZo7OK%j-1Tbt+!kEQhfcIvGXMxbF zufYB4Poww}v#U!q4||ZsMv{6@1!_96#v72iyMH(>R*+sphN?@Ui*L>uXz1enqhyb$ zLt!z!lXLQ;*C8}lKFLb)xch> zKpIE?SznR42Mi5MJ*`z<;Pr6pvgCO?UR6Sr-%^X1yNhti-3!wV9@!d7=k7i>D3W#p zDN=vi#bhj6_zbgMSUFP%hZfdRbAN_kRhAQrS{x{~t-+L}-%O}K9$PQnCi_rFE%B|U z#7(h7!}HYR48**83au98)dc5DD;h5}WbhhsA@g8Qx-B=9d6wwIqa{@M4fZv}vck^k zK+fE*@G@$PURYe8l=h0knzkY)05Bo2+!$4-!6njQmFM}{wXZi%Qs|Jht7tsq80RS$ zw@bkUTkx<-CSq+8-Y(z5Tp6{a6Awa_c;cABC3@Fq=ta+fnu*Y&j?&M<+r9#YtW56t z1(k0~g{Hy1YN>l6q^dBHdyATg1ibIpUwxSnYFjFdV}0(rGkP>7sCi1+H;vEc>!=Q8 zmYEdgk*^iD7Eb^cqC)`-RiuaukcrD=ZsIQm_u4(QK|K6XYe?C6Z}Bd5db88qgULmHN$59UacaMG!`2uOu+cY`VBdK6 zwrt;0_oM#M9tWqsYu_klJiv{(((E`tsRC80 z^lagxIb+sb!sZ9PAjogGfj;HB*D@|R_A?22Xhb zo+(3k>Bo*+#}0Fqutw8z+O}9A5o;;iwU;_jLf~z!PQ%KF)-Y2-8E;Rwp8SNr@6CfC zN0QmVKZlL7hqxAM#-r_hGcqd$60w?ylP8`?uSKv`nF9YHg&rT%EPzmEE84v9``KnH zkT$}h=R27?l(>m*?`5T<`}Z%e(wEPHA3`~Wu6&Jowb8nZ&)-Yz*)9Q~4T`)k zExB4;;IEmhioiqK?bvLzRY4Igi`uqNLg3O`*To-PgBkd%-FnIB>V-uwb#8e83N7)X zhNW7ou<2X4)1^F)N6*NHqJICliZrmF3&*7?^5(2B_?tbbAN=dM0=NAv;p08Zhy-`~ zjq=w$6b`TzVB1LkbGKI&j-!q+3FVKr{dG9gA6W3=mclQ)h9HNDpF;6mh zteFs=OZEa0+UPn|FB&vZUemh=8w?6(c|&}WE76!W z3HCNui!HAbF`KSYp%KcUr0j?;HnJD5$L%x7U75>o(WWj5UbqAX#2ynNrS`1Of)Lk$ zGxR5=&?Mq-mN#Z-YL+fxtra|QU4KikY6EU7!%V``id zrJflag5AeeQKI}GSJ9mClI@BvlFj{|=Fl{nAoa%oo!SCSb>@QJ7$~Kgzw#FU`f{%x zR-E2Fm$u}D1E7D>?=4t{R#OLl$=NtY7G74OeG+6QV%zaAG&=Yr6;(?PO-sL@R29Y+m--gOyL!Q>1KR;XyP#8@# zP(W>%E*i~dPTa_LBVv|iZ%|!rI1=yLKot$cJc$f2LL*SUU58u7TY6TK_4EN*d`Py0 z1})h#P8gsb&80Lb6M0F2gEq*82NmW!Zr=cB(w7OikF?PvJY9vkYuhi8BkR+{p)vzl z3E5hQ?RW_xK$-+!6QCdwaTE9by~Hvf?g%h+y#@@s*R*`^lEJkM^j%bH4Rtc7XAAzC z(^qNRWh8t&YBxL;HGnD=7aa*(i_OkPWYCwY}IykdCEBob{W z9EEw|z;9~qhY51WlCMewHgSv=GI>oS=N=I)VMa{c%cN&s`^38`2H5a5y-WkIh zWj`jtjlfcfcyHM36P%{0-nNZRc`VUQB*9}RFX{Z!B!&)wGiH&YtIk(g9HW{iVNWe+w$+s=;;{ z1VEKxPpV=vgZtiCOHZC$Q}E{03Y*|$n^>R|3&Y)|L`FfPR`GAE;I!vYvD-K%rx60F zkgeza8kF9ymQ5Kg#Sy!Q2Bkk^8G7CHc)VX720Y-;((P3OST{7xm(a3I&1n9IKBmH!k7buW zKzamBtXOa*+IRy|NdVk4&IRn|14(p_y;OINEq~YT^d8LNzzLM9)>YuO^}~TTU>?vG zrV$4o?>Y<7J$LpgDn(BXyS(-J0|Dg5N`nx&}8}m(Cvy+At9XD0+o==HRH)pj`KAUX09Yr3F-=B7UB@ z)94J;2)9lDbz5_443z8|{Q{Ux7np|ij_xs}P9j9|7x+MD*FcZwt-j#`kKwo3gl7DP zyBqV&yg6{vF;R)V=IeWqmCr-wm8Pyq`I9#A?oB5^zg5a}!)PD3{=nSIC#K)`DtX%9 z5jq}r^#`UvE=T!Hr)P1(Rqee^d7%+T&BEG}6}pSjIkc#rnkNkhoAhJP9$7w1KudmM z9p1c>J$(fVpKc4=^#hepcfR#~7q|AR9sg5ZnPp^}a=e5|qlw*j0SohpYK5gPR7ALP zeBKN^5bB1#(KfRCp~UnquNwb)#`Sw0Eho;2rg@5MMiJmHv&XOy^wlE?(QgwuFh!Z# zccs2QIP?BK>9jc;Xfa3LrXMarOpOPO_l2_<6V#?%e^oh)Bj-Hjuj&@682_sfE0a$j6~of5TKJVpuQe2{w4DCr~C zW|rjMnBtV;{y;7ID#%kTt0yhROV-}!IUmH!04yE^UO7as!>g}vI6&-+ci|n>fcUEj zyQGos@7gOWxZ`vu=~D=~d~T%<8UA95OuNAWdG~}{(x{*}Q4U(B)l!(>ZbnbTwVdgV zh5sx8cb1wz$*xWNsOQO*yez&NpLHV7%GzkSOvhfQl^+PdBli5a1D}|$WB`_x0E_lh zWcvJrY3D=x_4YpC2LgDy_~30e`MajY)61%&uE;A3ewa&-y)5V#7cWTVjfu{@J@l%@ zpBY5FHE19i@vWh*0@)^C0Trr7QCtwv&}p3y*2E;lX3-EnGEn~epb?})slu-Y#}m zMk&jE9}napTBg!X*`k(*ql-^UzYgD%R+S!tghULGn?}0#Ayz-}rLWLlyjU?02qftD z>SJH(Of;~hM&E3jUHVz= z_l{T=)6pEcE&PsmDRJ>*?UsT@yf5l7lxTP+j$g03=+zJ3yhee+?N-Y|1%){YFcah6 zNhA_`YaDmrydstVQ<%?hk+msXVidJ}HzxwNy42PHA!itA%qhyzX)^osA*HqdF71gC zG|FJS-S$ux5EO5Q+3Zd8NWXUOK|TXwH}~@t>TCPY%9HVVZ6ddd(?;sXPRMyBG14AG zX%OQ?^QR5y;37s}xA@k|m&PPb*Z7;;`|mENfkWq=VIGPg@7~0t*Vatuy0); zuvdc5TYRnchmX+Kn>{KBPF+lUmV}5S1OB!WQDwXy3D~}MK=$JoRIl?FwGIr=hc@A{ zC5f~|mDwx)MHsgPj$1eL1k|rG!aP*Rw`n9(SeW#CQN3@y)dyvZ{7xf!IPx7o{Ve3z zN6)Mk=521~aR_%6TuITXnn%Y+n3fhUwB{cWPvrD391~)0(~gh1>Cqld=LHE4kvwcQ z+p#xrF=3^q;c)0X-U41;a($^{ko=F)?i>=Tyu7tcN(Ca;xO)#<^tro*szups0qv`= zk104W_QAG@EAxa-UnfD%W@5q8-*iQ*T}_wTsJ2(@8pp7%1Z=lqsYZfBzt9-Q_pX!Q&C=k%7Yt7Ub2qXeDt6IG z-Uduf)#xsw3zBZ!b?qJcZOLkyUgZ1k_uTozrmX-E*8^*<3u8s0pH0S-zcArDhX26}dtmjxn!3rDc@0% zMF&FH3YC}evV~i)wjCwPXNik`;^S-|<2lB#>-e!dzEb-)uADH<4YkX-e{NI}Q6?(q z`mJQ(OX}n5?7hbg!&a#@Lh!{nr&?v_kuyLi?G`hY`%6BCT=UqpzYimo$SrLcn&#s$ zA57_!f@!NQ2@FY2{wi0=M~*_i4O`g zljMxMm3U0ao<9p3nd{;*QZBINs2`4&Ug96#M^Tyd7ALBCts%5lRybYJqfK^#mQwnX zX)|m0O1G*O@3C;+F=qn|I|zytR5S7$t8w;GG#|#7`i@!h5s4=MaCpSgR+&yV@$<2$uuZv8(4}F_ zB-fAlW#kQmN`jwh(&I9lRU=4@A(b|Dzg}X6BKp<`x1;$nY*=1gx8kv@APP02LuVB1c2 zQ{$Sb{ra6xQWx2hKo1ZWQ(cp>l6Kl+lbyHBKgmK@KF?5UCDLu^DL$@*Dr%I=Ls;#|9(pXE12I(Ge zHds`WtDRlNY3t#S(3yFL)j@yvlyRjkq;*|Qx(`Sb?JeCWC6c|)hWaHP8Ex_^iR<*y zHP}DfM5+m=mD#jWVIB@L+l&Z?Kj!S5i8OC7ya`JVbmYxKY5~uw)hW#`ev$oU=BInn z`+)j9UQkBt>%B}8lG0qf_+0tOG*?7~=vQ)wI`fqh)S0QWqyt{lM0Q#1@$HRS^V~K5 zTw3>nnDiVfcp`^b%dzp>`wJ7TD1iuT|NBMzJWt~eooc_Z%6M;O#Gf~vp!*Y#NCPqFV3c5nJJz>{u_YjKA?>9W=Zr`8_pb8Zk*c0x#%kFXk3m%w$&3Bol zNkC16iGA$=wQRqvUc_Ak&np#+@Vodv>;Tlu_p!@se9G-ALZtS7w$&i{p%I^|wUC}0 z8Y>)AP+;mB=C1}(Y(~UwkTQz?Z3H1UH@choX+$)XlDEVyJkgbwZrQ4m+++B3s>{ls z1Y^fYdEDFH10|-}J$FupK~#~<1k5=+%seT>*=gQX59&w^mMOl)WC_vTA_ALS1L-#07CSec9~g-C4X3Oaan zGL=?`j-zY%waw-0fKA6v>;Gcqd1*A28$;xab%L7mC+@gPo?q+=E87CH%VD&Ku0+Rt z&lrn}qb@~yuBQdr*!a`I7E`4Bm0;gQ>R*54T`W%dA*ASd&*bG+a*}EJ;@D37ti3DM z;vsb+CR}5S(5~D3eO9r6ZRJY4ahb<*!tU_oe%X)TzsP&8X^BsFSb1vJc3mzImeflQi5~l-9|2&sStnNj< zB1f8}kk2W*fv`-{`5X(~EEfNgsxgd$WYBvWmvmFNu;&YwV#qj878Nd}q*M#?%lJQL z+$+;DuKa2WZ)X{?5H=7b0Du&t;ib z;V7(UKf1Mi&WRh+mg`tl7`n|F!`_y&SuZ}4ZxO5_Mx?l-K6xDV)yLi~BYn~N*tRV2 zg#m-7lG5Ubr2f7~)Om2b-!%rA;%D)?#J8u?wG=PX;)Hf~FHK1H= zW&+U2uQDK+bf^TI=t<(}y@wo<5BEw^>V87KP-)f-GsGH6&u2yM=AVbEH-8Es$FNVZ zwx&>1$%T(6hlAg}KJKd<#i8#rE3IEyH0fQ(T@BA&M7%x<1!xIb3ejOaj{9o7yt`%W zl7G(jFzv~JchF~K=leKLCYNiktDQj8xML77XwZM#s(p!fPGwJ-aSxpz8xYAi=0rQuR&0I#&pYJfNj&d%X@lZhYcE|tz`tJ~A%(Ax%w_14VZ^=5P z{GAZgM0h<9MzioK6;mVIR@39DsLU6H=ut~L@3uZ$*$COZ3vhf=icHuvAV_AVOj(wp%X-XiJ1li3J~G^ecXkc;oI{wYLG?55()1o{*mP&@Tvk8p)-8U2 z9;+EHqsnm}J{l8`v6f2jx^?cB`R@>QmPg;OPa}FJyQAi*8uQ*XARUMW~eQPEC>{EHvfHibkPRcVgS7#o)qRih_S6Ds(!h*34Mz zZHHfNGP&{Sm9~vQsA002oyF7H0FKp?Lf-nZs0-MXI|;kZv-=3~s{1Os55?&-?fK<& zIQHGveOegY9)5Uf-v01SN;Z1Gj0v4O*eZb!7IvU+3wSmbmyWrnN_Mz^BTbMNMR@$= z^%Ls9{Mp1}%gA(CPEFR~EA1Y|pL(gu*mTNczb6LnjiY$anzm@=D1W_st-#p!X#JE? zWyPF#Y&B_$QTKs$2qzoh_jSyzkXLr%ccl--@%E8a24>t~E@Mlfb67Z2P1 znr~yi>V|WLxlsZt+-Hc~S#UCu42|mEnIO=pIQiCiT(J_*B!N8gN?fULE?_3Gp{Ng) zgU@adA~;>OEZJaO^S;DOsr`rYz2+zFfyD1*-=@J=9x~NQ^6&^c{l3{K<$MX); zSozR~hlqDHNujjm2`~8528i0?QhwG+Gan69%MGbHy|lWfywOX!(&GHKum!#C$uyWI zU{5ERp|O$FM@s0=M3(_pZ3ssjbd_0*UX;+dZ=ya6?z>3pHR;VbS$Z>LUJzYCkX%z2 zhKoIFB7t<)*h1H*q}AF!#h&r|-sN!RQ!o{uoKhRF%@OO*% zA&4=&X$B>_`VqhKkcGSe&czUYFwtGK$J{Nr5ktu ziyWP>DT>{n{^ir8_JZeg2BBf^8<&&KGWC4!u-QJ7j$T>$0LPG~?svS|De>#6WaJ=sd()28v zxVO-A^WF4X=2^lH7=)#i|1~7|IZ%)4K}N`0rg!hQ-K@cNqk?0GaLasXf(%Q+%Nl}) zFE{%-l3J@WG1-WT8Z`%YwikU*Gu*LebWPiRMu?qoHt7dNWir(0COateP3nVFWA|U9 zUvC)Im1AUA40HVZkX7sZ`Ci4$tehiE(z5d!5IUcUzIEG6)7sa zY^9QEN$0Y>i#!5PD4Vkxk1K5=C9g{ceja|~p%KU1H^$qWAyaFR-!tylUlF*tH!4Ag zZv1L+=7j*Ou+zOC*#Yj>#zr`WbtSU zkKHrQ|3V;#ROT0!xZ}wWmg}R7aRI?`P3ctG8&_49o{3uu`V+}Ff{*-fq`!K{CHE7@ z!HTbX4urXmfG|wy{T)1{ zB%DfPkl_VR#zb7qty(cHJzK1n?@71T^X_y>yZ3ofS0i5~+xI>`D=Y7v*+rwkPGv|| znlwdvrO`?)7X63UH($&;j8F^}C6@w&fdiuWCG7Ky@1Qw7=i|5Dc`}jq6t-i;IVYdL z@%$@Y{8zug{SbPO!xisj=6z;SwgRQrH#m=nmZeJ6^3f&f2<>H;?QfMSPc;M&0lquQ zdi>jjmQroX+6PX2Xv|kDWt%bLkfCGuoL^eSx>>I1wx0CVqUfaXrif?e=tK!wt@;qx zI5nN4o|*^hh|}A9nHyd)yOm*h0o6!A+3kcZ^iHeIcWU1g2Z&j_@s47lnZPp9pft%=w|)&?b4mHD?VmDcsKHcakHv6Z z`lhnKP2HUNGB*GJ=ds2qrStTThO#WF=g2lR{Q10HW-;A-9^Sxt>M|GIWPUZsBV^a^ zRKH$kV4YE+q};i{d7Y+`QdyNjTt5?l0Q_)vB^8>xW@ig21iXieiMbP26R{eTe|+fr z4d2r~3==fBf|4|kn;CjPH5<+@SwsYJP?DC`UKxA3__bHRW5HkBfQ+tb5&2Z*$CC=S z0mn4g4REuK@^Fd>Uj06DxlF&L+B3ikYzq|wGy z_a}K!=uTCgK~x{z*SY7gP{P>IwT3d?N2y*fmebOkv6-UlsAQ`DV;GK*vI3&NWhR@< z#AJ4HN%S~0Vv?m^XcHiZv*n)d@#-PT+!s&k>vKWaV*Mkl-f7es%Uo$CgVB%!U0uNk zCEYw8%rNiVt4UiLHebd0k}YOu)?Z7NuZzdS&uCq#c+^tL=&88zBe_zKuX11N_@CsJ zH#YVyMvQbR!YJY2@BQabI|SGg&y5$*dd-|HSpd)K)-IE^*v5#}}q z#<|WMfcdGhec1s|$i@?9i^L$&;0f@o4d!0W#zc8Ivs>f~%}T+)o*1eBYpaLiIGq$n zhnNuL3gbB@!62j=I>Ob#^uzthgHb)LRgq$ssVhFLmH+PXmzjdGxW`}jgOo11hw`hz zK{Ra27W2*QN8m^jr6w5*|7`^& zeB#i{d0z7S!5mCdYlWkOeo)^3_U?L`qmcEBML&#xrf&0{(nBV|onr4!4#NuPOsGgj z7(Z63*WOnwSLXlPB~A%<$=3E~mRU(ySPUQtllZ0nhpM-Zi~0%MMx{Y=>5c{Il+?v7FbF_=~%iuq#HzumF^M=Nm;ti`g`8RE~mg! zoZEZ2G8bi9-UHt}cuvJ@=84DRQOYV%mJLGzU4$G8%M}Ld2Uj1h2VVChlR@@mi#@3J z67uLC<PD0k2msz{kJMq0RL$&>7V7Huaswvfk`5-n|`$FH5*r>uXDpW}>3 zloYOY`&&p=D}=VSrt^z;WYBXX^@Mo0GoqcIt#?cjBy~z)x2$?A*|A`pD+qs zhmu$8i(_8Ix?V_A^KFd@Se&zL8|(q6$?CBT2S9(uH0N0saa=*i{*`-xo25B}SVs@f(qA{HN>SVt z!7+WQH`++=#?p?j&Q>XZkVAYiS|ZS%0jEU5iY2C@;0|Wsu-yRIw*wULp@F)Vbm?JV z5!CHpLB*>~b2vIUX#Y{W=@WY41N~cBV0(#B!?s0^MxNaJ%{$bPD&*n@=u8{UG%~lm z$DYcKi3wy`QtIjcMtuWtR(w4}XI#kxJx$lRwrG_Pd-QdK=%S!vlP!(lOh zRPEcI&8^f&JW%i731JIBfX-D}*)B6OcksMFrKN4oDw7-ng2@Kzw%@!SDSEs17Je}R zlLlNSz2O?A8nrFB-nWaG7hLubVK-UA z=!j;~^lqXe%T)OW1s31Zy$f}aGTqzua2%j<(Jd2PxrZd@Dcr|AV?XZW>yl2jCja%( z#7Rq=_Tg_)Cr)sQ`(QfJjG7cvDZlPJ)~p&;M>rY)7JZ+HA0XPsZnl}l+hE%zN4@i zNSZTndI*HSQPeai#ht<=$Fkev_tbV;d$cNwwS^`^?e@vv<#BuMML}TokFfLS~KHHRDDdec!sg6IWPA0v<6W9 z#~vALde5aE9h}=)zr~5@;`%IeF5S92mxaB}sBv@l8;N(NDQ|IyiDi^4K^Nmfkn1kc zJzpavT0^YxF=b=&Pn~t(ptz9`4It6el59Nl+0eDlXX=q_#Uj@t2bUCw7q=;fwbqj4 z{Qi|t8!1LDf~tc7k^6BAXzTD2r(|2k zsJ{*%C9hSxc7?N{m|d%#)`ioyed-jGvH&H-tTyOLN`9u*YQuw9D#@r%$3zq$xhC?@ zU0{V5Rs&GBHjmdd+$;x(ItOjg{W99*c?~o8Tt{ny1eW2T{Kh3LI4(S;qP4}K=~x9` zQJTNUHs6okQ>f#GGWOuqws-3atDJIOof$)1y!c-a{HBUDi0NFII5~}!nojLU?>Q@C zu6I#554>M&2b>+U6O7}2Mu6>u67dDCoeR&#=pBaJc+o&^19IN+U+NgpW;yIS!|Q*9 zsTjNE@ZN6kEO~q`EnzH&QVb85mc~n5^(U$w$$@^Jl|S%_tFSOO*VGpNTMw)9ed;3e z5BwE}mofX9EZ7>f_PO@$Ko)wxZsH(J4#Av?)pavTezUw;KtrMuRo5)*ssn?ON^5IGIHJ{`@qpr5T!4np|w66D^LVpeO z{|%DZsxJ4K+^qpygYTvhtE)s6(pQwuZ9|!kJV+OGeJN5@b_S!X3Ay5(>$Ku9g181! z(MSllu@h4?rAF{uzF{uz_f|;|*)lqMvsm(nj%`Lx8fr{KwC&2N$2nI%>dp)5hUq2` zl2FTgJF8`7lYVGMg_ zI37>Kd$@w&#sA93RIZSYZGu+!QnGe9^PntkEyNOvuMagR`|gm4yq%NNn*hp2;H#*H zJGtTl8^TJL@gxovA@SwbW6CzKqWiLO60_5W`V@3R02imJvyn(0<|3xB{>41UZ#C$f z`A=~SKzT740c6I_m((-&$)+?D_Zbq_YFbok22-_oFsRpK;FM$KiAE;BW&;g!QPw_B zdHMJn1na!!g|7noCaE05GPJoSc~{sQO6vFH{@+~%GN}Gyg`!l>Vl835iF6m=!o{<+ z)G(|%r_o3T^6lf$+f-2iBikknV}@>Aga;;Irm*7gnpR0(SZGTsE+yShtHbe@PcVnV zyn3r*T8-vW z#s2tc(9NNM>>y)O*CL|^3n`C6BcIMP6ZqcZ3*>QE5Dq05(|T9-Sh)%{&L_ugXb#?{ zOlbOFA1Y#s{^pXX>sB1Tda?edf)bg2gMNL=0#THuMiBg)MtPLBXMv~rWbHd#s}$pCT!Te0M4YJ&GZwM0P(clbcN-AJ*{teZYA^kdc2LEpL0 zw28CInaUwj8|YX2wD)FY`Zm8um1IjKE@9ZJ3{5_-OvEhZkRm60F^^q-q&a$_Jy;9* zNQ7C%3Q3;%z~_8DlKH*y&;^cPSp7j(|Me=*(!m>Nl~fd5=YhzRe(5@!=6$Z@-$-e& z7|qE zOz%arT|+2MXq*bB@|#bwurCbIr>lWSVh85wi$uX!ab>asr!UX-kHr4d7baHbcK96* z+(}*92fewSWs&PET-Z8v$T7RAq|IE=&~PBvOpF0||O*P*$8Fl+3_bJMqWym!{tY zVM*ySJpbz1LPB_KV! zoWZ5vZ3O^hV~E9fwAIlTPUoa$*Q>@`o6M>qVv3weBEc+GiU7yg2wE^FcmNHHVKt8n zkGcDC0NWP;>guK^Gjrh5l?)sn6PtJdT-v3F1B-NmJs3Jqy{C8oOYc8*fErBLc~Q9M ziWXB^)ahIj1b0{nz!AQ;FU7;-Xx#}Bn*wFsD;UadgX0dKQE!zIpw=L+lT;HtSD)ta z9Ty2yV5GrjnKko1v;(9N7Hm~I;tXiZR9STld7qf)y}FQZQ99)0gGlT@Doj3W& zRZ!K+iDPa!t_fI>AaO~eB1VMwON*4-vmmG}ryXZBQ5MN&WWmcF|7>~(2#dMVgmA~npWRQq zyJYIEQI4C^DYoWn-oWv5XEDzD*)o4kFZudla8*0e+|O%CU3+zB6?g2X8$qxWMW)QT z5ZLV7`{xQ6C&3?m%;RWkNkcX;Z<5;2%fgOHAG$~sAca4?F!Uk?K?l(pHIQaRj7P`} zK5RO-pFl%`ouhmVhJo-JY!yM;;mQb|er)LXCTV@4KP}jmHXV?oOqA~lWXPt!i5dRM zC)kj$?EqM*R;_Q*&8yFzbfXMzgOvqm0*k-Q>8h^$)96RO$2{rWox=->?FvyWy$ns5;gCu3ut({behB7>eD7HX!xXW|bX$J~D zeQ@XmB8RCopjv^*^^&jrth`QZVakszOALf3EV|+O2(nJ$1fq|A)nX1Wek( zdC-uMnJs^Pc0lKzp$X2VVi*0<3wZ@mK^U_ zjhlYaPE)Yd|FX8`pM<3qnO#w=78%uvsEyAg`q&@p!Ml@&jkFrJhD47`f5Vyu>moQl zAUk%@kUu!Ibc?OUIv+EySfO^ z`2ctrBJmrcmT8f2bfwh8zu9=%_rBd$pOWF(Wb~ zRF?irv6&%RQQAoqp5^|y+n>DC8~$9G4sT19rs4~727%bQu$Ju|7>0;d-A;!-qCgrn z8KCR+VIJ8Cdl)Br(5jX_1`j$WBFFw)`hP#k3sXSr9Wv{4tOXs4`?hpAW(()-M$%9T zpNj2%_*7#8G40KWZ~C58QK09&%k7Wp_!ShGG6PFyDP97T9$OLZQjpz*C5*;u`0}!Z zO9dIMG8S8s9END&ok)av)b@RWB=~QN#QuaM{CU-npz5A7Um4al!@I)IN)E{H6DXg^ zg0jpV+F7Yzapv{J#L39YH~k7e9Jx*_E+SyXs_LHK~h1l3-^i{;@_5G5R75xl1LFhM=lZq zMwUxM^`J2iLL25ji5eb`e1W+37i98K^4gF~7(m~Asu1`aTW)=$^nfmrKDR}j^=KSPa zMwqgGkz@N6D*O-Noe1GDw=&rb-HHkl6k?GcrL}Lzyl4z++Uw}CLDM5*^)L7@ko=!j z!bij8p3j3FNxZtdOk0n4HQ@NJC#3g@gi93nss8uFm>O8!9+`m=uy;{=@;wjDHiZo2Iv=K5F}MGgpBApCa5YPmq$+4y_{Me zg*qa7y+Vx+#lJZqCgUkY$bwWk5)M+gS%*s=QvC(X&%AsnHO)JcUUs3 z2f&rb!vE@@|7$=%l<0hD4$S4M`SM8GZ+%SKmTTnr7f?^pDIO|DlNn3C9*M7vY)$O< z^?d@JThyn0;`|XL*K|)fEsXq+k1LE@QG8RBP3QvpTyJB4XVsLf%5hqU6cJGl3FIC5 z?Qp}DC&+_bQykt|89u9Nb)5I3`u34XK@VkK8stku&nwliBSNJ1;!0zU5F$ap%au-P zJZ&=ex{;XLbjhE0gd&L~7al_|k;aOCdSwL*kzs02TH1{INyM`UFmVD&Fz#%0eTgSf zU`fTQI4p@B1>1} zZojl=?#}K&KdFwcq9JestMIi6E13{q7P7t*4%eQ-=$4-AJ2ls>2kGZ16lzMb;5^*C zPvoDCgR z1k$=*?*riTBC($lKPB=r_N?bJWtfmv9+`WdVM?QP><5axgVu@o^7P1U`CnWoR12q7 zU?m_$t{aXZbTEE_mDv0#?l;rlH_Y6F&Q0jWZjoC7AJNcSu1Me&^Z6a}vc#*gx2Zz? zmjq>NcCWeyQV?j1{w%uF7dVPD>j7OB*t5$~?2}*BnoLFE!|Mu!5i*U5Tf@HEDVA>lU5}QJPKPwO=mq!y4EQHluzC=|fpH*J+PGF9KJe z;pwFB_)4>5s&2Qlk2=K+FW_8$d8r{yeKh0#w*4D4i0-VlN+(S)0o)`y6>TM^KEdzfC?DKY(H6n z4=~Qkpg;;dy{Cmqk>#X4%M+7OdI0{QS89W2O*Ek{ng?CCRwLf%dNPf)$;2 zY<EhY`8M}2KI0&y?mzt2ULTXeaT(IV+V)RA+B*Jl{88{ z&o!_Q#SFaE%lBHiz!U{5Bi59rg}~;Ih-W=dRl8t8>%h}-VFL#H{5d*~S9f+1YMzlo zybf1gN`|lw8(VjF)@bEGj-|i;aB_Yb^3C8G1F#T1yIw=7168o^P`dRBD%vptqBTV6 zrf1u?!&&Nb`|7@D_tQ}M;cO83WFfcId`^gMh#_=n!1^$dfF}9F;5x6M#;^+fhat>W zs+3N1>t3lXQFz?l!dWzrB1c}31b19wGhmu}w{c_=Hb6+kGe+hTx$BF2Uqi$E(V2ei z`-tag5I66ax9q%8a1X&?<01X}ptQZf*9|vEyzp6mpQ^#LvFmR2yV2v6WcQ~r=ZHUo z^vjw&=<)%B@jmL~rVBEhE4Yf3@tTdPcuThBk8dKe!49=lxNJ^0SV5Y=!};Nx!ZJ5tZ&e+Xy@?RjxdwwSz!mNgBz_wz1N}V+6z*|c*L4@TkNIG zE$jBDXEC@_{x_W(__zGC!Yzy){rJdCvuGL_@;&+?y4vdnchy_d-L}54Au$n-@Hpl8Jyl zYpp)HW84?8KY-2tr?2i{B!g%$s%9Eo+ZT7k>bLgbHsqPQ@pv3j(87mFL?@drGra-j zrU=>;(%ofR@c59u_H5h66|XATw|tM(&Yt!yMu`ajGHpuPEtA5xG67h?#yMSL*e@j$ zhm2U9XIC!l8w*4yBxC&fMm~1^;#MwC#*)XoM71O6LyNxgb2h{8c@m$p=yn6^q9T&bm@h_%G9QzmSp2_&)Q znvc2j(MH4^iFDWT)!Z3S5}A+r{dU&zD5yn)&Ju0~*z=x&)7KN;4vs1W6q3-B7M|Ye z0%oqFvrbas(8PcO}h2^&y8yZLhRzUWdK7a&lA z1*I*8(Rm*F!E^Vl-t13?*&Wp8`DaPZ)C%M620c*dy%s!H&b!wS`Osk_bE}4~!Y*Ys z^M&L}od&>5nIF8!DuMCPnORqh*GTkCNbzaU+WbjTAYYtV=}`m>quN~45lk1+uTVz7 zZ}pX*Uijk&=Y`nxjU_pR^hEfKaR+wKGB=+$b`-pP+h3$LciRE(iM<>-`NK#V72TiQ zP7$pAp^fitNNE=x5Fnv&qdh@*Z*1|X5Xfh!&}ifkaxrI>v^&V*XCs_TqE58v&YCM~ zRj_)xZ2+e$tvJUgI|4G%wM~_ERoMK-@)=bodc>43`p+r@Z2%Bc!Hsb=BPFmJh!T@(tDmEZ*@Gm zmFP5XJN{4+Dy}OT`M7w;kQiNWmLpa{NS zPX?0GhxqCb%g^zew8Vc@kjxHKHIeqk;c>ekM5KPV3|ctam&|U@h={{oXtNic5p&CA^s8Ho#&ffb`ru5grhqn}D(F z_Jb(Mrg(3R_mwRzIQfJV(j%xycK=d8hl;McKXjBIr6Dh)eZLlY z=0D+CpGG1$wonQF_(OSYJZOznntF0s5#H?}s3_!#YN_LVEk#<1s;z$0W5Bqw&F>M2 zqgN6nWP#wJ>J3G)W7#1Y%i z*6pNcg@-BPtxcq8d(Im-1u5rDg<4~bvLBT^9?fa_P0B}Zdp*%EWKG1*zdeK7TW{P_ z$>3lCU;bI|_{G)`>^3qBQx4|y#{;?(4DPO?<4V!8QOVF91$+{a(3!oZF5ZS)WnAR% zEkooa=u%2Vedfj{2QDg9oO@c(@Kj2B*$@0NBy*wl8|uwZh8--TOW|ASGJI^n;3T;_ zjKh|rF;z>kbmaI3d-wxghh5P@wJ4~R z@ngSKq@}MP<*O_tP#I46aM#|AR2g{(vNZ;NyEb>%WOzbqTg-)-znZ>4hiW%rnm#3i zKHNdD3FLPuk4hkUcZYiwosxj%Fa=*8Lc38qg$J z0eEL^)(LDIik+K2Q=a7K`q>vuDc3_mk~|;~w&AzB_Nj4PCb65@K+n1EYyKL==0ZFkzFT+F<>P7iql=Tp4%sq)W)QB8fS8@KJodeGg)QON(?mlR>sDy`Jf@hz_O4CL?VFD;Q985-Ki!dKk9hL0aK~(ZV-Pn0 zOI5s_=3NT^WARmf#*6VQ%n)|-NR;_XQe}L*-+C53y29)%h?+1$e%q`$9C_q$GMQxe zY?K;77&ti-V$PwromQ!0&L-(BPeYm5An_-lX;NO4y)aMkc{y5b8yLX|S{RSLyeF2{ zs!C869omid7E08GY#<_UA7FWnE?2J^lxGiWY2KbmzDqKwECuQizI!}Qj5cO2@yFY6 zOT&H4SQz_dXmN7|z|wcyR(IAYTj%McG>NGn z3DOR@CsLgIHv^2CE=8QyzbwS-X(R^_##U$heF{kK1`Dn^J`}Ddf7`+-3JsyggrOYB zlpH(V2Cb)_sTEuiHlI`DDITe->K?PoKhn7}q^zZ8GF%9P;CYI7izejtdib+UM~YLTmInwnY;#&?SJjKn+KK&5 zj-P&LU4nrv4SW4&Nso4EtUH4}KklWtKmKM^2wB1FIs?x52^}obcS8d<82p|q6;E`4 z7@vCtImh^{8*Ox_;%>uD`8R^f$bA?UY^<6nvJ26yT5)}PLSk;SXTrGJgX+rHH zup=;{yu{HDtZ2qXJQ5GXJ;~#8 zqp-u=7`Nxja+l7$X=Sx}r}%yTMCYJ~gw8@VbpD3!;}W^89LJKBYEHL`j1aOGy4bF- z#z&{^h9dp@LSM+xOQi$ZqobR7p2`4HWA@tkhbYEc(@SjBu9hBIW4)+%C~sNiw@x@< zzkR9qR%5tVq1JWj&#at6_bMjoKZkcH3&HS}tU}~MQ^S+2ne^95nzr$^4;RRTz`~f< zoSt+2u=Qr`RcO3%7itURSW*96~=_65D}SA#p% z%dx2!|E&ByXzNc5drDfDclzNeeOt4aDhrn6V%F8%I7PP zNDPuCV+vEDIc!GSD85@pAAza?*^vrIL!dZl$tg#_eEn{|qm{ZXI&AY*k-OOdFu=-lnv99Hye~4<&BS?UFbjGIwsx%(u1(g@H`p7nV* z$|Bt-iQ1Jc!R8kCZ?lqc67Q;22Tw-!6mZa$xLp(+F!Tgvh3J&4hX`inivi!M4JzfLk899w+@}EoNC?Aj>>!Uz(J+s6d;YOYlhZFUnH9#OtRL#03 z{)~?1wpXymDIN${T?V@;Vur44iq)XzS=9(1;jUkm@EI^tLA-VbFAiD^5id0muaCme zW)-xvH2glKR7^X;{kZDcnj23?$OOnpd|#eQdK~fkvmc8l(eo`?2DuqNjUJa{Y)565 zMfEY&ns{w3!XlK?YZ&XJZ`q&$cENwBD2qnQM-@vI41cj^NhLYQ-*#=@91r#nPS*mu zNP8r&G{tH->q%}bixRy$?Qi=x{Il6I3wB;gU{P6K`l$qjjRT2|f=L%Wa;acznk7!O z5L_j-t3FtcK=7Sd@cP|;`MkjsC$srC)kFzd?>xn)Gq3yLr^Z*3YjzjcWp6tbq%EEx zmVeXtwvikqDL#-uX9yPC;6p1eD!Vk~>B?VCvUiuU1>Fj1^0=;Jg;j#SvZ-S`&%wfhv#(B=S{pef6gLbUIsqDDR_mkkIFcEccz35nm z$s1&2?ITUCn*6l_(z|lBLD7LPZrP9d=nO*)z~O)%$slHrF{MbK6%nY^hPlyYKsme`s95_ z9%A2`fUV|EC2ISjvyA8CY^RZwk4^TYevv$V_@Tc(nqy>9hsft!`jTXg;wkxNMn6xR zby@QgRkWtF2C0OLd6;29R`;{HjRuDo+Y^-_rz=P`Eio-y_Cj^LljI?B_$vP!%6u*> zBvo?VBjZpN#pi z>3(?S^deaew&X`2=HBG&rDDL)`1^)Nv!dk=Fufm;o1vcPM}1FtbBR|kL{KATZTk#M z`0#45faeIMqN`6S#KU1TaDR0Djye(m$$#gjY>8`M3OzcuvxW&NphKL9Jc>s07V0q^ z8&x=cY-oW>^ipsyO%(KTfuP^Xay5&H!y?$w5Q9Q%@jSGZ~iLtY=|IqVAEZU zb2#4U@;}BE(_nqPygvXKwU!VIe<^&rO8SaSwSdKj9WBk#-bSuU785-$yzef`5e}ea z_q@-yEG_0_LF$SaHCND)_8I0$AZR5UGKxRo`qOgQ1Ff+v7Swe0-a5GrKoT~<<}@-Q zl>(3g%Fn*loqRS;f&K*pfFJ}FUFAA%Se9$z9~VQcjYeXln}%aXT{w$#EGRqWy)?{R zlyRqp8#(+EMWRu5SrEV8<7yZ~2eHN%ppSvW=x`@qGU-jUqcazxm-<{QQ=Hx%)X!I zc%-?IuxMYRuZN!pz{}8ST)mWfhf!qsAtr3vjdkRsWqeOg4$LXx>(wU_^T|Riq2{lwA$EgDuIB{Eunj+`_@;@cf@Yv3P(BDD$zG^o1=fezqAV5pNx+;%^!}v+U5I zhrwvk@6g;e(;w0|Y81MRFGwubO=r%*k-LE7AmX54{`|bSb4nQ20Yv|~?TLBkk%8iF z<)-}W3zVezOQ&)=>*+g9I-qje7m!aFMNb9tDzay)AbEpM!f1(F3q9F>u{@7dheywa zn&2y;M091E`Qt=LbKym5m4_gKBW@z{Fy!U93#3=r@8do$$%`fLmAF66sIR1&AGdw` z91BZcw)9=k(dKdL-fTPngWJmW?yJCS4)ipA>eEjXefg_Oet8Xk>Ii+FisI}=!Il*U5=lprR7q^y5t*=!02NOUEaEw&(B*oNQ!hB z3oU4swQ(+s=nMY+2)iPWwT#}KEFX!Z_sHH8(}3imL`?3ed7E6l&!bX8^wXi4YSyoYl8AZ*@cz_KVwY@pqd zAZSWJEp~(5b=0>*+d7<1fAIau5eP7($im^{Gu5Ru}}&vL`lo)4vtBy&BO5L=R!IOk8UdV7u-MY3##zLWv-e|dXC^r zU0tW0f5h~>bbs_1^ZX^N(GDnb?m@?W|MxWb5ktA;wrk8P9BSP>0tt?I#`y`(dyVr5 z<%C9vxz3J-c?85V*GYjzfTNm^Qbco@%@=Y3B<~!r5gjc? z*=8rGpP=yEVl5XiE6NCzdW0t{*4;qG)lXy~_ZUcQn8Up{V+L4k+cZ4yHvBDFM~u zPu8Z;%{Ok89^0QG>Q1uRp^wWOW=-1~$g`d6iw7<+LgtUNm3!;1PwW+Xih2omEUxp3 zi`S3lD{2Te^QTTUk5I#-Dp+sG)zF>qlk$w)D6YVoG}>^rt{=ZRyc^oG`b!bLhMN-N z78CY(ik=tTROW-w?82a0wk=aIcq~K!Nc_qIw;ES-3jy z%{Za7x&5L4$!Kq?t$ol)tD?cKc%S+ogYKe$s=f)OXoyl*I;4HCaZd-|rIgHW9_5c0 z_=&bDwx?^2JP1%88O;>_!!9qb#sF8h^V;(DW`N!{GRq2DZ#BPf-?N(DHmIT283=N( z9RF1B$^*!W@gP|D#_?x%iK+~FQ~yXCddAt?8rg*hsM3g-?QRsR0olZqp+ydhaM9 z--J!_PTHEkO#V3$A?ptk={2>(SPGIN@3z_(tA1^+Qhnz=-MGoXVr0 ztJ4=NhW(Ir2UJ8_4-rZM9s!cQ5`7bC*yCA=M~~mhTYV~ZoVfcGt}{|;;9=s>7&s|5 zi*}VEO+IL(0b5Mu8-))RI2ZK!NA(c|{4 zUa23{q53cZc&Dbhx4U`uK#a$H6W!x$H+7m39`)SpDs~)lJm9(7A+=<@l&UimiuK?nEXD9tV3lYYHB^LJ2oC8ua^Ju>% zw686l_`HCMSTSyG&ig&4Z3VQT^|ac8ez-w{wMi(`u6nQT#R7RFi2RuC$kuufAWA9W zcRIh_;lbBn346$`l9E||iyT=~RMIv})8uLw+WPw6%ZLNt4PT?^$J>O~*lzkUo!!A8 zE=RO?r#BG^J@IR3ircQ&UGxFg22mq9mkuX%VSlznf9*#HO7-~KcCI|Ny>l4kq+DH5 znKh|e$~sB!qVG8E9_6I0A+L4v(@hCuP?>lj&ts=;-&F8smDJjy#k*pJl603NyL5)w zkG=b4^g`r8x!;Bpiw|lSj3WOLsAIw2o#DPkzmrI$8JNE1nQ}{z0#tlVZKAHuV%UGPR=7knG)D4 za(K}qA3vjy#ykVHfic*6Bo|@IfR@fzkc?+t*fGP>;DeL~L{mPlHEB1_)j-LR(mpL^X;PqX{kPq{ zR^C9+lcmÐlPUhhu9nPfa|o(&}TnNl3_-8MQM@F_QbQClw+=@&_a3c=nIj1mw+M zd;}~pdV*a}rKL78Tw=;cjVso!yB=b(MOztPY!J+9jSYDlM4wUW6Ft@DFs$@Ge)ED8 zT9HeecKt|3DUw~X4|rs{eN%-I%)ZsA`n(^Lq~EWBIHhE5RTM)8XvbjyERH?1}d0H_!seyN|XPOz=)DP|rh01qO4kW0K<8n?cM z8;V;hOX>b&g)(}1s-r+WED6g&4`V!{E=;A5hgj~R<#&ecO(#~h_F$ST5}w3n^T0_$ zaK-nq38E3xD92K3+JOkdV*zwhe>3gH3gtjY_vn|aZBcikYqeXN@EC#Ih7&g!&uqVF zWw&SrU1XHa-GKEqq5vjo7-OEJtxEc4K3-%Za=o(AFnqGLV558UFW)nDuK*mv+rug( zhD^>t+urRLmDtLmMfoF}5LxFJU0=Qo8ZOr?MY45!bFWVyc+IH)gHs^QGd-wE)%-FP zbhlQw4p?Ob4ud3VgV^b9^Rl~L$*3D-UMg<=&lLV2s@^gz$}ejBmXNMt=w?7t>F$^T zR2l?C8q{HENr9oe!9qY{=mw=Er4=MbdXQGhA*B1g@P9w=b3b1^_~3EO%)a)u_Fik9 z>v!5us7~>|#)@$Q0`?yBI-EZSS~HU`teyFyGNi2djU8@lkN*sR?eDgk`qsFhyv&bW z0RdjZc4%!%ojH`=Fd>rG!7dyEcSzfx52grs z<6?ApyZw%3csS_DsO~ulY&eE)h7jRVKr1|Q+pyga*5$No9@(H?{JxEH>(n|CY}ZQ? z&UG#>yraax*Qx_RARg46f1j$3PwEf+w$EUF$XqSPCE#tkm~IyKT4c5R)@d!<(w9FS zIahw$atq0oEOt?NHZ~i`+J^4esW``pE+P#-aqzw`_#-L@Vjd=>EtcjA>{phmsFmq_q`?!5sg4C)iMe_rrzz!)cs{7<* zK~`@Djm52GMUuB**J_Njjh*^?Ty(~*1YGu|V#bl8T_-lV``RAd^c%-GD-#bC*oD=h zS{eujZz~Z<`E4Kgk3gd>J zPSdSapPBEr=&z4-M7Bs$xq&vD>1L+p0(%gtM{CFBgnqs?LwDxHN>z`^ ziHIt8P|ETZkpbx8=)wsId^o^Ruf}fr2*iTm;=i~CL!MIn3Ffg=Dp8)VUdb;K*1nX; zMqCqnBm8)uf zcp0(2NgdS(x2Ea&wu&Z9ohG121bW^^Nyf*NS;EaQ#%g2v8aXs5vF;R4K~JGSPB+>X zo8<}Bsifrla?#5+P_pJmFC{_C<;c)f>!ADPnv~gG|1}4GKGzTZ*!yuL{M!$wzr2ka z@=W6u6!J2&WWQ-Geby>C61cd2yH0Wgsv*z2BJM6U%k$~8K+mRToRC%Ued~&o4t59u zdTZ)osOp&RkPK$Q`Ne9_EgZZnNe2CM?ZgB2_?_tNkFh|t_JMd!2i&@4_@c|N9vqdf zNgwN)6M8x0XZ6@5STdARM#VDmwFjl0nthKSy2@xztfio<3u?Q6gi;sb1b@OAJa{R& zD_251^OQjvV5F_-2|MsfS_HOPJpLm$&rue7{FiV3#vG5mk zMB|`Wq`#ZFwEp1vYmjVSC;yfcck*h7so5$Q?~h03Qc!(W-fdY)9XfaAgGG%~kp#G} z?NvER>a%6HTd_v68|^`a2OsgRs{tKEXLKa@2xtAu>e;^Hr#ynEkv_;x+;c&BTkRm6 zoP9p%?qG1&!EKTx=2D(`tDY!={ta3J+dOS~=r3`p;mSi&dJDm3_gWu|E+A?mY+Z8= zUT`ItN-94I{2=jhNppsMYKL61Lv`g%Z?65;yzAb%oeH0a;PzH1<*-E#Ng~oPY(a01 z$c2qsbjH1V`cG8%q({s;(+nx#9x(hzu;`|Z`*oCujF!P~MnvUcU>;RYMRMU^puyLA zUr?8PapsOBh#0CPQevD2*F1*7kLg`$+Q{*8jVS3WPob>IJF(H=2%zjWaM01k1`Dy4niFp9)fNf7R zDYzSuh*-oXRcL5YjbC;>J6^I>xdZ}55CP~;yYVG45+dUYcE|ud3_Z@did*wzu*=jF zN9KjSO1zDh1tzj0@Ok|Tva9^8B%=2)9?xlDJSiPHpbr724y(3|@aJbXcRv)ua zRck)634|w|n^KP3&na%m6`q_#(>*>KTSpypg}q+zd-L=VH<};~1n8Dv%*p2jpq3&V zoCs5x&jT;0f8|g1+EJhFPbe;D#FrPGFq=-J+BYj29nJ-B?M9|??JKQM#0~Gc&TA@< z?p`t*+hp1F!_ku_rYhnU^<6SPbXlJyv1C?Rb@-I}#S_*#-s&zj>XNd%ob;uQpJ8rm zv28>j1zn-7?$SVkH8YI%mbZ0A74UedsP*b_?o8nH-qL0yz~2sjeDLuOYVh!lj86^~ zM_79Z4yiGwM2l6#VJ>P%HQ&u63laQ$Ccj$`XzaE-)X`u~3$Qga`*Y#M8^@_kwRX5Z z4A!~D^~L(^3l?a{X(LyeE_E3K3K|!MPA{FiYMELW1pK!1AF5cN%RB;ee(Rxt9{3TK zA{9JqxCXV6bZmTQQ=}Tg56wPm#QafkZRX)-(%9xS-;DzV7#|rxEbo*7HqGHD>L|(T zCF1=*B6i2Rua|nOY<{{CCMX+O(au%jikG~eS@*G|j-0f9$sUz6VtFWSo}zm(~>ePend_MnwWcC}W39vNq?XVe7R_%ZY*P<4;9uJHNwf7+(4 z*h!FZIGO-Y$h#$0w|BdDBwn}cK`9zrziiAstv0p_+kh25_;O2cNf9-r$bs4Lzepy2 zRf*uxBJY`TR*AalK7G{;_Fd@Lo+=1xSpIxCh@B46(m9G9jq&%xB_H%GjvEfr@lqa} zAXCM`1Ykjo$ozGZJ4^}oCU&?dcNo|Q8f-em65N+lQ%2#bMD1!MB^^nogxoS|(d_po z7^h<+`3XJ7Y@Vej%y6h{Is(f4*~n@}^!w^T zT}k9R39;fl*d_XFuak}`A2tMMP#!w|@hEwtBy-@@8KfiwYZI9KnL{ol0Lgk^`%cvd zCfQ?or^v}pFY6>*{t@3#d+i%JBhh{CwUa4ScbN$yZ{=9t5c454WpBzp_YYY?I(*zx zP?xb4%SNqWX>dsg4Rg2T@~4w{9=7%d43nnTAo?Y^&s@kZ{;?zju-HOU0CpAdkE+M+e%~4)k zlcG)gms#LWGGNUl7FvBW?R7ik=%wU>k;DowTCSz|XMCz$VDLiUOVha!R$2J9@%t)U zgxLOlG_C)DPI;m!(|&QBYfspBsb}S%NUJDep4Um0S!|SX4H7i(J?TZP=4nb7D&yE` zBsCFbDkQ#&^~kPH@4?Kw8E(yU?;93X&3!X^cV@{lmSuU{h#sxiZV*lm0R3v7s_0zb zeiPjRF{>V(a3&zQ*Qvo?j7oL;eY%38%WJQftx$u{2$i#^S!mHF@y^}@T&UzMVa$U_ zEz>?ZdLuBknwHoYF8Gh^6QH@X4F`>yLejd2p8}KLcb>=J?e3BmCb+`dwHn&APogg$ z1Jo~(jetm>A?bZ+$a+c%!4WaR1;>K8+9BE4s zHSv&qywAtk2sf)OSb;c}f^Nvwv6*MG#_P@6j&k`u39CE!6YZ@QzX+iY2Pfj7$8+0c zS3%6RRK*3cspT};?U3O|wX|a|s^F75(>8Lwejk(HJ#hiZ8k2jrm8vk++7(2#xq-17ckh_%C=P>n7N#RY== zW1%x^FNc7>{Ziinh?;o{6dMIAE-)$ad z2YuBSD5To@;yD};1B_>@6l&cS|F=3qR9L^Z^^6q1AGD4ag?sB#J^%e=+cbnzy^!0K`8Ycw$QzasU{Z(9Ev$;|`A&$(80ppzIC#;!j! zM%S?;16su&qvz<#8bDU=;d^iZ$;J|q^jpOn(Ytq)T zM&%&v7*?&ab^hp~M@T!W?B~I0ia#2Xdr1gV*p@Y|TV&iS3C%Fa0qhJ4RO>LN5>rK` zCS|TPmd9ZY-8|rPRRoLI)6-`K2PJIq<{&0>d7*Qa3(;?0z0`wr3}TaKMvl{iBEWVN z-4REBR0dKunMVupXAGR7J)z&hv#0kMRL`Kw$1mCh% zr~xj+I$Ugz*aggeLxBH4umkHG!xqJ|KGizET93pvg zH!kG2v%{IT5qP0oza`2}DuJ|=;%`OV0l=e(;J%$+@j(!kIyx ziO$yVOeIP~VF6fLMmoFT3zGo!!&ob zcG&bunmEj20*RXUC~`7XejE_fyaUMDdj1CFtwlWp7Fz2sz~Y0~>u{C&J#vVM2ktNh zxULgc%W6ljyJC$Chk4_W2yQF zAoz;LzeVZ8w0{`RT!s?Qdx9Jne}GWIk><9&MEFM#<~W+yjDRrZ5i{C8X(H)Z><%Ze zLU^C0*x}Xc0lD1YczX~+pu;2)%U5ULnjeh6@uF)Kfs+zUUf(evj*N-u`Tq74r4+wv z4e1Sk{Xi0+b`jsd4d@`hdN^zMQe`eFu=tk&5A7`i-_C5u+7D=RF{dS_J%7Pj)CXe1mT#`wcl z-_0j{jT@TA;NI-hnu+BB=Dx*s6=}nV$VRJ#P%~g)Q%VK)uOu(%0H=Jbh|TE$@Cb(C zsj~44VwRk0hS7$Z%#2PQpvsG%fY3u=G18#F_B-&Gg{pv(X`E&h<}?jH;{`-XVF8d? zfqxcoN$Quktn;e8*|65x@Hqr9fE~->#&p#*A-}zXCBW;Lmq2!Tk>6b1xiu!JAgUnjzuH=OQP4W$)-%6KjA#$W2?~l%PBh44_k+PO2}6Sf=*; zl{V~s>n3ga>HzS>&cH}e=Tq{3F0YaRHvD3*TJ~R6V8c^~>Rnbz0Tixgz%!+gt^=!s z+UB;-YZPP~DY)N{rOpl7G)6b$NBa*3f`aQrk*tQKlsp3pFyPwYCg%9%28Y5F0&Lmj ziY2BPGQUtG}UE~*Z&Ovc`>)Nn1v?I$;vH^g5Ul0U<|bb}O4enrfXSs22}O}saAaJuwd zP6EdJHn!eRdO2AM_zBAcE|7V)BEd8>sSjM{oO=>P7J+5!{|vx1eAd# zOCuTpiO%i?YWJp;7!Xt}R3K*);-TU$Y~~79&hk&{s*Id}M{uor7))V-T5|pFHV)Pe zAUpO&{*tXq`Y#A`i)U~?glYZYF*Hr0Z6m?6DZUmsyL`YRLJ=4HxTR400rU8(i4Y3c z(lX+<2y(=T(5_2C{x9kVBJ^re<5lImSd&?=yeEH>Q6CN*8XAz>gnDgHB8Wua2fVM! ziQ?T|$SBq}<*OaH!F_gqbFtCdSlqN}Cb0b)_6Il-@81?cy9y`*3}r2az0UMsZ2%&c zdeEnCA2A1nB`d%Fj4D+I@s3fjo%4Be)yj4(rfg z5^?^}V-xo0g@`-*(e`hqinJDu2o8Ovyl9;IfC3VCzdy!XB@{}cfPl4!Oc2!B=1{+$ z0f&$5HJHV`+j{^Y)|L2c&tVZHPKByslVT3<5t783ZYWT*Zt@_un8Ata+xNZv z8=*)R3wmo0H9`5l3IwEfX>maDxw4R<>&r4hL2El!56$>>FX+-2mG4781W%fJq~@uU zW15i*akNo8{#6ZI=;_%W*^sHHp4;XJxLw@6Geq6aPfeYhzIr8w2<|Sl`z6&`a5c|+ zU7`x4A?nXC61OLZ+ffhS+Nj4Bt?gz*%rn`60$$2>KfxF+M~|FOAV)LX3S|*5 zPJ2GAqtkPHr?FQ45f`S1m=}_9DXv*ZlT*DyJtp~(%-cwNibLmp@s-Vn)2j-Ns7mUB z3a7e{OHX?vamNWqULChnS2k&kzpP(6n_*!mLw_=MSSbfo0dx8UcjHaJJhw&hx=D}G ze_;|3QCTtK+S%?^^K zK9B@pHfk~5ODEP2ckkIzXZKraI|Rj3Y$`AW4WB(ZfIv~$ietQ8In6u(F>O$%e>AlF z@4(S6rZu$siIV@T!Uy1pPNC@vW`8(zP9T~vM6f8h$Fks&ax-`U zb=b|>W9hip`5s{yMPGkCaY?qqkOUBT5`^agEHsBs?fn4dxyS7tYkxYR*k!$Ys>&YJ zWVVCbqL%sG>WtMQZaXDXmYnJ(dnp3-oamKpGf`97F3kS-OZ~ds4!|5P4s(jiCXPks z{4wKCA0pBrwpAnx&u#;bf^3N=e3)PQ^^yEuH|If5z8r}@@)lWCj!R7Ifr>QRet`xV z5CX4{3n^r-R-i~Dsxu~vo)c_d?I;oiZau%Qje@e)g!AiJrNkv@vxMc2MB1`_*53)L zn=MWT!IQ}*jI^hnAK`n|K30hr(C^*A64@;8e22$2=I9sXcx6QSi8 zyK0kwrroJaT0$gps<3Z+4cdy%v1ffKn zQ3fOERUK1qnHkNv=2{NSzD@UF9I;C%T39&#Z71B)p~PG+{D_LRS;cD#pEtqy-SCM? zW)%+SpAzV;w4A(2<*%r-O7pH5gemMkG6NbM|5IH zV}tC;7$%zS1$oR#`r@Q?UTLZQ?uff;xmUaM>bx2LjCbsLR)#Uh(`4V&n;3M#3?sz= z5${IHe5DyPu5K1%FmG@$Qw>Y*F!9>xL_Hlat7NXO!!h}x0)Y8gpKxO^9chflO4AvLl=j-LEYNQxF%(81Xgn;#lG|V7IeN>e zZbb}oH@Sev`8q|SFFI&=hj8)wNo_aJN-ANub?uH2=^p?zCo zt9A|hi%B+HgycL)l?Jh))3l;j@pJyuSM%MS{|Hj|@@h1mZHq z66VAUY_~)*w>>zYn3Lx{dH7gb0QCH45^EHftz_4}3j|_x?VYw(mhTb5d@L`#ZGV43 zk5tLEgY>1$LY&s14jCI%pj>gxS+NUEjs??0d|sAQAL_z)w|XKT%y|;9 zc@8eKot}BS_=c!z47#5!3bYo#nUFaMHD-p734PI;i4wg1@1A;#RiX_qI!3$fdhd(_ z?Qsv51Ldw*?vqiGwyI?v6-fybAru_e*uIl7Gwjt1oNRNWJlP_gkD`oQ+1ra)t*9TD z5^Kg!kJ97Cfs^Qmg6ZTDA}N`y9^VWMuT}PHMeIHtr5%X@Rph$ZN$U=J%PK=WdBdkF zIY6C``$Ts_YW#hb0S8jlKU|9mZanmI>PMk>B>qS8q*W{UyfG zGQ~p7!)=H7im7zK8mO#M7X{ExM`|Oizy5v*+@iWVFn>=*5mhFN` zA;kDnH)oQ)UU4I+Qj7zB>{h8fSHw_lSb9LlTzz4aovX|KyykJo>8>DjHmj0Qh*jd%!vkH0N+$B| zq!(lK+IUed8N;ZZpxCj`4CjoDp;T%=;+ETBgTs9UnS~{u`BuDFoEb=BpOT~a2?7ZA zR5_q%t)d>|@y5ie@S5r7EZpNx^LSgKAMus0H{aoIQ?`5G6Gv`#a#|SCIz89h<$c6uL zmEYz=#|j9 z9)Y=-(IA;VgYC%B(*7xP(bFPfGxwlotAq2EI@Z7|;8ZfNWf}>uPj_2)X;$Nu*(FDp zj|K<+;zW}KD_i8OksoGJ?gdU6V9Bc+avuide4K?^Yamdm(aV-^nc8hOE=JilPMX(f zpixY<#cQv$3}o2P+dS(x>ju4Jq(MP!9Hbx(`41d&1O%V#{o}SHZ(H3~hjnC9T{D5z z(rAWMZ}+0xD)BdL|4YKWN(K!R4|x! zNU=We&TZa+4{%K=pF5dv3$JdL)X>tdACHdQb`t@8);65{=S!`L$Clg~md?##N2ps1 zn+jfL@PAol8mQ8KiCAg35f{(T6bU)&pF>UAbu~Wstu&jceOgfA&##79P4$flD)(_; z1|WmqLss9?gWp##U$4}vHIZkBUs1Ss8D79*{Da>t(~%Zdb^D=33NW9+cvx{!gC~`A z8L(zGGsSlzshGm>wNn@92y7#-$U55Z!}@I(L? zmSOIA=(o|ZMn#+EAWjwTZyF+2b4sdpcyuXx8u-=qL|^m^A--13e`J$a>%f=ifpwG$ z@=(DiavPe=(kFqG09NVohKjcZf8FTl;UMWn<1J8+NY0K75$RwNXYWemb<9;%g7hqV zcj#~8qzaT0Wdc}2jbv+%YOhe3j))~}r_+Yvj+|YKlg6%{=T=&C9F<)P6#>QsCET^> zxp)_?#(+2sdi}IyHC`;iwsUto{auhl+ZsQE9+FisCRoFv=nkL^Re&2n?^+a-LBpI$ z)t_9qK?#>-0~p9YDT?0ud0<MX&dp@^a+19isi*EkQ%4&TCw zgZTEOc$(c=<%h+PSEXvZL{y|AizZU0;|EP;b8rRyI_z_l{DpK8v=aa3{-d>w<`P~y zO(Jl)ZIagN9=;Y>@j4^njl#K7*gH$o`a%5qxlsZtIZ65bB_)oyDe^M4ZMB5o^p8N*@SB6b}a70aP+!NAWnjLAp@1ryr~2sUZ0Lg z4Tgl2EIEs_nf>k6p~mN73UlNZP)&z}3350Mr+7^f{SzJv;lM|v8Y&1kmc$NAYfovZ z!_tgLPBoz;9hzRip(VYnFqvn$JNpU6T$8H|7B|zOS}zy3nmX5Uj>d8M3pJnx1(?>I z(lBJjfl_~c^Myv+beiOu*E~f)tTi{2eKjI>%4<&{40ZH@0?qi?4r@`>^N|tMQ|UO9 zFDX``vBwV>7FiXcVx#Q?Fp^3&Xw^XPp3d%>m2w;fGdG3+q!FM^>r&eH*eg3UiXr%$ z(RKOGvEW-P7qu8xG+EukV$e}%CF;8Ka^&O^Q+Y|L?#M&d%~kWV=RL~FhU01zf|hMc z9##wX;UXJ)~NI<|p*wZ!W;;RJTiuOgh&cuN7#^vUvv+2H&!qGl1Oa&E^A?o+1y~~+Uy`pPDm71_ZP$A9xd=4d#Lw?Z(p#Xlzbk=_NQS}n|U6|Zf2)4k#(zQtPkz^l%&y7%I$JxDVE*0#6oJ$_fhwf=`1zz=r|+NZ-Z(!M82Hk^HgXo zeJ-ZMXHH(r&9A-%y)@@C#O-^09;+`pNgt7Oes zGY)Nc%x{qI@u|!FYctMPM$@9F*~33FgQ^$Vpk0xpv%X{gmsuAab>!?ofQZmCzqcCy zheIrSliQfIv^c96e)xl{AOeV7bnU;p85W^XadrT{ULe|umuAfIV#(oWP88Q8E9r~s zl9XCi0UV+XbYE!)=nPnAe(qt{(~rsA^0x1Frh z11nNSB0xnxG3(-b_k0vM&j<<3UE*dL~k~AgBqb-dnRz7cZMy$TpMRlxcJikG+ax3l;T**^j zP74$q42mnFS>lLhnT1$Z<>;7`61Tih5nX~eS-I=Sgb^UF-C{px8(4MVGLTtnJ^lKOl9QXp)3!Au;c7d- z8!8TB9F-zqhDCsy#caHQk(HIUwOd$~%j?bhYd5rnA?iEX60~Qq!gs~%!?HFp*^=_o zX(5?*!>QF8REBa%N%rH)6=8b_8$+0YCiIDlbCYJ=vurr_JOjlv`FXkfrZT954iS{O z8M)Het zXqQp%mh-X{-dPNwSMM5I3N>?dYJBjCj*%*r05YZ_Y6<=FKAZ3sz?G4kYxmssBK?1$ z?{dbPtYwjgRcaf5qEq6|eY{4?jZ3Va_LLPF24)H500Tg3QwKH16mBUT`KtRDFS^zJ-4#2^=&?px$9YiQD)x|%u?_M@oM+PxP$&Z#Q-|E znRumjXA?(e9G5$3lLw!dj_fxJ%@f(!XHteTS17`u2brsK1R)zK0?voWlkG?;6Cx>j z{)QK6k>(M9Eqq+<7K-s){d9!IZ`OeQ>qOgC5P;_8tpu&3PIc>H4|RYVN<@l;inyJv zT606rT7FyZ*Ltfdg8tIvJpV0K>V&hQ(x}AYlwfFlf>${ZxM|%c$~1-CtQgiR;mDHoc?#59gXI7-x-vSVm z4DD(2C?-PB+pbq2ek!5IoN~d@S8D%A&lkaUG4E^?d1%M8-0UVP`0CiMVT@-2_TMJ9u(_?tDk&m^Hf5%(M>+^sfyOgWKc z8udh@CysRZG3sS9P;PYye3`xWq~A&YfzH%jG#rn5)~)UcOBw{i{#ZDOta)pvo$EJ# zID&9DQ1!42sg`BdB`%~Ec1r5b`p4{7y?tiTLclQCWNxqiv7ktZw4SU%QTWAMdxO(ZSekb)ivW<>X*R57P7^;9g zwI5M0hYEl3wkk^+Qz8s5aQVSu5mSeCAW>7(GOIgpJZgP1cmKf7A;I4^4W4MOUk0&z zBdbN$A_8j-bciX@Z5o_oB;UC>-OZY<|*%7UG^|RQ&_AJ4a70q#NCl z`)z`5hHQNmw>0?VkQn#a@^zLg}eTH-l+1bK8j1(Y_Utvp^$p8 z;NJoE{;2OI51e+-|JWC{th_3D_)+#>%aFC$&GI~L?QJ#newA*ZYXHY_*~%UPJq%() z0inLDJG%Pb3qHKs>8|Aa*X`T4nddpz7yVw6H^0q}+U56adH0>obhAslx+}4RUOH(R`rie?a_5QvW>jYcj(Z z#T5%Qvl8`OD^vd|*E=Wh`b|P1QKF@t2%S{s^GClx>!qrVoHHejtxlx{+M#dbWt=DY zy{pifM(jri=U#e|R4wS3t+j0{RWi6(gpLG|W6xK+)NV}$j?r>OaIR z55q+bfDLVcR1tpX7n#rq%9m*2nQQt=fgs0U%Ap;QVCXW8gmm$ z=2{OJhXV^{ry7v-T1B*@fh1~U&j`BH-8ypg(p(f_CwiZLvgsjA+!W9=!POWFMe{mt z6Kn+t)3G*Yu6+_|cgY6qPk>TjW|O%TZce6Bt!4PBnW>L)AxKRNeh?&#yK@otMPk*9 zsx8;-U^NHoL@_pCa9mC@#_JHH4`KyT@ys6K*w(Fnp3J%4f}pRpX)dW(*s_DnMGKR- z0XDFU;sdW)bEk4NqwZl>zqU1%Q^_26GeQ?=-sa!J^NC!!S-`~)20x#w(_eRN;M7?C zvj6qHN^#sv5ep+wBVmgmEIr5H+7+P&ZvIqi8#z>$bo`OecI=z8pLqo1wDtXqR1#zg zJ8EPFZx5)DSLEn?Q4!=F9_nBPaN*A=`iKm^hYd6oc?54oRW@azUN}|VfR7eiyZ*s2 z9cnYvvD&xnwZ?zJix=Ywd#Ko2Sh?o%Rr>`A#46|~ef1SZfAoNY{r;+7pB#FtocdDt zka-4~9CHRX3^j#&-`^T zM+CPr1}P6!);o19`V-Fi9GrTIB&M1Qp5KmgvpZiiR`y9 zO4*Ok=R&T*b#}S5O8pH^<8=So%K?rDf58g1@rMM*(l3S~hMS_OPx{`};f!@^~544Ox zQ4MK|?$4o7{G$T+ol5wVmmXdDKdIAgQ~EI;nN5`J#Fv^B4i~gFRpdJ3By%kGrBPg_ zL1zLFF`&RiH|mV*#4pj(9rE}6ro@;eaZ)+u#`_{{WdRPUvgP|)+E;K3b=U_m_<%rj z5!uBkNVZ}D4qzm{O<-->F&%fGM(6feZYW7{t( z*TjDMBDIDbj%j*kVnp7Lbpm`j(Y$2hbEcoqgL-$ccKH4>UffsgoM*Hc&}K5Jm9AL@ z!Ww$6RK_av#Nn_FW!?QH=&7Cs<=sf-{g-HAo09#b9|N({;RY9z_;1*GvjD*)$M9m% zufS0+*hc?h$~sd74yn3j&ig7Y_n&K1@-9xmogSE@;HVn%hC| zHAhDnEATg$i>AF0h2-Th8<(a4n7BA99ZB$4I9=I@RuQk6QgU)=GBNB=rO z$s0I>zi7>xN#64nuYBj=Q22AQpZA>Y&g2TDn0KfznM#kM%2;C&O+2J)A_wUlat!1h z9XeC-H>vp%j8ybek-)FQSv8^wx$*F7+(4yY-bEs#z*R!_kwd!VkK4B$8_)!IE) zJ{57LURD>D0v5coS!>XotjYne!oo9@QZNhu86_lfs^m5sVj97_-)h1KvqW zUOrcnw{>_4_)k9TUzAHv%8Ea)0&BdH?O?9bV`zZ<=(*JLwu;fhPul!GIe4uor|uOl zR>jnAy&Wf2yQK$8Se0$3lowU43L?l^44P#HmxkL=ZNwS}aP76ZCSSSz$wr;}1^u#& z!k)}M7r-+m+MpsX`^&P1MMfX%E&}pt+}?T2h&@HZfmJclc<9^Y@8W z<)zgDuLRXQ)O;|+ZcSNa%arp)T>j@HrJ1Jejety!e6?OK)JSd+`|>&VQ>?8OEe z^U_emu^cxowSxf3IAxkhy_r4MzVIhqxsvVxPPP<9$aUNO`9;wFHH}MBke!BJyq?kK z^LHG-rgRmK1*vi!*bEs*pqK9;_Lv-^gO9{mWHsV=4a@#6?!+@^19sy~ zQC|>0I}lVc#niurBRyP|nEW@{0g;5lC=xKlnqR;SV%s0r_1>OruXZ#Z=ogbYWukQU zdyKP4Sj_ZiC`kla#Gk}u))Q_om-Tnmo}=K@VT@KHVMBz&d)NRdohIskuQx3ryk~?* zmG)w~fC~`2@E8Yh)Noxq9lZ32I<%E(Cf*>=_}9&i(jT{tV;|OUOYgG-6!_eVgyOIKhvHq6YfKWK435O>E7b z0%%Yxbq{WZu~i7MOaEYz6AkWNk@3Wk9=5HX%kvrGT2)_$>Dxc)Sz374Y&&%Kc5*=8|o zzUDCTS=FI(Nf>W&F|cB;OD3^#5V~OLFJE9yIHDC9f2(+}`KcSa(r+%Q9~Ljl`8amf zQ*xj>Ebm)?Ph4^AsxCi1Mp*Hoe z)as@tmz&=$9vdhbj!Wb476j{Fo$K*++t~51gZfrE(Zsja;!Z$9VFDUTjwv4LP-|q}1O$%(&bLsNqWAhC{;^Os0ia7iB;C{jZ zLuVmjhDzgX@G-kJflDeuasrck0*hW%1#+n zhIHM^tL_XMJwB+gZ7vUxYhSUHpvOAg9Ek1KZ4ZK7!Fww zw#|@;6yrR6y!YjgegE;UMM7E)tkCXTc+~W&fuG2^4m4VCF=zoJagJPw@p}e z!<>AE8nwKtJ7!E27^97$Zn4@CTLTvfQsYhx|KK{DIrNW$S*I9ZU$$KAtIWk3b2aXaR6 z))lKcs1Iu2xW3rjwU{ZTSpj`rTr-7>pooC^ z{m;y-J}%1qCoh3UtoxS#^&jLc8}#flW@?odLOntep|M{|)vnCF7ATo-ATEQ>B6_nL zAfmeC1V&`WxEg2({&q1Y0d?Bg!V>pLHPMfjjha~k|pLyP& z+-G7{V|0xq_ZS6!<8pF4hU^WLnk+|pMuFA73gKIGw&DxI1Z&eI$~j@YoNN`je}%;U znL&NSA@m2*+m!L>k@J(TO~9-C8}ZIUfl|d)P*a$jkgDsTOAhzRCGh25DWJRhD_Zb1 zMu*QE0nDE6!|$`dxqJf#A0Zyva0^ijc9N^l9@8Li%i17`T+)32n6%G)OSjF-T~dq( zkZ+`2)J12z{Ck<~H+d{ z$=7aES#1$1H5_khHY2CwfFH%W^JY{S(A+U?DA8G>4P0!sT>hWW?g@&}iZlWA-DY}* z?@D6sdqW=U0cxyIG|ZXF;7gYf`15*o#+OX~b8Y$zSWGa-hQ_a3BUFHg2%>~olpz{{ zcdR=rDrJ zM*3&MpYr<@lEMi;Of8`;{<=ygO zZ;>9yH$RP@iFq%WvziYu`XjON(!Km^=p1ZEgM0G63*Mn?fwK}D{|qpYri5d6XZ|-z z@*Pl2m8?+fsjhUS-urj6|Nno=ecb~hvkXqVtGnorf>7o=%}UiVWbc8bk8y&@cJ0jU zI|_mxV1{|_B)Of=CDFQs5oynA=eRfL!jAmd!F?+>U*$H4fNN7kvrr7p+cCX^7Yx)L zA0W-YsacB2BIk()c)0QJ@x)o}N5f~6!`MwRrU##_VN;n^erZcRJMt?aXTNc;M6$6t zuiI%6voL}WT>jmjhwNAC^yu}N0<-fBAaOQXyfTnjy_u4tV)I5pNEhhlE{XwH-v4LU za7$bf-T$A8){fODv%2PY^%IET2Ds0M&kyaxW;|hyZ5io*_uJ{PFJ5EP*g$>XK?>B9 z!^KecQ!}qwV$2{ydurBB3=rylY+PgXcOb2PTNal>kP;cAUG-!`*>#Ws{qbp$Q&yIY z?Q5?E+h!gq+uU^$Zu(wI4Ihh6FSuuYBnUi)BTcsTxfHSGMR?W1LTXp$^FIZ~|NAzp zt&|1(5*vnMEuOm6$JyL=+N;K-AvE$9qZ$+S;#`~LM0YSmrS%$jrI1&SanD%JK>VfI zJA4~0gz_J?O~dG@LqF3UwPnV=5M3I?2U zbr>9)algTSPk&8vN_=a398k|Hu~8)Pp6LEs>%xr`efT==O1}uZcnmlk{NFD~RLf}# zy!*#g9|FpNCA@BhCo5KTaP#nE@5-$}Tr?}9z=vm5DS$h&4xqcY1n8klzN@MNUH!q} z4~NZ19}UM28`6J%32w3C^0#M4_g!s<6Y(;p(rN#AwFnw(=AgE@`g&&F1_pVTVbYb7 zfAz#_`6RIb9Sg!?e8zI4HyMvoS*MP4(Kv(uzAsTHR|YM>johnRKfCi*X9Y$dRMCK+ zT=C$4ePbZ1$W!%&Ji1C+Q(kqFSBR}NF?f!qnBWnLlWo0(y}LkRW4sGnj?UPQV;e4R z?jW%rFvY~Cp{YPGMWs>x2V?=RM_)ztN=P| za3lEfV@h) zL#dy!p{Co;tyl`g>4#IZl1{*@3(kNh*`-a#t%*7yRA|1+LiPunHft9=#0Gs~$-|RU zk$w7~|Jx@2|6X}7U@Qptfu6UmfTwPQA@+^-8@D9Tp<}8!)drs#+wIZ6{^VSYM`Mwz zao~T+rW(4&;h+2EpYx3~!hS0N5&Pwxe9JnAtfwh9#VW3T7Dw0e4W2n39RgluZ2!4# z3O2=IQWq2AtT~|~{EGV+L9h3>^^mo{EKhj@vK1Oc!GPsffoL`Io|IA7P);AozVDZI3Dt@V6_#V|WTxH+Fm3au8NVs713n@F? zK;I-fcLmOd*I#H{M_O1vc~aMZRD7VBNOfPv7vwm!ECKN+l`{ZTGRr`>>ZqAYHop-*cOi zl6s7Mcpm#o8=_I-l!wVi$c!Eb^3Ew)8+Y0_cbdUg3}CIM?t;g-0?rlR6iyI zvb&7$De6oV7Q9?hYEGnq23U4_dB!`h5@f)AV_=vq8I`@7~>u$D}*AkfRoCMmPff(38IX%dyj4g1kpZR02EHXf)p=wRB2scG= zURUwwZCgq1jjA_M=tiI(yPf{uav8d9hYmr?5p%D+pLKkncRSvx17Wk zuPy&?GgAo(Iw`xcfcSp2Y8QS(7cW~ueXM6AcbQA8!ejEW$^3s`3nK-6&E6PwHilVe zR9wOYO92RRiPpD*<`(A7L8uqPzl95Rjj*sY5J6OfZ^^ltRzdszl{=*vgLSf5Bf(wa-dy-Py_xZXoAOv;dc(=z{^-Ms{_nNTXK`@~&$5d| z6zfiBb15Y2SDX6-iC$9hXIlTBsU@d!d9Rp9sr#>141D0q#g-@*p=8t%FZ_&{n$GRl z4u;Ejd;7DCd6yK=M~b#a3CKDA0MW$o;Qh4D#r*=jjmvV9MXk9*={Y#e1SxY!`Mcl$ z;x(r+D~K(Srmxirz2$WCVSec|rVWo59OM;Xdx~r!XL$LqUsVAVOOQ$@f-&BaoeCzV zwN`_YgR>{&GnVs`Ncnga#PL*!gao~3N+IYeR({-`cu$i^m-&I&3P3a3A!iTS zN4`y{{U{coooUfSHueG|Nw(9%6Z(f-MzRz0ZFa;StqBNm6S}uzN!i z!;D8(|I}b1pCCoQG}AG_bA9Vo8g9Jup%`fb8*OSfNwbT3&=_8aDN2iw&8z&Ql6!0; z0oTy8Nwu{($lM>QY*AUJg#8f$!0))vl7IS04hCl-op7K*);8u|;|r`a9tV;$XEbEf z2m59JF%=RAX%)mf6*G}>*OVUzpN7X_tPA6VGDoKFbo7>88@bR2`6)2Do%YIp- zVIbf-FamUktte5UAa;$YRqu}qe?v-Jf`ML@gJIHox!P>ZK7 zWi$4!+7XXU;2Ut=u_)va=Uo31g%^Nh$&n6ScNH=fd)71ilxKa7y+h=;iytf zm7m7zl}Ba9tm~>Y#9znIZ;wV(z zyBnsZ@jIX0!e2ptIJ0;&@RmwdzSYVxT|JwO1-w*i$cl+N_;F5U55Ho2nqvj|wC8zN zzI)av(StABmKD+Y0YjW-Zs<^Y4F$a66Eb7>{ScWvmlIgSYP``pastMi+SQC18v-8J ztDJ0Q=N)pk98G~g3AH?UU(;~2OF(&j1{@%`;E(HEkr*JdC$^kK1wDm$@gH)tZ`B@* z|8cf$KqySyUg5UJAW**t7lIOvI>fMZ13H0sYwqgOG!DTVOPcwNQjO#Bf-`q-dZ*?k zjxEqWvF*Jbq{umXwSLD)o35waLD4X1PE@$&*~GZ%Bb!;ta{(Uq?e1p_kop@Jw9259e9#X}kVTKY7Q%UI`#*|!;`xihN4zZ*C5)w(v#-f4+$4Xq zu!<7{K+uw?3_)Ij58)Sv+_wM@$9p_J5jlVjXz7xcYx(Xu?8XkCgzJqQ$vgxbqT{pX zsTGw>bhX509F5rSZ-l5P@~#A{*zpuujX=afdtE_yK*X71u9QgwS{ zsUNC0gIbLmqMlu}auwj{7v{$5+5jki3z(>{mLOF1KOSuj0IJvF!||N5zUB>suA>W! znB%>iJ@7MEzdsnS;`YQcyqIRZv4q61%s&PZD6M0FaA^e%m_Vx6z1PZTCFNDj4;3Jx!>?4O zp()BIzYTpHz``QGG=9?hZ`HMp6gfFH9os0pTADP)#ePMvOc!o-VDeV(ocY{lB++;& zDg4Mt{plynJzFTjE6aqas)GlJf-{skVSAf3TXB&oY-47mrZX=|iKihG(>q{J3ORF# zC9p6ITs^)Z&vp}k!{~V1|36i41)F?mN5{%<1qMsuoH4eu>$#H(2fY=v zxSS)_W`WnngY&VUfcf6172twNU*k(pP;LQAQSl#4(j5 z?y?T>eOc!ae9wda8Ql6i##u=dfC+fsaB$(#iXo^+XhQy)WJrI|k%5QR80*A5>1wl8 zv@mD&S@DFZ9!*DosZo%A_l5r6FOuzIATX)-5L0wvxGU~@d${?*8D9ZC??PuEXZs0ErRTG_jj1|5Sotk z;qyxHhv2<{pkc4dLu!94K))v&mCls*Em7ms%;#^Ww}bAyU+U;C#}XtPkUVu?fporH z$Y~x?3HMmgCw5lP;%Ph1CLh9}ob^h;>&8&fcqA}aelK;`Fx{E@vbp_nU|6(s23ry# zJ4gSGekY%CXZQb=s}=IEqQkmLYy$277%-ld?|h_Hl^$wYUo21*h*BQB-q06x zRwiuZVYEan^l$TPsY!mO4C~GL28qld5sD3?nGll-Zxd`B%}1?|2I~<*g77bR9{t?H zvJ$k~KI?KFYw{gXqKkJJyXWu7&;4bjf07RSauV|FPf)FAI#Ga{OD>Q!p9aUH3}J-J-AGjZU;%=?0qZR3y{M4!IV``>yv}vgnrP>6 z6`wd`usJ5q&p<7`OgEfJ59g%_Rz7jQNNlX0OfZrna1U##IJ1-2XX*un#)3WLNt{8n z!zhdmI=v=#DyZY;U-8YaoS^Be00xN>|0byFP!wFaCJ8wPkqufI6!J-obi@O4nm!jp}O zh-QWq$FKK0e-DaC+`9%rV`L~6w!f+oX!P{E{ucPp)K}zF=Z5Oa_a9$$0CYYsHINAs z+V?K(V2A^|g|`88MYNbQ#Oqm@F8iVuerYaB+F4771(P7!l|BH`^(yBmcA$V#WRFva z4tLIaL+|g%tLnSnbN4!l9)l+LLDxd>GK37I|K|ISx!~>F@orLSi_ikLZ0VG|)0A5L zE3)lz$dD(EKN)xRcqT7grO^{LD35w@ybbxOv`p4;*Y<|IorbtdV1?J`<_9ETBSmrV~6h?NeERQ+nbJB~;U;vd=O;^A zsDBMCo~)%&Z>mZLqXa1F!mtRdA<#FtN(1J(Q7_FnZkef*&0Ugrv;|diZW4Ct=g~yB z+;*n7CGs82!E`Tsk0i-9AP*;19~p|-F^0s~6D=Q5K|Aj+EVaZ^03y#KuUxFy9H;Lf zPSiGNBN%o1M-Z=mpdwC0Ag1j{ZqFqsA{T-0ipF>vHL3WKUv3{K?o4oAK9+c&IU&?W z7y)4+4>{Mr@497`53Or^I)s*q94HhW%1LetLyCtW0w*|-POlS)PUEMJ= z0a+N>eBK_)XcCj7DDHS&5VyG})Ypp+rI)P{;+iEMGN8E2{wKaM<|i4TZA7DWN-h6_ z4lY*#H+6&;dH#vEpT%LXYoQKZc*;qc_@pQ}l^_?Y{lgAnBd_z59Q)cbkXAnFW}tQm z2e~;ykoon>Ci6rQ@t+o%oRWQKh}tT6E!`DJX7W<}S`d~41|fTUjfav~ zH^Ugm)n~l%<@Xd`F_S7|h0}CVFOxviNUqUhe}#tr6_8azndB6iAYo4>J6 zbPqE@3^d4@LccLboRt5yhP5Z^bozMCjQQNx#EBbd277=T{XAEp3Q6B8_xB-ZI;h)UL}j7aLp~%~$Rk+{ zd#2MTeCM|m&^09q^78nCTa!zHJJt}_P}`Z{4NF*xltB;I@vFnM`a>ENbAC!1E7ibQ zEu_(N*NGexN$`3d&ryL>tstvAuEAVz?2UY62R>U4V|0AqT6OfHkk)I1Bju3M;mLdZNv6O!sGnO#qc|Q5;WT2p$eg_R;hWkUsbo7{_z}DLbyyEAs9gIo( zj4Iwp5YFFk&uQR6wfGSS%1N*9-xTU|nM7_hfbCC|@0O-rDaX~*d>+^+?Et1|55Zms zF(bFLR>%vl&TT_I<`IK&tHpr8C+}<c2YvOpMZ6nE!%b$ z%iz*4pJO@*!{GTK+>JO*I+w7bVG@&g2G=gdnZw7STAKe_vR#7}oxX~V96wFBt0kmh zmF@{WCQs=z5OH?3_MPMKIzF5+*2RS!2U?^X^ZcHm2R8P&z88=io>WazxLw=^R_Abw zufCbuwgQ}nIC)s%k_WsY6>;+Z>ID0n-m(;ecP60XB1%KD>8|1czO1)lCpJkD^qA?P z{rC3SWHbj4WrUIpg{b3GZvxikCs(Bb*^0hvckblzNAdVd<%(7#%|7x3Nva*inBje= zfI<}axkN6YzD->;a0{CM>v0yl;Dfd_Cnn~YnG?>!yRC%D3EbJ5kH7P| zboa{EahGBc`wp0S7zlC^(W#Qg>APJPv`)#FI)(-}0R5|2i331RZeiN@kBP%rqMmMq z7jMjf-x|dnIdQ`oav8a9|Gj|lUQms~u~=FAyNKnU|FU&PpJFI$`7LCEld^ut`Ai)* zK3`S*G%;yk7)hZ{9VY$Kv;e7vLeXv)5xnCStXf}r?$cum!?x@&jyvhK$B)ssvReL- z?!?o7UAM7BzRB`L5&h4UZz7Yllm1NI)6%bgw-}PYK|GJ-SLkrsQiec0+@SAOBfH72JcH7pKBFjWkwSn zEc{5S(ZN>3@o9`QP~+0?_JUAX-4kNZ8qs#MAoLr|=Ew(%eabNC4;|m8ju%>Yc98}) zeRj0Gp4}yS@%x?RoA=m#gTIy)RTGIi@RF4G$myFZGC?yMy7+*X?%?6gzW!Q(Sl{xs z%$+98v_qPV8YCiJi4On7c71Lgutn6#DKRH>5X za1o+@xqG^*1$8~RLrUp)nl?*;WW8wo9#CUl=Z^f_Z!*j;+w$sn2x+C7SG#sEiotcm zZy6%gXg@95bQd$k>SL2H9AZf-;M75X0%K7bFE?^Z577jl-Tulc<%rM;GRQ8`5gCGs*p40X^N~N@@RVl91n> zvOeAT8&8w}dltdjuS6`NbUrBsl)6Ss$Ci&P;GA`(qc0u+9a+MEXzs`#mDjkeK79Ri zmqTC{g&QtvxpwbwC@6*%QSJ|==_Ia(QvS5?!@&BiF(`c4)ifyVSCIkO`Xy24 zhA<9!JQuiMNyYs$E2o2jQ+)!;=B%BqLMrzPTU&p4NRIFoWr6{M_h`Q_UyAQ4wWIdE(M6+|` zADP3%X*WICUYLsgI^DMfm4w%h#zZ}u^M%k|o4VG@cVV9QF|gm6y`jM-2wYFlrX!KN zPQFbymS~eUomvm{Gu&M>v}}|ARf6!+BiNXU11F2FY9KdXo6o44&>B=J;B>ei0rCs)9h}YKXd}Kt$V>#7$0R;{0t=di zA1S{GNSYzwKh)DOCrMxwcAWDQh<13{0*pT}k2@bE+Myv>iT?7X=akGtHhPf2u z?^xJr8#+GFU$57gNLn6#bz#wlmS z?>6Og`tuDk-O>hFj^<*(PsM9XKVCfw)*L}I_W!j7jawRE%T_LRS zSr3i*;f!_zW?9U&4*ttPTYOTi*5g;RF}GSIgJ7yYdI2ve+93fu&ph;YZ+I$rd#95b zH@`op0Dh*8y&IDr)}44vq$VoSBA9^DqH`)?r&>SJKQ^%0bh}i4Qda$jq7Ly9rZgUX z=v-(Q_G`72fLAE^PDH)z1^}IcC9?gQzGdR0g9&d`_p8R6C6hj^JYn*cM!p z*y?dX<8gS5hsOt7Rbv5m9v%gV$PJku1p^scNhxZQHOE*Uli^{BdNlZH_%3T8(Gn0p z3z7VT=u`LTk3#=eeGfix8;^Z;e)leVmO__;GO_Fz9mgssgxxbbhfQNyhW=LS-K)WM z?@r>Y-|a#*`q6hK1r*W=lo!#jcjwvG47DUi8g-F zU<-aST&tld-SA+0Y}F_%Z4>l@g`1KHM(PwcCrU4n5q=2)Ah%*QAPeb&`V?BIiirmU z?rZrAmhb-Z7VUv48-C~*a$B-zdWchn#yPP{VRKxT68osTlm3SiQTaa>=k+@&1gwE& z!u{ME%PWqw(7V*v(_u_?2Smagi zT@J~a@{7*1LX$%Vel*gAy}P**Worryk5#G{3~(pi`r(GH|5AtIvqm=_UrkON?)>5+ z*kcHDcu?_fSvU{JoEa7Y#gAHkddYt64%4uWDMJt@nYJM~#EEzxK1hcrc$g#BgWGT^ zNQ?)Jr8)Al(vKOil`kYl6{VKdEU>IeSaqmE?0_0t!Q=shn1xHwE-q3C^qK=NLqx=1 znp>lgqM81E8?BwCq58=HQCvUy0a*9=`Qx|1E45U$N$Ko)N^(zDZjF=G6}XCN4& zew@|G#F{6%!&>BeBBuAN1%t-aW?}Ps%Zy1lrG4^Aa+WRWpCA4VseNqnB62#Soxw1{ zUjFNmR$%9S;Yfeyij3BF)ZLP)DMC3h`ov*rn}=-LA`+3m_At11y%O67JU4jkn1=n<$o--^tMQG9>Nh47|36?$H%8$rgChv%}kcLXc1bn(|c!F_9BD`h@C~5^F zgWF3SFtTZ2hPq3K$|nr_YAqfUN%-GcAkq2H(lha21Ps$5`cp))f;}5@JhcvXrol*Y zd7X&Iz!JNJ;cItV@_T!uzL736~n?e&emSW(j@*U!zs@P6qR=K2$`<2{~oDm{c}cPCge%$3}ZUp}MF|BbU@O z(M8iop`DNX*7!n@lIETJG%yhSB+!p+1hXG70$MjHyw|vZ8tEa^Z+XXv&OFpUDX2m8 zzZZU0evHRlEUa&br+WV&oOJ78QE2H$hlc=YQv8)IQ>Cw_2e+B?;O>e-wl{pB!35Mc zurcnp*h6>h2RpeI&IMm5ABSRaP`lC3Y1^9$4vecKC`X)em%&R=vS-2zLK~RS5uUF1 zBIKLU@r6FGqMaM|F>C+|aTq?OkGu@&-biaE?r@>IIj4QdOHR(l?bW9;qQKQ zRLcJ0pi4o8(iIDD@2Xv8F^Lc0mNOp%<|ScN=6*14;6t+Q{MVj+N5F<-U}gFlrNlMJ zKAT3Tf4==DU&f#q?#>v<{`?at^bdev@UWePXn!m4=_fhbT$$aMut*Bo4cfwz@uin- z6m;4-ef~~_)QQ(kxYP#GYAW@OxG&7`GXC3Bzw`BfdEqODhY4$YIggsgG(-N_@rqq_ zGkos*ZQ`$+g&5z`WqXb^aDLAc4yH455$<=VdnQgv_Bqg!Hd9TKq8t`$DDCi=6o92S z6x|!SdA<1p#wq0D$rX#YLB8&tFsI01#lHl`38Nu~W-keUl7BX)zut zg_LhfiBC?$1n0SK*-O&<{s=<%LbwQ6Hk8;kp0!0L;4U7+%Ths~H|$(Cwm&t5vi5RN z`1V4&EO^xo!k)kI;n76~bYWk$Yf{KvKB*wTCxLQ0SyW11w3oWfNBa>^T==M+vjBVV zJJkyrsT*CJ!6(0? zT=LXFj;~-GEo2^il*qFx&?qPHEa`i}W)8$?9Oxx&40R*Y2~YNO`nUJr*gj{QfWFGI zuIZVtuy{}K<*i;dc=p-%RtBPjI>&)WG>V4g)Vg;QEG}J)_cg``XxyZW2PD<~ThXm$ zqGSr^YXCkV&*WHtiy{iF0X z@Zciu#;qRMSDpwJkWf>(?pj>lN_X1kn)D=T1dpiBu#`#>2)1z#B4wD5buGXT8VU`2>8E3Si$q)eS&q3QB!%yi=? zW$V5;Y72}1DjZBpezN8XDv=)vyvyPfr&YDLRW>HS4Xoa{;60*}VnL-+)8g+SA0%g+?Y_43PfM<7dF2o8Z`1k8{d!JHJWUl;?6drhup>C*`2 zgZIk`#ro4)c>2AOZ;8= zi+VU24x%yo_q^iR5wk=zHtoA-(NAeDOw+>W6QYciyD<5KsxWqwFBpmqT9hrvFV^Ek zLeTv0z*U)eVXfvX^M$N$v^MAn)-&M!%3{FFHtnMBNk*Ml6!H(Pmk`Z$v55@<*i;+X zJaQv5f$Fb<-SkhE7@yYD4Dx+g!1!Hn&2#{CC#Eip{0cb^8*isTb>Is6&H7h%29*9H z!M!RMzmT4<$NdPjPm9KJ{9kVl1mW*(-m_*{30{_O=K zRPI+F7HnB??JXhpk^&1xTSFj-3=s8SdCm&V453s?rBqWxhvH8WN>+EtMq0nrA4E%; zHN5`KsZqBW9j^qeewjm#&vqIV$noBZMsPi=vuAs2i4AV*5b5&8>0o@2>FpO{Ly*x! zS0pImC!|wI0e*c$F#05A4?NExImFb~W1QJ-0abT&}h?p{4e z%)7Pcqar1tp;G(^gVgf=U&Q6 z3NX&(#C|c8MTm-qi4bcYK&iv182sQ;v-7R+u~AsmEx+OEp%h>5m1+~Csu#15biy&l z^WFcPnqtfKtk-KN-KGsfvT>pfK`Y|)KLW?X$aq_n)}#^mNeLMn0V~9d1#iqxx^fKJ zI=g7K$}lM(q}U3oiQkTF8ps3CMsAN{^=Evd)jxGiejqfKTFteTR(f86w%lB&5>(^w zJ<6v?vM5t%u<-JES_V15y&-pb{?l88uo&2wxHWiZd@lOI1p}z%2z%X&atWfxGAOOb zkQgQpug+W$UCb2Kv!xS5`NXxUjqU9Ii0&wxqhiQ4E#s&V>DD}p_+;5FX9gFEKK#;; zkKij&q!W9?b_OXm5{zWBq>kk^B!?oY)0`#YS&X7baOa%-s*Py2OH2=)EQ7mdmEycCmb1OG*)Ff81)& zM{6Dx-kkz|svV^((e=yAe}0?==zyi@&9fo!mB9}k^3B>OH4LK3!b$2EQZ0e@&3hg`BLdYYwfUsPg}o}~s~eRGC#F$uIWC4=ekbvQHeQ?Q^O0(6Qfap@C7B*rbJbto{WGb25cFq#9Y3_oQfZb3EFcyt6<)Di zkOzZ$woA8M%B*?*_;~DI;RMJ8hCi}@0&gyufVO;vaP_Z!$S-Td31ry52}Y00weKne zt0q6tlJxnzKE3^->J06q4ki)STA3qA>QetSVcGtZT)vn5ER2`U%KI_96)FlJIDiGa z2h!Cvq#TDbQzN}ocbvF$*#x|JdikBA1415wAA*nqgIE0bUK4{oT9w$Uc&EWVYsW$*s(}`8Vsau?(v9Pmosl@lxkdFd zh38d&z@qzGb{rgoK zePlM|rcb|Xw~JlIFf3go;Vu-1>22Ee}IaW5d^ z4b2kdYS&`n92;F2KIIj-kVP?NTk{P1Orxw=D3$`45MOTjy2#WmiFZREr3a@69R}$t zgp!MuOBlaj2n26gaA#bUUv@ng0?jo;XbEkWWkAq}knIb$`>w!VoGNsuM2(5VD_6@rqt13Xovd0oi< z2Us*KFfw>n$WPsH5O;a{Ozqr_ISySbTjwpKO=F%G{9qE{qXDhzM6klqUK(PF!p)@I z8~a*aG^kMi^qSl_k2YiQnH0z8?e5`vx{J#{1nv*NZ^Z`R_iiO96f$3w9 z)FWAdL=?aCU&K;7OptzwJ(7ELTj@O>EiPofA^UI2j&?}ZssV2Fyp1UWRXHAY*ezcn z>^Gij)8X|79ODs5<)%uco^88n;9aHnwOkYc=<^Q7o(=tam^;U1i0dmAeAN z+N+gzYu|%T&x1~yuUtHr5fCt;4vyU3WHZN6r0epXbn9}>Z^!SyKck@na~!XJs*P~` z`1xwP>1*+L!SGm|N$zn$s@ZGd_dC;;xhxh8{#8!*vCK#188u(NAE`hCi+3`uA8cVi zh#CT2)H|!q<9?D}MVGIosDQ2DF9W*2VnP=$y}kua1l zXT@%2DY?05O)I!tG+11oforaq3=MUe_MOT#TbOZ*zyA5y8jCB>O*8R1v+(W%NJvfqJin_-dK|vvT9r)*Y%6sJiJ-_G z3a}r<;Q5IKOki*NQ%(a3Mg;?WlphBNgbB+E`IgLn=rfQ>g$-U~QO1Jz$_;PefFkuw zPpeT4mzAi2{n!8%lu<$RIsVYL^q0UaY3QfDV;UFR5)w?`a@t@ujmb{8Mo=5q@T^h6 zD~eEKq;aEMUc!`j(MbQIsbco7T?rp;5SCdtO^kX&Uj(tCO{ivsxydgN-t4w|cir7j7P46kMUrjT^_McYKH zh)Jgvt}#4w_PrblmZW0-w~Ln0#3i`#fj$Kd1qKsl66uy0m0URyNmcWY)$mQm|5-n2 ztC$&q#Sb&0ELwW2OtVk2%dri-Z_WSC#Si-VlTz(naO)JgCrLZKZw&Q{7e~$PoN*Rn zll>f(>8?E;zan%8W*~Lu%56NjG>;12z~x~2i~Qv~{wo&a=r*|AoJYwgPNbHFI{sr* zIY%Or<$1#ov6bM4U0~c^cO~D8TK|Dpd^P#c8LdKKiyMM|Gsv zjXG@`LHUizoZe@F0RP(e`?V_B?9|mZV){oL;}-!;n92dM^k@l3vv5I6;fYj1{8#T= ze`>c!`Z9 znfU%naeAM%Uw-FGB1`1f0a>I=eNm;bgXFdN^Z8S1f<0Ozp7+=|F`82>pw+mV8y+C8 za<|Jd# zdlw!h;Pc~5m)tGfJQC?xU*Qo)A^Z=Bko12DEWcPV2p1g{uEHvJG$|)Pk)|T2f_|ZU za(mb75MvwBY=mY0*)Xq=P7m*@o+! ztRdHz%)NJH=JJjnUX5mPFIj$fgm@v|<=QA+Ts-%{GULi7fhDWs)(xuSaR{6yE9PGg z(`AzV&d5YNZ-oEvXf8ryX76vk!W5aZVL%ymlJAQztzvsBHKTu1$#(GX*T*xr&tmfi z+OhpM@lVU$BBB?eLIx6eruq8FC;Z~|-AHm!>3I7aarU-OQs<^g8PnRaeh1%(Pn`Z5 z?l7|3Lu2RdN2&a#`Ct$7f4i96pd?e|@z@6|#rS~h%^fU{_`A*G-l`f$8htq>LtHKq zc<@twIk~t9H}H;k$*e#qE{|kp(JS!w4*s2_6lOtR!-Rhg^GxTDP^~5}e{NODj!OM9 zWa}j7jbX8LSJC9g}`STXtN!mcx+{G#=6jMnYw$MBPt5R_Cb znnYx*b!C&{;t#qbZS2#^Crs9wh)dP@=E^6kjPR9;ZI4>%;Jromb{DCs#tji=T=Pa; z8F+1O@#99gydYH$)n1TX)x%L*7yb6e_}dHru4+F}7ClwO%Zanq%iJ~;o`sj{sGjPv zz7+rBz6bI`%n&k;Xu{@i-@{TDSgvXw%X2DV3cP_+=$4?NZ0>eEux;NckKu-C()&~g z_s-PJIiUf~YDAP)_R2sy3Mvnz7Tr}rAKPYG7z;0NEt^Is^H0GYD>IOc3&OL$hnD$6m{Bu}|0#xPy z;c~!d${QP0-7vaFgYxBnjNLN*%(Pu|F^0{6F&Kzz=2=H>BlfgJY2dghDIBo69K2(vJn3u9}U>_Yj9!9+OO7_GGkd#P~MY#+F+WPGHz z?D5A}uG+A{TU-cjV?cgU&0chP6Su-_QnevD_eExL&nL@d9|-n!mp@TK4nfVri>GF4 z%l7WHvZ?K5Hoe?O;QF{;#VfY0!2#7N`?m~@h*PQTW8+1-n%}u9*jT&Y4<7R!VPE@G z$untxHu}CZ;YrdEKL*|_7g({N?<(QwrIIYgdLK?wg|+?yb&OEAz1-w1rL+h5TRB0n z*9sWAxSo+vE^8ZW?)xK4zHtt><@-KdZnv{Ge7l9Z*?!$B3Dv41O#C*}%BCst=5#vh z#kl*3_L_eD-MwCqoQmzW(s`&r1<6iE(tGu4ZPbJK+eFksbW+iaU zu{}T9!pVB_cKufgt3J2wlT~edyyN}(Hx--m_iRnRWER+?d$z?5pv%?PYv#eCqR@d0 z>N>c~Kj=iPKS&-&s7xSxeXME91zl=6G^M1+2JAq6jL4SZxu3uI-B#;Va3lE}Lko&X z1skUEba|2KUrO^SPu=mX;~Cz|o^9``jhebU4#VPqGBSb3wR^093f5s4V$3ko8=8KYjzb!(Q{$m}w*HD8k@0H&}g@e4QN?R>V60)4TchtWt^d3}UIk86TN;>nd%)XY2VQmTSgC7->ZyPjqv<|28h2+fdT*1j9%et$+Dk>;MnBV9yVb_1$t z@KwHpd(fRC)ptY*K6atIyyYwB?u1v4n26xnF0$9J|B8#tdSaOHWK$2|l|3xR z`T;}#Y!hsrjrsRlGfqX^QIQ?RzeF(b{ijs)p>EITr&Lnwv#-7bBC(b z#E_Fsc^W1S8LD8+pN(MFS&nrAftqCyRuiwdYEGt znWyfoA%s0-GglX-dxh>HRWf?v6%uxL{u%KC>Qf#=25ns!4xs47lUcKKu{lH>R>AnD z3!J%2?&iBvL&>+HGcgJ>EXw2t5;H2Sa5MbpXB%{>=n^)oi`P_^l~vfN%$*>kOBzct zA0{4%MWoOkNLWsS9IxeZMMO^UFAa$zalz*D)Vut#`KTooPQH(!x-8_!gu+Yl;%08> zoGNzhCC(-%!}0SzVWhY0w~5DZ>pu;nDUQhA>8~YRi9Mt#0vC-AT^BcXsLcPK(bQ|i z4kUnGMIr`T9$Yt3Lcgky%(|ZSL|T~SsXlV3IlrIN|ExswaR!8+$va%h|EIG)XJE^F zbn^^*m{w05Aok62BlU;yN^lXsdr(>Rr3~tjFw&c|&dc2%oopr!fCiV|rqlVXu3JAbaAxYGp3zvMP^%DpJ0x zU2Ac-gj8#&ROCR2-p==eremO`_aHUDt;nlCG?_cLtHl(TNQ56?@pQsaXFnhzycUL))uXTcN@FZj74kH*)lX(8 z4laGKz6fPaduskj7Q4u+@5(7V3LRz4Syd$;;xnFX&vg{}JHAXX@I8}p**bFi`=j@(?ClWusvX3lu;0fnbSB%wA=E>Ww z(EqqCY3bcdky`XXzCt&f&$r-mk)}+vOWL`-tq&8nhc_S&+;>wA1G*c=L4042Awgk!S65X8*Jq*@;c7Rc zMz=$V=BcY&J)Z7^)rh8ZqUuS$3ex<|s-V2Qo|wm!#Ua}~sGI;ZwDMhP-$&S*Ty>ro zn#N@RtsMzD@V zxioHi@(f6++3IZmvcq1wv#Q&DJL+i1D*cxPmR2yPwqQ>CFMI8>&iE}$dA_p#&!5MX z`_r1X{YQoQQ_Qz2fI8F-E*2K(HDPYz`wnQ(*AG?sy=;fETcPwFCDW%7&llBg9TEAc zr=qC2)BP(x`#~?1BXO%wqdR=Czl$Y%jHaVblcm$T&k=;XkhVzrq#@#d;o%2eq}EB8w&-b64+>Pp%$O znTxGa7$X8h%*bAUSh+&eq?u^!IxZYJJ9QD$L7EjfNB^FqWL`Wy_fT)>W#Jdi0{!$2 z;%EVghVV3;H)XEq=TH5CZ}Y+sLJv_c?oNntUqNx`S*lJU!eC}^!h&fZ=aB>F1N!ub zh&2}0YtK?U3M5JvEz2?=n45YHp)c5uOkG?$P#?Y(Ag45l5-|R634kvwb;Xg;{ zMrD_i{FZ76h0G{9M(pI1Kf=QNWc&CR22QK7>6^2XUGFm8EZLMfw~*zJBMqjbwmsaf zjS(cjeWUA|0I9ruC*l%v9oqxE9dJMU(PDR{`y5V_tro!PNaw)GJw4i63qSah{_(jn z)q6&tPEiZ>Hbk-cS_*I0Qz8*P(jL z|Iw7&ywi(tW8~JwFAX{4bDeS70%kiye=Dw#2!8~@hYeEo974?juLa!E7UNo06Z`hw z0O7*p5$;*?Fa=YqF5W+5iDUESj$g#w)Scufm<}738?-o6V<)vL*Ytn4VRu%WTImjJ zp2VM}*@JrnsmwR~4K>mM=2*{Kzpp$Rbx)0c+41uilemnXa>l+&X5FiOw?V~VkJzuJ zOoW>I#ITX}@2Hl{2tqxuOtHW}e_isq+jzW}6A}fVMwj_TwMIuZmvO2AH2=L*ER`k5 zJ|9$@_3xl#$o~BIb&c&OXGG#7eEQmQG0aLJO{!$6Ut_xhAZV`>{avNC`(N7sho*P& zXZnBt#~qSVsFz0696BksR7mD9hon--Y0gr~F>)9-hdHa{P-;R>C1+dCr!kbsayE0E z;~2x3!*ZB?_wMui{Q+I)s^|ksjS3UocLAOsG(8Yi5bMt+GIfkm z*QRgeS+`Z83d@%-XGM9HewA0Fi4y`Y9u?;8+y1Wt!!Hm^8NcZ&P z5j|s3@*sTwHV2>F)qrVzoI>()nSjiJ-_;4SHcbubDl+EIQHV49W9~1@)8@yjfsXg3u@EwdV5bwW2=_zzmE; zUa5)hpOXG|h7aHb23ewl>V({1WZA2kNhy!{vjn8#Y}sh*5bcj6mESToJiC@zBeu2o^xlhUb6>UVhqz9FAM#mt zr$mCBUnG}HXpmTrlr-f7FS^n?zjCoyD1Xk>f0{GL?q1Mi7! zh$@1qX4hV)j~DCR<>IRj++FBVF9MDKEpRFVNoU_-qu-9_lRQUwEn-`JBqj8|9{Gm$|d#Ew-C z_#0ow96xD(t=FghDq5q4`TXvN_qRZE&FA8h9Lkz22bHDe9Yt|~^hui@`&whh{rt6B zsI|x+{|(5JQvLLEa#Xfq$=WLO7?6Ij6%jVX&y|#8Qk}C5x9^Yn`KAXviJVB+c&K)y zW*19IbFX(#Gtf?AA`A3(pc2V{M+8CVPFX$`L~T@pKj{M?1vrtpM|oo6!)GG zODxbGd>^NrbAhUCa07phAYW@$T1=5quxT3ZV>k)c`IEcRjr_F37~a4a+DVhw<$oad z%&Y5&3_tki9@2@AIZmlnnN`?XDN!dQ%Wm7pJ8@T`@JdR^(wvfi6lHT7T@hr1>7}4n zNDl(E(Xin`*LsS#vz2I}h=GgV`_&%c-vI`Mc8C-=Z|y&EcOYf3F|=T89D&|&Klc4J zEvu?6i&g@b{{m(EV#zruJ=FT z4|C4b4*hK&W5Ic$*L<^$RQdJ!7wGdKCtHI!*Cc;Gp`GURq#Wmt|Cx=e_e1a?fx)V3 zJQ^2aUva9?8KqMO@%H+rNWlh?7%ws|`|BwX;4xP7o0};~uDYT_cBhB5bqN_;#ZKwi zMse%ImGz#`_so4T@gQ%HRHd$k9XMepS^t`HeZev_uiHLI-KDM$V(R;`Jc+P?DXH&9;@jS=(^j=M&-ofG(>rnGqv3F(_{lO@=XGwcD zA&SW8TXMItLnqp>EJn17_qfsmZ;u&k&sy=^d z*>h(LMh;HhW^;2Lx!ZXx(RSe*)zDOAFnp0l0|mfE#)Vf;y?8md6c^-G6%}maoM*o6 zM_Yiu>4d=XlBShUsa92|np&$igR8;=z6!=iIDmu)WBSsIjIkagwwYf4EdIU)*zrh4 z|D9dK1O712kaizM;1OzQis!u0>AAKbsFjW^&pcHt;Y{KB4N6Pe!C)cUDAY~$-rjY$ zSkT}>v9Wo5j|)=(yNQe)lyMpkK#z5Ad7@`nZr9B(o4c6%W>Y@NgS{GC7`&wpZK-LK zz*{RY<`aVLl2-W=MhE}4df!ih!1=4*wE~%=Jq1CHCciW$ks+f8&s06+HH3FjZ$H1M zMoc87))%lBs;VYeT;Ri<#1m0fvdzzzUgu$SQyuOWE+~9qRVl_=9~5fD7;GUewn{>$T`H3eccW$kG;TpHPac{BV@=Qh3`LZRjJHgjR^R`i%=yfWEsIS8Rev z4Thodk|xA~;gwVLAg|X=HN|7OOGi6BhttYZKH(?YZp;`ypnja&8(?-0YRoi-S4%a@>^Jpp?8HIBgzMm$8kJ%4b1xVf@PYF7(W(T*ft%|9rUmUtsRJtPw&0Qld>Qq* z3kNFw!pyDe@B3Fj<;w)FZjpr2^5KUmG66Q*@Qu38m59Hhv>uh)XXewXuMa@fcESHS zSSvXfxKHWh2mR`<#y!$~JbtaUyf@?eDnvv%MdtTD%Z?lxubCFQ(}@0ISdLpbL69ZY z*w&`ogvodGZ)B}1j++k;SOMHs&)lhe#tL$<8&6uBvnoy1(9P z$XPmYaVLQP0vw-Qg`%H@^X%#A&W{(h{%@86OT)q4!@Vw^EXC^=v1(0suM=}qmO8TgTE|y(=xE=&A3XN&EYE*bs$45- zniaq(_IjfY46AK(cnIGGa*x47#=`4MS`F}1fpwgX&_gG>a!xKHMtSlVa>`>V;QC!r zAX^}W+)Q!1`zK_wBKQimUHr%JQ&4QNu6fr$+IOo+QL*$rvSzh+Tq@a)rqS;cCVyKP zId_`Qb`61pR*FPU<&i`me5;;v5rN-XnXUF!%)N%3E<$7Lgn}?!1o;pAoStIwz;U&y zxhjVd-u)^aD^;6e8p0mc9(b63cqW>PIp?Ef_BMoN_y4R%Vltl_x8L@=PU%+*(|(rc;6uxg0CF zcIg6g=2_7WP<<29W$UriyN>R0L#=s1OciG!U+Tt(Y*y3z#M}q37u?BEwbi|q0(Cf} z!Hz_tHjQP=Uu^E0(RPj*3GBYs^%eC7=4ZlI&u&U6C$ZUdh)JRJ7F`wn)GUng<0QJm zPv`GG93-~LuDHWjb+N|EKf`szeoR#ZY2~Td;!~gCJXmsaQ{>(AvL=`W=UWW^2B~$~ z#tq~*((w2Fw2$7e%pP@rwHA@^1Gro7MAILsu~3i%M@QA}lqV z?;XhBe;FQ6dEpRJ*o}cIyi}|ea(1;>;L?gQu;E4vc%19wgC?t4CG3naxLyS2m*~~j zJawT^mU}stbnHFQ^7oFr^9nM7dC0`qYa+p8vTp?4x(a zC$oYap5kvyb3SDP5AQcvN#?Syc45}!MDahuguc%umie8}^vIT3@jUB@U~8iZ6X6rp z$OD3j%`5W~nLVL?$*O%XWf|R=&u2*OjQ1iSu{sSDCb^UG3<|Zj*YnFUsk0|F5I{AL zTC7PeuAw@%?L~Xwn*I|}MoU_O#NHUNzZeU}wBmHOA6n%DxJFs1LXm@8f`$G~)|~nf z^Tfk$l4mw#a?tMke6dA1aZnK?Mch)Iq$a9rYj|s2KGnw#aIx%l@7KB>W0+1RFs{N^ zgH|*Nu5w8wmKT39mT0!Qvv*X-EUU!}8erO<(egj*q^Yu(i0@e}iP;UMF1|lUedDFv z^oQ@6h6m%KVF>~I?)%&5$>K<=F)R$==U5eU7CheXqYEzyzQcByE;aG3c>zD`qlTX~ zuRn%8tHE{@gzwZ>%ch(K{14@(y$Z9jz6vY$fQ6Z_$VDgH1p;(Y8NY9JK!043oz_!+ zF-?s_6)29FK1~i%DK6S#wy>!JRY&sI1payC&P(0nZqOce_`d4X^90$eiQWz0w6TFP zFZpY6tn+rtho-B*V6V~N{es@2a=yOxITVS8b8v-xwh)@O$3Me!6=z){n7VT+wmoG#=NfIc(F@vl z;vxUy?LW-H9XX}ptjR{B{kO0Z&I8byHJK;jzXZTS^DKcV&Tpx_l;v#L1#R-S{@p#c zgGozkroJV)Ku8boe(0H{1ULO zaQ*kuGd_N5EL1?SnpMD*AQ;T*E?chFZcdz3)RWc0(eV+%Op8!16{gS&f+;b?yMBP~snm+FJp zyF145c*^-8n}hX&RVa)mVz?>vuYkEEjB^8l9WU|u=3F=Qb;~lUfBI}A(d{m;YW)wy zm&N-ZUAcDf`V9eO{n-%_5fin9tfh=#tq$r^IQ)F~$#~G*8bt0m@r1Hnz|q~KZ*Cgu z?uV>JsK_YT>6Qo5QKUjG{uwA@m=UD_+VDCukFeHbr_dI;XjJ1T^khv)IGjfFiS>6iMvhVC2D$2UNVE#7 z=|Ff^Q%0?BGGFV?%dzxP&C^>^6mcZpUl7Ss*TC1R-lkDQK7&?VcVnhx|XV~E~^0k%21@{SiXj&A>G3pL@_}qkWw&dzhE5O-M#0Lru z+_=*}bW%`jw6g57X>2h~={DV=IB3J^F89%--<=Ls!FKHv+o^7;lk!u5jq0a2&$^CS zr-;!%QF&-&!{r=`$9k=b%+xGt(#~V2Z9CpZh%3A9=-=blCY{|1HP>G$P2~801{A=V z7zsrj1(%)mI(u*;XSrt;eZETfW^lzNrE=m9Qeq$!n}Ivpc2wbC`QsX7Kn!bnp?>Emx9qxY+HT;TpFc~0z&}nntHLd{jLhsgMMSC$lp!(M6=xnu3{v*e z$@_e*h>2*kL!WQAx>^zv%o#ygIdjGdkK{g`W_?z1FgzbCbSRD2Yi}*t285dAkV7I~ zYR}_XAkr|jjxgb3M;J;u#0t|J+o2ac4khPi!;WzERvxklqd=~{_Mht-IJ|d87dK+6 zd@6_ex5k7&Z!7U7so=a>44~*+e2vWJ+-U+mbZy?iPe0oqH}|0GEav|9>gw@2K-WHk z|9{~lT{xa3OXhy0;x}xAR-iU_9zdp-E&v1nGJu4o@Oryi>NodCIRxS^Yr6)P+et6g)I(|Jg_SJD4Bd&YdJ$p_HHkw83z56P;cQtk6dA5r8 z*mDYkdbf~3ukGU_-B-=q8y*h%kzDpJ_8FREI5&t5$J`nU+W5FNuF>J2CR-RQ8I3!a zL3~_4FPaM2;(+(O3TF_Qy_u``TwDc*FmaUcc5pohCrdu#C3;H?HNU1;@4M^^!_*-_D4KXcjq-cwG!{FTZVo}?#pK*>?0 zO|fG05Z?KYP+wA#%~_8T3t*aC?A-CYdlN_m_!p`s z_#UQdPG61$lHM1JM#WdWRsTA+hhI=ENjB{dl+AlV?DkpMXxy1mf0>MmaH6R-9@P#w zwRB>QrD8#QOxJY&;N<<8&k|_fJna4KI}MmDK)+M0d;6oN@C`UhG3>%$;>?CAh~ReY z_Tq6~rB5{>e>0jK@QWum-w;dLC~#1Q1RcC|wb$AvdW?3^o_?Zt+mmt_{nhMleMAmF zH<#dle}$iTUDko)ySs_U^2sg<*^kq z>e7Y!^TpS8XLiS|S5bL*32mtGzwcJ!U*#(DiNE9w6#pq71XNk^yf*{*?u2$gng$vl>E&}NM~zJkI`w6t)i zYT%O!3;ln2=65@cB|KdAkA|H5PW+|~<+)&r5jYh`e&cl@is7B`*T&ZAvlpfd(H|9V z2Hw*uZIuTf+dhhX`27Yn)P1K1%^6rFJzTo_Y@cw}_>9=1em{D)u&vKuxP|`3JnB?Yq_~6&6#|T@a)gF+29|ni=d_Z@r z93lEhWw=@lVLB}j;MPaik4A>sSDzU)+dZHG@$4CE*P zq2h6l|4x0V(};h?-+An4vrpScZLI41E6#R=h+DDfl3RwTLmIaejo1NoOU>`Zgpkom zf{@Kh5bF|Exup~2=`8Lk#$o2I{hI-pby5{KC5H8qP-H< zL}^0CPJa(lL-(xx<0_o)5?`YU7C%*>X)&*2R*Oazns(%4X^GfKd*8$Zx=)Oy=-CsS z0y0-;Kg>qjzpMXfVKtA#%BN9#D@HILMs$HYMO^O-R&$Oxe|F#LK7rZ}vlx)aNq*@r z1?xAsFRl6wJE90(&k-U#yJu5}NIsjFTE?MV(l$;U#-@F5CWNg@kZRhRH!zR(!%|Sg_~ong@CB*=g=tux0e_m? zl4SX{T$Td#>UZ@meL~ITnrQMguv1Owy9aBMGJ|o`iU5R-zC7jUpiuXEL3fhV# zLl0gjh3_?gA#xJ$$V}gih*ONBc6v=51k1$ z&>GwF#!IJ^UA*HLfG{%kKZ4QL zNS2^dDE0pabA01T(Qskb^(}M4z`SMXsfDD_@O0;QL9U;BS}h6XMv?a3!g~%97fTE+ z2s7hS4Rcxq3Z^31u{TlL8UxYy_&%O>Vlyr0DiB+>yjIeXw<>h8(3Hvgh;de(LvwCP zl-Ra)q1l70Sm#$)r6-!khkSHmG6H*dPZ750YnKAY;?v#K@*XzxRev093iZr!idSp; z+_NCTB`tnqISz6OOIf>IRja<^NwAl7k8lr3{UWuCkn4a<84{d(%05GL9SH=8r>@=^ z3%ZoYj)^|u>v%ArQ}i{sDcQO*3l~YLS^8czr%O6YkRSj5niglqbu`@#+HzAHYaHTV z(*f0J8u^i8b#($v1u$LkM!E4$6z@J#1vJl7m2G)-vfJl(=K6%^P#ap&zGC^zChu#< zHG4Ezv$ACPCRJI-4w6{8d}=f4-D-2gHWQ1Hv4*kbr?y(}o6~;&ZQHdK2syba!9DT^ zz*G3ryj@MK==?H`X*rY`J9Q`XUM08A`wxCu6pt`aS(kWVA)Yj3JUuKCfXA=}? zdkYJDAp9sPUExQp5{K#pJp9H*P@HxnFmHZW?*=^nws^c)I}%hgfK;bns@)Tot!=*4 z1JEoyaX;D|kPP->{qT8}S)<%E*fj(Gv@t)nrB2Xz1J3f_DEs;kT!evUfME=QkS1c1 z>@iHG2g-_bo9Rc7sIpW2_>Et|PsA;@UxAayB4Ip=AwJ37ZN zs;sdS;uB#3}`vXv{yLSvskERxm4s5s#N1~Njq_QJNfx(pz+ny zu2%nT?scg3A+x%`u$>$0dNLucyW`mU{{{Ba_uF;nsDOJX=|O&#)ivGqt>uw1J`ZiQ zlkxTt&zgB(bg5Z(otfaN`pSy&+{l1EztVh?H|+-u>^vlCnY#htl1{zR#AOhzAp_Vh z;5E-pp@@t+ma!q|l}ps;g`>T_hi#`pg!9UG)@($;DWwzRYS$ONVYh{U|E=)9JaMy? zVF7qf!RClB$^Y~r(fE;Na)U-IUOSK63084%e4Vwh|JuJJEJ*{$0&&=`-QX4Hj$s}U zz?S&n;qrP&eu7S%lAq;Xwp8pO^#l>SbR567o=qxv-TykeOz5-%a?+=pQ|76irI>_U z4U4yl4r=Z?!+;OU$-L>STwe7;m)qr`5F2d9Dxi0-xinUw6-nh{VGfMT%{wFfu(B4H zN?MEMn3^akRhj3&q5H?~EQGS9jeX?CDrh|@TRK|{TM>CT4_HrF5L|K{(QRM%8&;~s zKzyrqY+##rH;|n?aCFG;DyqqJ?PTgnuCQOf|MGzJ`IOE3qBSNRp6)bRdky&;8xmvg zcyG3~6kVJ2lqHZRqMpM70g*M+j7sYP`i(Xp7>7XgTDXBV2~nI_z4l^wB3rP>c(~EX zmiu4nWzw8KnOf4rD zGLmon5T07yjG-v{f{G#eMaJEGWFNZN%B05gr-XQ0r$wEM(J=8Vj3TG4nQYRb@Q%t> z6QjN?G#>%1xc~GPH+efJVFB&sx)US!*+1y_4HW({sk#*6U0M{e{Cc!oyl7?k{p;5X z6_3XKW2<$WZgu*!ws;=az>jRia4K+=lHf>+9HFN=*j_CZ-I$)IHY6Nx@eLij_%`vs zfEd{2dgGFvN7KnQ)8*Oel&Vqf{hmwYF1Z<7oPXu~+Zy%IAi`G9<~(6d>hQmaQhF5% zTj;WAn(xWxRpujeujO^CO~1&NLyekWY%1{EHRU_(`(MeabbK*o0gOAU4JdbcmDOK} zjxExK#f`F{p47UqAqAVj=#LJQPF9*S>w4cl1U&I#2Z0hH50dxXbiR?r5Z(%P_lmrA z4MI7{p=4Z0V#zDn9e4EJSmKhPIBf)1L+O)U^0b=<@HZ=_$1J`1BZ$;H@v2W7+uT`o z@TdIQWH#wS8%}3G5%v9Z=>1;#=Wph%JPYZmXo)(PE=}^=MYqQ}t;G_`5rGDiYH>Sf zdAV}UOebV!SYdm2OY@tcUFizuPc>^2nra)nPmi|JFI$`@^_OQcks(cZik;gdNwS5A z^@Un>)-`%aG36fCTGVYP=hd0_*Qm1pY!o&(cF%c#^4nzln0v>^{++a(>TACNPY|i= zvR7R-8WZML%sR>_8sz2aTG2ElN2BWMHlpbj`XuhkWG~}b#+ulpg|y~vm5+BE#wkIB z3{A%TF_n)@SYBZRZ(nX18vkN}L)J4J1b8-xh&jJTBjLYo>DW8;xGy02<8WakV6&T= zs#7Ozb}?7z0zWO}qsncOb8r;-R}bKzo}y>8g36_UjjXlK**n1O@7j7eRE4n3l1`}iarj;2`h-m_9;z=1s;MZPJuj`Z{IoOq~`57~t zMBj;i?2*sIW%A|sacycrg~^5bAA>xs$G+*(HfYQFt42N6Rdy4MwF*!>`LUH|ew}di zLZkhz3~)%iUif&$__h_{#dsH5j<%=0@WN7PKxjdwa(Xk5qY+S3|7T5?xArfgeOn`jAz)S{kb|n61`=*#e?r>K=7L&R*A3 zeYiT+vK48U2$V$Gz#xqSVdA65yL{;4m6L0;ru;ECM+e}nK5M)ode+vNn|aVl+$;FK zl6B9IYNX=eBSk+hNZfu6NfO*7$+}>qjoJAt$|fycG^?1ZtnKJ(v@)vUOdgFj4LCH! zM4+)-_g;4NNN5N%0$$uS>OVAcxt&Q*x=L-{EQ--7JFAHL|7a7sJTGs?ZQ`V;@Uy6IBU|y(f=hd!xh{CG_@b*b$z)=M z`f5va%f9F4sP{{4F#;WF-JP~7(7Yrmaq@qD3A?1Hi-&9Kq~Ob4SeX=KJB?>~6dw5V z9l!UK8i?`MK@r*%TmBjPtNa)IRsmK^-WFVKM^< zf?i#}^b<7a%^&q8i+S&iRVc>Ly14G>>Q7zNPT~AYzLgE1H@wJ<)QX`7k2Oh`ezB9pq2dz(OZ33sfxRHvA)t89{jvBE)oC z-bnQzhYlLbeZUz34Q?(wBPt+cUF2Q|t+xwf8d$lt6-GJb8hjX16-kkx9cCR;dcQaB zIZ^P9OB}PRL%1mCVl~=zwZy}u_fs{qE>RJ~ zgAeZ#z59@xFz3M^mB8e&^IbiO)%s{~MKJ3OA&qsW`W6@0f61=SGZ#9?9w{4uKV(UK zl{ZUDE*CuRRkxB@rf%Xe$2WR%*4kSaecNH*Qtip=fbn}}PyQ#o#}26|RGihcgV}z~ z{l7t15+9@860nZD#kh1~_;t>G=f4_X zdE*FXuadW0<+B2$eUr#W=grq@5@SAJfe`RHDrQ!r2wm^_Qe0AgTL${f5(4j|XuxFq z=54D1evM6o?8%`e3eHxcbg?jy0lfl-HUAI?9xaX}QBrK0_TN)}%k|OL>CXG^LG zjB-uP-C7&4M@_E~amD--fJw_+P=*0J4CTuAwbTlle3@e=w0Ev*1^>$AE8T+;bcoq$ z3Z#3`0H5v~V_i)muE4``tCMRXtA9cUeZnEB9~J{P1L1E|3rv?6sK&i-pdfdpIs0gc zmfm4&H1%iQxfOnwue_VZpPf~y&V;Q&?ZRS|PWqYpKXwkMJ3;ocbm76ppfuX%L`S#L zQ%uGF-DtOlhNBo`*$IBpr^}o)-J{&OS{3{>ilW0n%3433`dHN3y_UZjA(q zAk>u^fwoQ5J1je{tEO^%?m0oxEa~q?HTr2K)GJTm4vgx3HSmW|>K&~P(&l?Cc+B-# zL_Zo`On#xd2_q=`X*K1qVxwbRSay7tZLF#$6{Y3rtB_$IvXYmM7Vz#8MN?rDkycFS zZaiKkp5|6&shb2g(=Qg?MY#q=IK5-z>J&~h>ptZ~mwWqyQD$MH_3~5zPrq|!6GF^h zoC8s8v3zLZ!r8vXrC!xvf{k{`bKMwEwaOb)-I*wB95&)>@>U=WQR5ir{(XiTSGTJv z{NiKY9<%z#!h;8249PR*Hf5HQ;%Uccaa7zMPJO)1A#n znS=Wa?);PQAR7TA(YSw8*RVW!b^xIodja_anHLnTo{{j@&{>h${^m5HFUiAPuJToZ z;{EBb*XP-{%Cl=OWx~Eb4R`Mp-w#C2wtQZ_JU47Vh!I z+40K}yxoDd2t=7_@2dkYvr1dX+8{F0u*n+m_(Eok%gWj-lSzGc!PKT~4KUce z*C zU$F*yt?u>0$ikfbOXw&hdSl>JWq1fUtG%mq# z|4-I68A^9I92^moF!XtcnqWhqJK0qKRRHn&3=;5o<;#|(sqSy0s6BDux2Cg$6WjPb zB12;HAT^(Nx~3r8V=DzmVr&IF>LbYp{1Ni|0=id%s<96DBL z?bbp3kMtVaA|n(c75ca?P)j!@Nk(F{Onv|oaxqTR*~ec9sTZcy&-S%!uhBHuGn(6o z0beE3tPG+bHL6*Yhkr9xXkaLg#Bc(9hZY)^8_|O15MX+Imoh%aCB!DWV6VR0Bs_$S z0M=AW$`6LGTZJgx89ya`M@m_02{F0=P+ZzFFXlQ4;@w;W&Bd+i$Ju;lb4kq0pGrMc#xG)cp zE#B`DvebpQ6`;RZr-mzNo9^myuL6qwuI8jYUpK8VsW4uEuu;Y8HH_9!6^pqM$%KUX zzo*P;SZF#_7Y_Q*rAx@wjKiBd(JSY;v_#%1^I1x=dWEuFcILv-idas}5-sue;Uvsc zP|e7Ax`Is>JH`hadoxLlW?NAVkD0;BJu9AgZ2hQ*fddA+10N$i#U{F5q#2h+)INZI z75#@!jV}*s^Fc2fadNurbB)6y9KIHsAFtw2F0>!WcNUuU7?Iq)>98tLHqKHwwtMtG zMjc)*H`-U88+&!~nbk}C8N!3ZocT68j59|UI+Ri@@>@`1*Jb~NSJNG#LXzz%>W=)d zoGKx`riMa;e3$AA->+3e<#=+73C-C)ORdrFH9C|e1Q+{s;GITMzy^t!D9=2IS<0S` zuY1b5=UUGSO{7@j&%0Iw&LPI8P4SgyE6P+H&|;a~TsIyL0k{gkzORXN#QFch&Cy@o zCC&z%xnZ3+GyqO?AeH-}9tsz)$&TF&{%>CJ;v&G};jJMC!x4RKJoV1%gP2aFD4i@k z+ULF2gASTp#@_k=RS@jr)tvA3MUS)nw0mi!2Os6D92T7cns$g93InLrc*{yRGmu}W zQKUmM%Mmegflw5w)ROVZVlq@j>HexEVgCcX^(7Wxu8v%~4d+sG&^7JuPco>=$fn{d zedLllSsZcXI$Ob^-x!AaJk{0Cema3;?LeD6tWN$lcKBlG%QwMeFD$L4NaS`KgmGAW z|0hAR@l)?@CTb~4nKdB=2^o&?RphrUC(8EGtJQ^T3G}8yp-NPS=yqE?`>)62LC3uAF@gFH!TP#a>^Ji% z>Vg3CF+`zyX7o+nMi4{Tjmm>#8>D`T_>-Sm^;SLqXtcLNE#f7g>nLh3T|muql31>; zt6Af-xmz80i$g5(OaUx4u!iX<=H~n{ob(lqQI}T@;|-jVKPW($25)^W zm*{T^oqiU(c;7LPvtdfGbYVX((!Ojk za7BpFwovPS8hL{2L~aSV*}BeqmwSJ_m>gx6l~bVeMQ5czZ5l%GG;*&Wa~$G4Glkj^ zSj3SQ#Hx1jI%=oHGD?q*USAUPWVx#%n`~TS2kRtS9VddG{`tq;YlSiVrfR^k!KOdp zFW0ttA58W$=73;2&BzmXM`jeV&hOxecj&Oz}`%Av-YzR^F&k%D1d2)?fa zk`*|!$@7Zi-T?N`FePQpyD7FBtNZ6Ux#uvUJ9AKy@Ydns#&fS3zUBdP*;!VJO86{- zD@@BNBqtHpJZtUaexz03{JUb^&jza^|4&t~nraJU`qlna)i&K0&)YCjYD~C(bI_qr zc(Pci8WfPxHNF9)y^XWz6WdEH$K8(h{)TspphXcMSX2LLgKwBlH=yk}dcue0QFy$T zt|Kw{`ObVddawA}tSjLDZAs!jX4ZU)|GVvUY(F5h;6(BMA;K8^vEQd{RB$d&`BKdn zDOBk^S^SFO&eBi&8wv%=YxXGEt9+6oXND~(s1X=Kt;+4*(PJb%&t;Je$Q5Um@4$ld zK`za$5pyBfVE+%4s9BgMhmv3XxW#9gAP|UaEpi~v{JmFoH@;SYWhOT2(r%mJA9gmN z-U)w?9DHiOeZX^z+`>KEg;JZ|T|vu_-+1E547KFd*UKvCYcf1S>W&oc|)Aphf> z@_vh{HaX^SZRIK980ovZ$sVvNw6}q?fQOB*-a&-^5}sb*0Dnk`VelU17s$W$bdR}l2wc7Cv{zmnMEI~eVU}IX~G>)g) zC*%Nh>k5_s6qw1GZeIRhjho}hv)CK5)!)@EvI3x4vlHDfV4!2W+)9QAJ7_)U!;P1= z){aqr*&HA&dgX^|+f66N7~fRD?za2{^%zM`BN_3WBmMBs_-^)&9ra5yb1hTFp~(Ll z;v=Qe#=72O3W2He$)k4|sFC^}EOuWbCcOTpVq^bJ@Nc4n;mh^i7KdEH-VMJ?Q&Y)} zJJu$z{#_lti$4cIZvMT}eMX6?*!&cbP+9S*u8^`)W3!6shN%?k4&qcA$0*ch!`$5l zS6KWRz~+y*F50V8vs#Ovr_m~f(oa<2F8D21f@_@eQ}4G8zqeaUZn0693eS&@S4G%6 zW-)hz&@W!>^vYctqG`DN90dnc+0VM0?DaK2LTBRFEMe4oNfje#j|6j^&cHh8EqPmgn` zqdUUZ;8+4lbw$6F4YMI?hCyQO4{=GK?)o1pF|Wo`SZ!AXM1HOn9rKHgD)ge_EkprJr?*QD2kReXSopKB6f0d2 zYUn6ZM$nbP&iDlPZDSkJ?S zyDSrT$xJf4XZ#)dt9h+oX9b#V)y9Zxj*%`&3}_x-S8FcRnulmKO_*f|yxmT0tf--= zqY7GgHJ$rIw3J#zti=km;boc*x+)ifqHzlorCIW3Ci>+KRg&yJ+FFDrDMj+jj=>X< zrd^L$3EO$42GhIH(!S&e|Ko&?UGm*1KGrw}pdtz}4NvdY3Gj(#X6Chv<^Kox00{Xw z$Cu^uPO;EC?%v1Glbh||IW76`M1%9WQF$B{4V(ZM ziCf^8h7>Y%TuACgQsO|&FrUQ0?z@MehFV4NxR5ZL-Mp9cedPsu!P6N*7Dk(&p@wc$ zN}3J-o9vrt%O3woJCrNNE(^Be&l{U`t~)qHXo57Chi^L{?>9Dj0q3!{5hOlZ5<06) z6xpoIy2NOtK;^X3Ndt_GEfBPU%-HY(FVwQs)dC6fn=zh*(e63ST8rIPISKW@y}Itq zphd32;ssv>cKhy&!URW~uBvehIzbXC1$Qkyvw3XSd+0~dWXdA@Nsd5oXg@bS(d-su z#Rd(fhD|aeag@cdJUssT#@~!{o4XAQy3wwtLN&pbFsN?<<#2$NXF-m9&Dam0u$X6V zUwKNmF^Y{fSzyX|s3yERD__Jur67f+useRXGyyhCe7(5WQg27wUCqwfPq0LiCw9?R z!I=GfvS`n*t-U{pIbOv?kXnOC;K0^?eUD8e^uR)z+!M}RLB;xkW9@aU2c6zthLWSb5gZfXz=DX-KlT~(o*XsY_ zPm_E6)i{x)Cs%Hbm46In`kQbMLHOn2**I1tdO~?_IBfzP)cySr{RM}7DMO4Jy~m1- zFrb55Fy%GJ!ldja`)?3Uvw*7Olh0I9pD8^y)`opZL-YKPW$v7JVhbFoj_l?P`M_6w z2+nMG@HBt5&z7d!SDkdS1jF0CQK7*d`asG~vVbRVh~!cRd6~-o;1mwwq7*i_1OEzd zC6=YhbrkG0moItQSxn#GNKj5M3RwLBaphW84wlR|w$ylQ(BDRN1+OPB1U=9H`kXzO@MBJN zboDRwUZRU1&RajcpmCm9QW2KmR8>tdcG>COt7V!zylMe_HvvWg`ODYcA4ZFE*<;S0_F5|aZzp{jgA086E750+RLHXSmBMq7b2>% zer9J2j-B3;U@?=grm=?2i-ZO>LrD@`Q$hO-ryO3D>oO3O5incu$RoBY8XTNq~ zZg#n66I>AZuz!CwoVM zhxGm-&Lpg?#p)daB}axzykPzrr+d7aYF*m%x25Szx_>I!&94cRl{n{ZP8&^qFiu^6 z&iE5Gr%3xie1EoJ=`xmNJ}ie4UsypK9vM38h0Mcdq=_e-qB>cV$_p15A01K&>WB}N zw9v<=KT&(Ga{czT^OZ}T6F{&*=aBy#kwU=M&-J^fsr~kp-0ZW&d>7l^a>QhM<_+-u zplQUUm#DKNDRDXIL|wK@v%kx2%a-XUVm119PugG1J~+vIf@>;^4h((|4Q5j#o6txZf#Qih2Bl^*%P{^wg%en@f$7PY7YS|030{ z-_al>up&&ks`!EytdkfTzz@awcDU3`9l z)8#rO<4xjQ_Urim{qQ0f>=}ywJ+fY7K)VWKnCrv z%j8k7Pb-V(z`*YEHF8F_U~apf;Zbh4C?oT|(i1jdIA5{pIMq*J9KLAKvXJEJ)8Y#% zy{(+|3q-1JA^rDP?}&Wh&LiSD!o)>k6fRq%sNRZ%2P*jsLG%EXKj{TEImcg&MsTG) z&|7%O(xsXx63dS7pIRRJ0YzhG=!PI(H*kAK9d;m&D}$jS7(Fw39;@!t(5}tg-Ft|F zC#sq#8FAf{j^D0<;qxmLX=8q7C>fh<-pq~#QG%kWyDvAmheiEe)?*8rdhJ#&eMSzJ zk{!kyw-etaYKxmwADt;|rvUx~&cvxh-~yd@g&5I%;xKk&oEK!co%t*V6$G)`RV&1- z6{6~VUDiY?G1@C}M89tK#4SKo-Irg8eOlWu$!s`oY4c2ZCF{KpPTNnJ-*mY154T&L zt%BiZZp3Oh9Y5dp+md1$?2muQ0+kuTofL5-_G$rcc|%jxf+%XDEhAV4yx5DUWIQtI z(%V^x_N?997m22}*DFYrwy~3GYfwgCcZ8U}f&J$cbuUww8DXe+erg>DZ0}xp(a0#~ zD#8Q>WmlG*HxQV~nWU8HdL@D`xg>$4j(d}+vJvMRhKxm68E>Ph`4PZaNE&Y^mgtwr ziWQbhGnmAejaAm+K2Kh|bFL%rOZ~<`zZ#R*^j~0M^%{j?cyD8F(t_a?uLo+@jM5xd z#wxx#O{Jg}s-;`{gf%UJ8=$ALmibZPP#SEo;R8f{%c!n}yw!4-%)njWaA)X*_2-QV zL6@;_7^}jFJP=wlfFaWGBsL zZSzDQ>u@PD==kK8*Y5h8L6IZoN1-&`uhU=8|5n9Grgt1%%oIVN;9hDncZ zJ~5BxdFxNNnYi*V#X<@?)ZP)~T)t>s>C*9k7L;DIbRmTZx_9&p1RSb?&L~#{ZXdfs zk~Ke3u=`-e4A29_?Q~Gd=T2dArkGopn5P06g?z1d-7zTX2j*@vZ&K-2KW5T^6OQ(^ zgNGN!ObnLvcE;ic3MH$$eQTemAxPMNSBCXu>WCDv>FtPT1I0)jaM>d9!fSR^FIrr? z_3k0BYWU8oTBs`8Z(P`iqlnM)a_`x96qw6Ej;{ifr?h{u{Ln_6(qWPUcMHoQmi z!YB5Ra)4i{>o*>r1NjDi)b=rBtaV#Sv9#`>#~I@?og^vO9a>2V@Umg| zmCL|%@N8pBfl+p2l(`R`V?rU?X0c3uWe@mW-BBg2tx7wtmA=~S0tixEZ~-C5QO2(l zWqv#Q6ZZt*ybapJh|f<;@54jXs7SadgU;esC(czi8AC`r?6*7?Zk)DJ93~bpG%Ylj zlONiPn6h6EG=O+$K&YwU;( zia^e$3$BPhMsAxn6#DyyZwmlr{tiY8VI~lYxw~&5UiFr*9g!KD{cRlJE2XV1ia`SBdTI(C?DH@y;6?8cuAsp7O6^mOTPfI{$i7F~_Z zWrfD6FDMQ#FY!69FO#r$@of*?=8|$t6xc{Yt*b`P$nR7fkr>k<*V&HpT&iOL=;(B*Bb>ORe z$1)riQgCTl?QtILouR8BKGwpO==6;B1G&Y8VjO?qE&3BwwEj*?B3e4_ev`)gVR&)u z%tF`iB0?y*fcWclXqrpF&W7i3`I9HdVH&C=A?E z>xPY_JVaC5Znfys>4~j_79nuz(IYSzO^8c4&+tIt_a!?NRjJRNA)a|FDPON__|dqT zsN=1bF+gs9qnqdC8x17FI&L~0lR{D|4*2Q`4zHR7c`^x^X7hSoK|O5mB*~)hy9fGT zsGz8w;{TEW7OE>>N4uPTxaOW$qTag$>)5TU|gjk~$9 zr$%@#kGtqpG2>Vs(?l7~?x*kO&=?Cx;`arhf7Nd;%P->1SboI}`JKb-#l1W@-~NX{ zWb?gbx7U1Vx9%3w^s?}q#EU=q!C&9Qj(akBKH+>!%qc32QRuBz=Dc4uno`2lS3R(34r-;{Iq)wi%E#^J z3JLGUADZfDDYOgwh6ZJRaL+#|Enkzw+J`w^mZG-Em=y(yB}}f*x$TBeql}?QOH{jw znQc-9w+*lU@CSu)TscOA-!v!F0HF*OMG`7EMXOvtSr%Y@Zu58Z;k}Qq&GJ==mQkY% zZe_EGbUf6RmiDy*SQ@k>h6c|{`ZBCbAb1BB`2re-I)+y;7SHBqy% zAIhCe!U)snP`!$-&sNQ>or(i?fW#+gK`p%v*X1l=NlOs45T&gZrVtjABn1+u;gnFK zHwUc#+J4KdXK-iJaY>h_`_W|(4LC(TG!u+L!#C6GXRfODi3EhPWRUZ_<4(rwe|&-$ zpS3hR)9S9yqwD)&7ntHx%5;t*+)$+Q{|lzFUmi@8%Q&sht2s`tk2b^sc0t-$J>VWU zj;b6W;M~IrP5b*dbiw*pz_3d!vM52W(|t-3fZ9Kr8aU%-)IQ&up7h{3nm_5x1yf!> z`4S!>Q<{P?TWAeyEpak&%`{3r@(}U{Q${-RX&qtRCdhBHrd&#Mhg;`O5sNxK$=2pa zjBd9Hr_(Np6xI5r#BP4g)(QFh(s~QTy$>d|v06L1AXENV6lXz50rPe1Ml)Wq;QoT1 zOm<0k9Q-4oOiU%@^9EeM0%*a0s(dlzHX zY5pL2W%4B08&p)VhU1s|%hF|Mp}}{IsS6a^(`5>(ARa-`I$D5=&Hi(OI{$ff(8v(yOB_aF`X^91 zNBySc6i~CG4hJ?g`1@KO8=f*(ptd+^BCPwr#S-2D1Ae6s?9%>T?tg!j^&hqWxGa{` zaW$WGI$w<3r~+0-m&42WG5tiwd$gF8`CP^}pfIB6+b| z58&~pCkj*|FMmZ*C+wEjn+6(zY~M0d$PwQ+ooR#SYj3%iwgHWgd9d`0Xe3BsETwND zZ?L~WRm(C4O}y9f{6;4u^_lp{xI(loS?tS16yT=B8hk12_;(EUzmVJiF`*nQ!c?j7 zSt?=rxqxgD=~oDIl0H)r$%Lb66YMA!&s3kZ<>gw#fti`u^&d{G z>APT>7B4eD3>5@5{%j&3lDa!?;OoCgZvBQlfLL2{HpmSGt7(awmB(X|pdOQRW_=7^ z+_8K5$1rf>P-f3!=lnshTI8;=YF_Y?`h{Y^Okyl);FXF@A=2d-JA2MBJ0?oWGyJ|D6PT3XV%SfH=dbJ>iw==##;^ zBV#fnBc9qRZY+Si_Nks--;I8^_|0v|YvmW%+wOA~*lLilXwTx>^rd1TI(NvS{!56F zTN*8?4yxM7+ub=>Gcp*W@=s~7|1J01?IXLM)9*tCj}+~R2^g!unybpwS+(LaQdXPs z=vhaX9(rW(4n*uv4{U3k!(|Smb1bh1Y)sm^XD+K}DngnCn;k=>&DTd)zgWHkns_nB z`3Kg}D*qz2^{aeP^0y<6r<_YPwj4&s18%5KUxM~C~}Ai|uXQm~5V z6|y9jg`ITXOw5+h7s%Q;wO#*|J=AuhIdic=3M-K!-|${gy{gBj32IuEd#?27iJ=Kg>nEpJiKQ zJdn(^YC{_XScB`uOea1N>@G1~D}W+mP8j8i{d7dP=CY6JOErdRL1B0*SpwWpndy;Y zV0Q3JV+w^+3DW9(6x*0CPjCY@n~R*i62?44itrtkH?~7zd2AwtbT^kD7={0+6_~cq zt$hcjb^q81G14bt(>Noi3{h@o)W6#QtM&q>Fvl#2`q!T;h`BEh#_vZVmXl7`E)<$W- zXARKnaOYh_!-!O2HoRB`jn1DfVXj(%`<(EALdNp_SeE&#pYGQd5_?I!So{5mlZ;w% zYU=Fs0pP?qZeuaw_={Td)Ycr-zLvYoC$P!vBv#xzd^x&1fc1YF2}~;o_ti3X6jttw zCGk46NBD4+CT#Hp(e=f8dB`7#w~we@HgT+0AsiF=g?(GMo+=u-d&MmTtF(0diy?3wd+k%|DHj$Tc9J*Ax zA-jOlx=AudIN-Hp_NT*(-D`^F*F-=$b=)HU^Pmow?RLJ|k3oDZ)p_+;{0F!C^?th- ztgkgV%1YL810=WVQFf*1Tc?@fQ_ySA&&vb|mJpOG zhd`*IDDi9?dcR(HZV_c+AfCE;gE-XjB%88``yyx8dIV&)Yt z+&P~m*LbKZ#g&AvpFZ^#5LZ<=1QLEjSTjQNZ_al#nwJC&p+Jr&Qm>}3#hsT7(+hA? z2j@~s*0QR6Dox!^s{Ie}zkhCAfETr7=I#Zn+^V8?fvGob}SBL=AD3a1qdcACRlzmF4#bZLVD@nOZtl1(HfWKjyPAbD zw}z~M=qYv@89SzsE)IDq1~-3U!=B4KQBn@#vk{%6*THGmBKdDZ{f>V&>g1uW%{j)O z>YcLY289RdOpn{F$AQdVmM@q6b_8|_abfG><~g}~tUn@o_0bY;E<`3g8LlanG96(U zJJxwxzq~BBt>tdBb`kT31Xz>Ic2z&JeZN)?ONDXc>YxN^E+Y_)r@9d->_iY1+0_yd z8SB!<{Nx%@yEZ`{)E}pC|8BBTl;u;&iLf@zA_o3q&>(Y8&J)uVp{gJR&_U($UMD~h z%9W`;@NMet$u}#8NlZ1U-sOl8_|3O*2;&n+{?(Q{Y%!5k!CmamjpnQFXXm@2{1v|R z%NKmmYM*-hqNa{)uW$l4kke$YS?Nn}Hauc|J!S+fSaJ!iuYe!huSC>H}XrI`)bG@yiSu(1Gl(BX_Z5 z_?O7!PiiDByFh2+*jFH{7^BU#7HpW-gk2)*e0kfBw)-;G_>@{;`>RNXrNOv=9EMEz zr;~!X{Q>sGH(`KruvHHcO7Xmiutpn}w>dIhmX`woK{MCo_iqQj_UU9={kk>(0T~9J zoz%T#9h}1>4+^WP4ls@`@4M;TGL5z(K&Gv|gJb%4MC?PT5)MP@?orkK+fjIPj}s-v zi>%KVp-K2T){ua1zS2IMe4ja1!(%9mKJTf+a)%4ht!N|bkS$~Lh?dy&-{mSK*j^bh z?kgcsklE(L?j!kgvq1bcntXaRVKN~h%@nsE{uWNSHQzZZQG7_(YB`~cI_5aL==pX# z0N)@IN`=B{_^FYzkpkl;qUp)H#xt%xf#p6P#`Uo?o39VjS6YoFglv|FQ~`QFLAEk! zv&b;QkbnCu_G9jM%sIrkbH5=C>~(KdvHpz4iY`B4$sC3xf@EyyU~WPEhAe%PzODm> z@K;A~+Q`nE6wcHo)Toi{vNX1-XoxLz)=~Z=PwYM70#PL0&ndSCw4lj*Du8|CQmR?B zGpU_aqcL@h_2zznZUz~kUV!`jcUOzlChX+tliR(vU3<8&%5cb7tp{ID&nXwZK^!>@ zwHdUND^t%Z@dIRVDdmQ&8{h0=8Oi?FU8w#b<02K- z@&HPEg1S_-kK7Rjb{04;7h!G~-jz-fXW|7~{QODAGq*gub?*LYUL4?<$%>gpc?dWL zb#sufkbbdYxH}MmU`C3UtTfAFF^d<$bJfMBjS^1_38j~N(_3CL*e{33PFZxAI;S9imN58(Ao8zJHOy0roT1A87kys;Y1X=r{wVF zObV+=4rO9Uj3h4tPh2S>NQsrH1~;@4@e{j375&cs3iOb>(XMiFyM3?Uzu%HWD*{gI zYY82m$U|eWGd|g@IujF0NgREvH^bg-1t<$pLgZWPlm7}v)M!;Kz<7F>O9G62=;<`>2`_5AhL z{p2s6ivwg=Vz>-qSQ{x-wHYG|=q1?}_G51J>N1yxBs`U~M5m`IeM}xKuE7mRzjyD5 z1Hg^Z{%Y@fHN=hWhWalNNDTzOV{Gb*7OMa|c?a1w1#sjKx3||OA0S@SOwfa^I`@5M zoMabGz=W!`9CZ>_8mh#!4VijTh$OgcTsUII1VR+iZ_NnzQ%%Z6O3=s=@W5+k zuiz=LAF+QskwUO1Uvef$Lc;S_ha{qo6{Ur-CYJ&7;R$q;L;xKBK-=t52I^g98vGru z+LOxqQdz~C_#T%3tD~+6y`6XHT?AH5BRUkiLnP4Gm0I0v@&FeB-*dM3a8!3|K4dBq zd{8yL9qi#sl!Y*1xjDW#`8482=XQTvxdgH}Ax{lr40}0{evXkGfZU!kd3Dm2JA%_) z(f*Nlv$WGqHYb!g!NB+2K$+zxZvy#BLsPR*6+&1P(9fp_}@fOZ7__f*2_s@rUBM=V7yOhB2+{p^wf4~6!V9)OU&v! zkKv0b%ngy?2-Y_O;6nXoYntzjm><1=y-c+EM1JZ^NoBcDP=h{g`-}5 zXHt!M#b$T|N`Uz((UEMqb+u;%`x$D44@I zkKxmtf;|73GVfCPQabCE0@~j~$mToc%qgXbcawQJV&oBBaJL<l!q#UFDM*h8f*YzRzGs%ip+KN&=3((i zKa4Kn!Lf$639|V7g_?jycBejCy|HwyB%EMKUYSuQZI{i|eP^p+9I&+`-2LM~ck8L8 z)1a5FiKt&3YXf<0s=wc)K$v|-q9e0&zAUkd!7@S0`|Ec{2iCg}pOzHR$yF(6NIm>d zt>S;!skM&_3rk4+0jIf}7k%-$%_{odAdKp+kbgU3c0>;<*;OLCu&40FXFc%VEUudQ zlAR%fekWpylazYPb_x&8)Ze}l6mLnqQ|=kEvLJaXncVyciea`V)|Ub6QSEw)yH{>W z=_w>tjoDv$NB!NTdWFWa+i9ZhYJ*$1&K2EnyG<^XA!SBoL+2wATH?%g?2Kd^J9QQ% zQ{6-RoGTRR1A_C-tr78z)+eu*u-cV_>~m(DUEx3q+0tdK2ar}Bt+IiaZWGtWi`jy_ zP|GDE)V3t!^*x|>p-kRBlvVD7D_;yh?u;a1cOxy@-NQ6;)x1j9N~76kAcGIvRS;2} zx}}m(70YdtWvT#(J<#h2GZfXm52*%N4~FUmUg5YI$`S6(bD!L+ z$mqFuZ(j%-Ps9m3;vM_i4VFGnodWoqi`OQN!0xvbY%&lmYLAmGNM0oeiU@7L0*HAJ zNpOe|Qya(CkY6!A(+0ZdKz263S@s7G;ADmeGsZ(~X5B@O}DoA=`N`Rd?xlnko4?>d*doQoyHwhX9vSvl-ZxY`me zQ|8!JZt9uO`mqey3_>-TctN+pqC_7Cfe<)ovd3YYhFuYdtWhcyOivbryaFCIPvg$0 z3UlJ>SLMA`nPH<$;v+@AhC4xresaq`VT%ZV&A~a?vC>}6{cJDr_H*1!7H2`yDweYF zf?!op8{!uRb3SW2j87cY!tak2Pz9+H`i1rITHM z*d|V^rU}Gw{2}hSz*;OdC8&ZkA}H}rE4LBdinT+VVT-LdcToH4-or zr5*Bn&Z2B$c@Nk#ruKu0>ehWp(`%1~8+dOC*poV~oF^{=N{aTf%hsTq)cmjv-J~hM zC{gYsZ6qU%BF1${dF>D3Hfdl)2_=_e*Rw7HzB)MKwax&X`doUh&!O8|sQ!bN%j=D6 zn?KJQsghen2zhf-IYQFYDAEIMoZ`xWT8Z~A()!rP!Se_6CFb+!ePd_uS}Bn144E<(0DI5idp7)14Xj2;*N-GQJJ8 z6vgqC8B1AvO^{dEq`ac;tWUtad;Ej)fNM$LDZaJt0}5*?{})VqGD1lg!aq5-H@bgK zLiq%~cF%?HJ>S2p;D%e)xt;R}6%<%0IIJg5%jti9}lAjmaUu&~4pq~x7zK8y@`o)d*XN9|xGl_g$% zAEXSm96kO1z)G|6S~X$Z2kk#0iaF%UC=bK;7xdAlPXkFEkKD>Y6!+As{C$>Qq*BMe zX|w*p5><3ft4?Oz2_jv*ZSyLTmh#`@jg$oBqbT#oW8W2Yx#3-XlUZV3H$Snw!0J^K z?;Zo0+*$wh+aygb^Rlxw$;%zjQ1Y+cCt1J~gO>B7mp~HTWMd>6)L&uan0ou4q!@W>lDKcJ^0mXIUEC1!}!X0k}?gMDL*Yq%(cd^A+oR= zRYD};h5hzrMyF~Iu3}xOP1?dqRt#1KAQD`L!W@0uFB2byyLW$%c(1hF^wtws?;-*N zvtyl-w(kVhTGmGzJT{8T*+XB`CwXm7Yyi1MStC2MT#b9)p8bL$6L@M~9)B8|?fG6m zr>s^F6bSUhiOk|5xxWw3UJ0;cBnh|#0L6OaGTxmL=``s)K$ISuLm4J14UT7XguhQs*k0mT3Aj zP2=I`@b5>F%-v=b@Zien`6#`lGL(Z21Se zvp*EBk7)YrB53pKmiGup%S}xv+O~b~?UekTo!6(nEj+{cXc@d>9rZ$TH%At~G)3S$ z=NLaV9CqV4WISP~h7q>Ma4a_(!LC0XE(8lJbMg+R_zZG-W|bysA}1nxlF7y8Xa zgrcQ(-tsQGpW9NbA@b@t)k=2Q4;+;GJYkQ}TPHGE&OFk%jcZKtH!Zh+lD+;oWdC5C z!w7rEY{r#Dnc%MUJ7JmASx^@wI}_KNF|1T;FzPUl;s=b$;lUpHSDo3G+|N?eRQJv} zNRD!`9D#O;(zQ#f(%L^d-9LpT%yNJYN+ZmaLkKL?G55cijjfnNs1O8x6kf(QxY@So z4-QaIRhoDowqty^1$K0B`ZkZF?WbLIAb0t9tu-n$0|-LR(t!j9-egN59)=xf4m9q%2~Ol!Ua zOh?ha+6ux6C#H0w3b$LTdn7^ad`MGr9I?ziSAA3;NNV}HQBG61XJ>N`Q(*qmI&^bxY%azIFH3q3N!FK)H zBya8p&#D2OW;wm!z_S8%Wvl=A5EHb;>6lA-r~2I8dAq8doqN)u3YJb_SB^>ffg3$D z7&E0NJszeC7Yn$YgB2iW=z$Wke!pco;mY-nU?=GsJe?oi%d=~2ak&e-^NLQ|6`gjP zV78nw3l2Fwn`)ZA)^wD8`*da6oO#QF#VNirQ|~m^QHA8XN*s_k`tc|a55rfw{~sUv;^XDu(fxY=DIFA71zmIB zDC!XKtb44VYrtVZffu+-OPNP?om_G?tG>MaTp%@+oG2dqr`P&)#hUy4*Q4j8)`POb z93SMyplB(2N!cn;4;2P@w&3ftB1{NL@YKSE+9?CX%Gu85LnO!-{@\d+(?:\.\d+)+)_(?P.+)$") + + +def _pick_latest_cis_variant(compliance_ids: Iterable[str]) -> str | None: + """Return the CIS compliance_id with the highest semantic version. + + CIS ships many variants per provider (e.g. cis_1.4_aws, ..., cis_6.0_aws). + A lexicographic sort is incorrect for version strings like ``1.10`` vs + ``1.2``; this helper parses the version into a tuple of ints so ``1.10`` + is correctly ordered after ``1.2``. Malformed names are skipped so a + broken JSON cannot crash the whole CIS pipeline. + + Args: + compliance_ids: Iterable of CIS compliance identifiers. Expected to + belong to a single provider (callers should pass the already + filtered keys from ``Compliance.get_bulk(provider_type)``). + + Returns: + The compliance_id with the highest parsed version, or ``None`` if no + well-formed CIS identifier was found. + """ + best_key: tuple[int, ...] | None = None + best_name: str | None = None + for name in compliance_ids: + match = _CIS_VARIANT_RE.match(name) + if not match: + continue + try: + key = tuple(int(part) for part in match.group("version").split(".")) + except ValueError: + # Defensive: the regex already guarantees numeric chunks, but we + # keep the guard so a future regex change cannot crash callers. + continue + if best_key is None or key > best_key: + best_key = key + best_name = name + return best_name + def generate_threatscore_report( tenant_id: str, @@ -191,6 +238,53 @@ def generate_csa_report( ) +def generate_cis_report( + tenant_id: str, + scan_id: str, + compliance_id: str, + output_path: str, + provider_id: str, + only_failed: bool = True, + include_manual: bool = False, + provider_obj: Provider | None = None, + requirement_statistics: dict[str, dict[str, int]] | None = None, + findings_cache: dict[str, list[FindingOutput]] | None = None, +) -> None: + """ + Generate a PDF compliance report for a specific CIS Benchmark variant. + + Unlike single-version frameworks (ENS, NIS2, CSA), CIS has multiple + variants per provider (e.g., cis_1.4_aws, cis_5.0_aws, cis_6.0_aws). This + wrapper is called once per variant, receiving the specific compliance_id. + + Args: + tenant_id: The tenant ID for Row-Level Security context. + scan_id: ID of the scan executed by Prowler. + compliance_id: ID of the specific CIS variant (e.g., "cis_5.0_aws"). + output_path: Output PDF file path. + provider_id: Provider ID for the scan. + only_failed: If True, only include failed requirements in detailed section. + include_manual: If True, include manual requirements in detailed section. + provider_obj: Pre-fetched Provider object to avoid duplicate queries. + requirement_statistics: Pre-aggregated requirement statistics. + findings_cache: Cache of already loaded findings to avoid duplicate queries. + """ + generator = CISReportGenerator(FRAMEWORK_REGISTRY["cis"]) + + generator.generate( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=compliance_id, + output_path=output_path, + provider_id=provider_id, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + only_failed=only_failed, + include_manual=include_manual, + ) + + def generate_compliance_reports( tenant_id: str, scan_id: str, @@ -199,6 +293,7 @@ def generate_compliance_reports( generate_ens: bool = True, generate_nis2: bool = True, generate_csa: bool = True, + generate_cis: bool = True, only_failed_threatscore: bool = True, min_risk_level_threatscore: int = 4, include_manual_ens: bool = True, @@ -206,6 +301,8 @@ def generate_compliance_reports( only_failed_nis2: bool = True, only_failed_csa: bool = True, include_manual_csa: bool = False, + only_failed_cis: bool = True, + include_manual_cis: bool = False, ) -> dict[str, dict[str, bool | str]]: """ Generate multiple compliance reports with shared database queries. @@ -215,6 +312,13 @@ def generate_compliance_reports( - Aggregating requirement statistics once (shared across all reports) - Reusing compliance framework data when possible + For CIS a single PDF is produced per run: the one matching the highest + available CIS version for the scan's provider (picked dynamically from + ``Compliance.get_bulk`` via :func:`_pick_latest_cis_variant`). The + returned ``results["cis"]`` entry has the same flat shape as the other + single-version frameworks — the picked variant is an internal detail, + not surfaced in the result. + Args: tenant_id: The tenant ID for Row-Level Security context. scan_id: The ID of the scan to generate reports for. @@ -223,6 +327,8 @@ def generate_compliance_reports( generate_ens: Whether to generate ENS report. generate_nis2: Whether to generate NIS2 report. generate_csa: Whether to generate CSA CCM report. + generate_cis: Whether to generate a CIS Benchmark report for the + latest CIS version available for the provider. only_failed_threatscore: For ThreatScore, only include failed requirements. min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements. include_manual_ens: For ENS, include manual requirements. @@ -230,22 +336,26 @@ def generate_compliance_reports( only_failed_nis2: For NIS2, only include failed requirements. only_failed_csa: For CSA CCM, only include failed requirements. include_manual_csa: For CSA CCM, include manual requirements. + only_failed_cis: For CIS, only include failed requirements in detailed section. + include_manual_cis: For CIS, include manual requirements in detailed section. Returns: - Dictionary with results for each report type. + Dictionary with results for each report type. Every value has the + same flat shape: ``{"upload": bool, "path": str, "error"?: str}``. """ logger.info( "Generating compliance reports for scan %s with provider %s" - " (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s)", + " (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s, CIS: %s)", scan_id, provider_id, generate_threatscore, generate_ens, generate_nis2, generate_csa, + generate_cis, ) - results = {} + results: dict = {} # Validate that the scan has findings and get provider info with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): @@ -259,6 +369,8 @@ def generate_compliance_reports( results["nis2"] = {"upload": False, "path": ""} if generate_csa: results["csa"] = {"upload": False, "path": ""} + if generate_cis: + results["cis"] = {"upload": False, "path": ""} return results provider_obj = Provider.objects.get(id=provider_id) @@ -299,11 +411,18 @@ def generate_compliance_reports( results["csa"] = {"upload": False, "path": ""} generate_csa = False + # For CIS we do NOT pre-check the provider against a hard-coded whitelist + # (that list drifts the moment a new CIS JSON ships). Instead, we let + # `_pick_latest_cis_variant` over `Compliance.get_bulk(provider_type)` + # return None for providers that lack CIS, and treat that as "nothing to + # do" below. + if ( not generate_threatscore and not generate_ens and not generate_nis2 and not generate_csa + and not generate_cis ): return results @@ -350,6 +469,13 @@ def generate_compliance_reports( scan_id, compliance_framework="csa", ) + cis_path = _generate_compliance_output_directory( + DJANGO_TMP_OUTPUT_DIRECTORY, + provider_uid, + tenant_id, + scan_id, + compliance_framework="cis", + ) out_dir = str(Path(threatscore_path).parent.parent) except Exception as e: logger.error("Error generating output directory: %s", e) @@ -362,6 +488,8 @@ def generate_compliance_reports( results["nis2"] = error_dict.copy() if generate_csa: results["csa"] = error_dict.copy() + if generate_cis: + results["cis"] = error_dict.copy() return results # Generate ThreatScore report @@ -569,12 +697,92 @@ def generate_compliance_reports( logger.error("Error generating CSA CCM report: %s", e) results["csa"] = {"upload": False, "path": "", "error": str(e)} - # Clean up temporary files if all reports were uploaded successfully - all_uploaded = all( - result.get("upload", False) - for result in results.values() - if result.get("upload") is not None - ) + # Generate CIS Benchmark report for the latest available version only. + # CIS ships multiple versions per provider (e.g. cis_1.4_aws, cis_5.0_aws, + # cis_6.0_aws); we dynamically pick the highest semantic version at run + # time rather than hard-coding a per-provider mapping. `Compliance.get_bulk` + # is the single source of truth for which providers have CIS. + if generate_cis: + latest_cis: str | None = None + try: + frameworks_bulk = Compliance.get_bulk(provider_type) + latest_cis = _pick_latest_cis_variant( + name for name in frameworks_bulk.keys() if name.startswith("cis_") + ) + except Exception as e: + logger.error("Error discovering CIS variants for %s: %s", provider_type, e) + results["cis"] = {"upload": False, "path": "", "error": str(e)} + + if "cis" not in results: + if latest_cis is None: + logger.info("No CIS variants available for provider %s", provider_type) + results["cis"] = {"upload": False, "path": ""} + else: + logger.info( + "Selected latest CIS variant for provider %s: %s", + provider_type, + latest_cis, + ) + pdf_path_cis = f"{cis_path}_cis_report.pdf" + try: + generate_cis_report( + tenant_id=tenant_id, + scan_id=scan_id, + compliance_id=latest_cis, + output_path=pdf_path_cis, + provider_id=provider_id, + only_failed=only_failed_cis, + include_manual=include_manual_cis, + provider_obj=provider_obj, + requirement_statistics=requirement_statistics, + findings_cache=findings_cache, + ) + + upload_uri_cis = _upload_to_s3( + tenant_id, + scan_id, + pdf_path_cis, + f"cis/{Path(pdf_path_cis).name}", + ) + + if upload_uri_cis: + results["cis"] = { + "upload": True, + "path": upload_uri_cis, + } + logger.info( + "CIS report %s uploaded to %s", + latest_cis, + upload_uri_cis, + ) + else: + results["cis"] = {"upload": False, "path": out_dir} + logger.warning( + "CIS report %s saved locally at %s", + latest_cis, + out_dir, + ) + + except Exception as e: + logger.error("Error generating CIS report %s: %s", latest_cis, e) + results["cis"] = { + "upload": False, + "path": "", + "error": str(e), + } + finally: + # Free ReportLab/matplotlib memory before moving on. + gc.collect() + + # Clean up temporary files only if every requested report has been + # successfully uploaded. All result entries now share the same flat + # shape, so the check is a single comprehension. + upload_flags = [ + bool(entry.get("upload", False)) + for entry in results.values() + if isinstance(entry, dict) and entry.get("upload") is not None + ] + all_uploaded = bool(upload_flags) and all(upload_flags) if all_uploaded: try: @@ -595,6 +803,7 @@ def generate_compliance_reports_job( generate_ens: bool = True, generate_nis2: bool = True, generate_csa: bool = True, + generate_cis: bool = True, ) -> dict[str, dict[str, bool | str]]: """ Celery task wrapper for generate_compliance_reports. @@ -607,9 +816,12 @@ def generate_compliance_reports_job( generate_ens: Whether to generate ENS report. generate_nis2: Whether to generate NIS2 report. generate_csa: Whether to generate CSA CCM report. + generate_cis: Whether to generate the CIS Benchmark report for the + latest CIS version available for the provider. Returns: - Dictionary with results for each report type. + Dictionary with results for each report type. Every entry shares the + same flat ``{"upload", "path", "error"?}`` shape. """ return generate_compliance_reports( tenant_id=tenant_id, @@ -619,4 +831,5 @@ def generate_compliance_reports_job( generate_ens=generate_ens, generate_nis2=generate_nis2, generate_csa=generate_csa, + generate_cis=generate_cis, ) diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py index 1fc475a4679..a538416f59d 100644 --- a/api/src/backend/tasks/jobs/reports/__init__.py +++ b/api/src/backend/tasks/jobs/reports/__init__.py @@ -17,6 +17,9 @@ get_chart_color_for_percentage, ) +# Framework-specific generators +from .cis import CISReportGenerator + # Reusable components # Reusable components: Color helpers, Badge components, Risk component, # Table components, Section components @@ -31,10 +34,12 @@ create_section_header, create_status_badge, create_summary_table, + escape_html, get_color_for_compliance, get_color_for_risk_level, get_color_for_weight, get_status_color, + truncate_text, ) # Framework configuration: Main configuration, Color constants, ENS colors, @@ -90,8 +95,6 @@ FrameworkConfig, get_framework_config, ) - -# Framework-specific generators from .csa import CSAReportGenerator from .ens import ENSReportGenerator from .nis2 import NIS2ReportGenerator @@ -109,6 +112,7 @@ "ENSReportGenerator", "NIS2ReportGenerator", "CSAReportGenerator", + "CISReportGenerator", # Configuration "FrameworkConfig", "FRAMEWORK_REGISTRY", @@ -182,6 +186,9 @@ # Section components "create_section_header", "create_summary_table", + # Text helpers + "truncate_text", + "escape_html", # Chart functions "get_chart_color_for_percentage", "create_vertical_bar_chart", diff --git a/api/src/backend/tasks/jobs/reports/cis.py b/api/src/backend/tasks/jobs/reports/cis.py new file mode 100644 index 00000000000..0fbb416a171 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/cis.py @@ -0,0 +1,755 @@ +import os +import re +from collections import defaultdict +from typing import Any + +from reportlab.lib.units import inch +from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle + +from api.models import StatusChoices + +from .base import ( + BaseComplianceReportGenerator, + ComplianceData, + RequirementData, + get_requirement_metadata, +) +from .charts import ( + create_horizontal_bar_chart, + create_pie_chart, + create_stacked_bar_chart, + get_chart_color_for_percentage, +) +from .components import ColumnConfig, create_data_table, escape_html, truncate_text +from .config import ( + CHART_COLOR_GREEN_1, + CHART_COLOR_RED, + CHART_COLOR_YELLOW, + COLOR_BG_BLUE, + COLOR_BLUE, + COLOR_BORDER_GRAY, + COLOR_DARK_GRAY, + COLOR_GRAY, + COLOR_GRID_GRAY, + COLOR_HIGH_RISK, + COLOR_LIGHT_BLUE, + COLOR_SAFE, + COLOR_WHITE, +) + +# Ordered buckets used both in the executive summary tables and the charts +# section. Exposed as module constants so the two call sites never drift. +_PROFILE_BUCKET_ORDER: tuple[str, ...] = ("L1", "L2", "Other") +_ASSESSMENT_BUCKET_ORDER: tuple[str, ...] = ("Automated", "Manual") + +# Anchored matchers for profile normalization — substring checks on "L1"/"L2" +# would happily match unrelated tokens like "CL2 Worker" or "HL2" coming from +# future CIS profile enum values. +_LEVEL_2_RE = re.compile(r"(?:\bLevel\s*2\b|\bL2\b|Level_2)") +_LEVEL_1_RE = re.compile(r"(?:\bLevel\s*1\b|\bL1\b|Level_1)") + + +def _normalize_profile(profile: Any) -> str: + """Bucket a CIS Profile enum/string into one of: ``L1``, ``L2``, ``Other``. + + The ``CIS_Requirement_Attribute_Profile`` enum has values like + ``"Level 1"``, ``"Level 2"``, ``"E3 Level 1"``, ``"E5 Level 2"``. We + collapse them into three buckets to keep charts and badges readable + across CIS variants, using anchored regex matches so that future enum + values cannot accidentally promote e.g. ``"CL2 Worker"`` into ``L2``. + + Args: + profile: The profile value (enum member, string, or ``None``). + + Returns: + One of ``"L1"``, ``"L2"``, ``"Other"``. + """ + if profile is None: + return "Other" + value = getattr(profile, "value", None) or str(profile) + if _LEVEL_2_RE.search(value): + return "L2" + if _LEVEL_1_RE.search(value): + return "L1" + return "Other" + + +def _profile_badge_text(bucket: str) -> str: + """Map a normalized profile bucket (L1/L2/Other) to a short badge label.""" + return {"L1": "Level 1", "L2": "Level 2"}.get(bucket, "Other") + + +# ============================================================================= +# CIS Report Generator +# ============================================================================= + + +class CISReportGenerator(BaseComplianceReportGenerator): + """ + PDF report generator for CIS (Center for Internet Security) Benchmarks. + + CIS differs from single-version frameworks (ENS, NIS2, CSA) in that: + - Each provider has multiple CIS versions (e.g. AWS: 1.4, 1.5, ..., 6.0). + - Section names differ across versions and providers and MUST be derived + at runtime from the loaded compliance data. + - Requirements carry Profile (Level 1/Level 2) and AssessmentStatus + (Automated/Manual) attributes that drive the executive summary and + charts. + + This generator produces: + - Cover page with Prowler logo and dynamic CIS version/provider metadata + - Executive summary with overall compliance score, counts, and breakdowns + by Profile and AssessmentStatus + - Charts: overall status pie, pass rate by section (horizontal bar), + Level 1 vs Level 2 pass/fail distribution (stacked bar) + - Requirements index grouped by dynamic section + - Detailed findings for FAIL requirements with CIS-specific audit / + remediation / rationale details + """ + + # Per-run memoization cache for ``_compute_statistics``. ``generate()`` + # is the public entry point and is called once per PDF, so scoping the + # cache to the last seen ComplianceData instance is enough to avoid the + # double computation between executive summary and charts section. + _stats_cache_key: int | None = None + _stats_cache_value: dict | None = None + + # Body section ordering — ensure every top-level section starts on its + # own clean page. The base class only puts a PageBreak AFTER Charts and + # Requirements Index, so Executive Summary and Charts end up sharing a + # page. This override prepends a PageBreak so Compliance Analysis always + # begins on a fresh page. + def _build_body_sections(self, data: ComplianceData) -> list: + return [PageBreak(), *super()._build_body_sections(data)] + + # ------------------------------------------------------------------------- + # Cover page override — shows dynamic CIS version + provider in the title + # ------------------------------------------------------------------------- + + def create_cover_page(self, data: ComplianceData) -> list: + """Create the CIS report cover page with Prowler + CIS logos side by side.""" + elements = [] + + # Create logos side by side (same pattern as NIS2 / ENS) + prowler_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/prowler_logo.png" + ) + cis_logo_path = os.path.join( + os.path.dirname(__file__), "../../assets/img/cis_logo.png" + ) + + if os.path.exists(cis_logo_path): + prowler_logo = Image(prowler_logo_path, width=3.5 * inch, height=0.7 * inch) + cis_logo = Image(cis_logo_path, width=2.3 * inch, height=1.1 * inch) + logos_table = Table( + [[prowler_logo, cis_logo]], colWidths=[4 * inch, 2.5 * inch] + ) + logos_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("VALIGN", (1, 0), (1, 0), "MIDDLE"), + ] + ) + ) + elements.append(logos_table) + elif os.path.exists(prowler_logo_path): + # Fallback: only the Prowler logo if the CIS asset is missing + elements.append(Image(prowler_logo_path, width=5 * inch, height=1 * inch)) + + elements.append(Spacer(1, 0.5 * inch)) + + # Dynamic title: "CIS Benchmark v5.0 — AWS Compliance Report" + provider_label = "" + if data.provider_obj: + provider_label = f" — {data.provider_obj.provider.upper()}" + title_text = ( + f"CIS Benchmark v{data.version}{provider_label}
Compliance Report" + ) + elements.append(Paragraph(title_text, self.styles["title"])) + elements.append(Spacer(1, 0.5 * inch)) + + # Metadata table via base class helper + info_rows = self._build_info_rows(data, language=self.config.language) + metadata_data = [] + for label, value in info_rows: + if label in ("Name:", "Description:") and value: + metadata_data.append( + [label, Paragraph(str(value), self.styles["normal_center"])] + ) + else: + metadata_data.append([label, value]) + + metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch]) + metadata_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (0, -1), COLOR_WHITE), + ("FONTNAME", (0, 0), (0, -1), "FiraCode"), + ("BACKGROUND", (1, 0), (1, -1), COLOR_BG_BLUE), + ("TEXTCOLOR", (1, 0), (1, -1), COLOR_GRAY), + ("FONTNAME", (1, 0), (1, -1), "PlusJakartaSans"), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONTSIZE", (0, 0), (-1, -1), 11), + ("GRID", (0, 0), (-1, -1), 1, COLOR_BORDER_GRAY), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) + elements.append(metadata_table) + + return elements + + # ------------------------------------------------------------------------- + # Executive Summary + # ------------------------------------------------------------------------- + + def create_executive_summary(self, data: ComplianceData) -> list: + """Create the CIS executive summary section.""" + elements = [] + + elements.append(Paragraph("Executive Summary", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + stats = self._compute_statistics(data) + + # --- Summary metrics table --- + summary_data = [ + ["Metric", "Value"], + ["Total Requirements", str(stats["total"])], + ["Passed", str(stats["passed"])], + ["Failed", str(stats["failed"])], + ["Manual", str(stats["manual"])], + ["Overall Compliance", f"{stats['overall_compliance']:.1f}%"], + ] + summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch]) + summary_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE), + ("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE), + ("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE), + ("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY), + ("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"), + ("FONTSIZE", (0, 0), (-1, 0), 12), + ("FONTSIZE", (0, 1), (-1, -1), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY), + ("BOTTOMPADDING", (0, 0), (-1, 0), 10), + ( + "ROWBACKGROUNDS", + (1, 1), + (1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(summary_table) + elements.append(Spacer(1, 0.25 * inch)) + + # --- Profile breakdown table --- + elements.append(Paragraph("Breakdown by Profile", self.styles["h2"])) + elements.append(Spacer(1, 0.1 * inch)) + profile_counts = stats["profile_counts"] + profile_table_data = [["Profile", "Passed", "Failed", "Manual", "Total"]] + for bucket in _PROFILE_BUCKET_ORDER: + counts = profile_counts.get(bucket, {"passed": 0, "failed": 0, "manual": 0}) + total = counts["passed"] + counts["failed"] + counts["manual"] + if total == 0: + continue + profile_table_data.append( + [ + _profile_badge_text(bucket), + str(counts["passed"]), + str(counts["failed"]), + str(counts["manual"]), + str(total), + ] + ) + profile_table = Table( + profile_table_data, + colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch], + ) + profile_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(profile_table) + elements.append(Spacer(1, 0.25 * inch)) + + # --- Assessment status breakdown --- + elements.append(Paragraph("Breakdown by Assessment Status", self.styles["h2"])) + elements.append(Spacer(1, 0.1 * inch)) + assessment_counts = stats["assessment_counts"] + assessment_table_data = [["Assessment", "Passed", "Failed", "Manual", "Total"]] + for bucket in _ASSESSMENT_BUCKET_ORDER: + counts = assessment_counts.get( + bucket, {"passed": 0, "failed": 0, "manual": 0} + ) + total = counts["passed"] + counts["failed"] + counts["manual"] + if total == 0: + continue + assessment_table_data.append( + [ + bucket, + str(counts["passed"]), + str(counts["failed"]), + str(counts["manual"]), + str(total), + ] + ) + assessment_table = Table( + assessment_table_data, + colWidths=[1.5 * inch, 1 * inch, 1 * inch, 1 * inch, 1 * inch], + ) + assessment_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_LIGHT_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(assessment_table) + elements.append(Spacer(1, 0.25 * inch)) + + # --- Top 5 failing sections --- + top_failing = stats["top_failing_sections"] + if top_failing: + elements.append( + Paragraph("Top Sections with Lowest Compliance", self.styles["h2"]) + ) + elements.append(Spacer(1, 0.1 * inch)) + top_table_data = [["Section", "Passed", "Failed", "Compliance"]] + for section_label, section_stats in top_failing: + passed = section_stats["passed"] + failed = section_stats["failed"] + total = passed + failed + pct = (passed / total * 100) if total > 0 else 100 + top_table_data.append( + [ + truncate_text(section_label, 55), + str(passed), + str(failed), + f"{pct:.1f}%", + ] + ) + top_table = Table( + top_table_data, + colWidths=[3.5 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch], + ) + top_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), COLOR_HIGH_RISK), + ("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "FiraCode"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTSIZE", (0, 1), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY), + ( + "ROWBACKGROUNDS", + (0, 1), + (-1, -1), + [COLOR_WHITE, COLOR_BG_BLUE], + ), + ] + ) + ) + elements.append(top_table) + + return elements + + # ------------------------------------------------------------------------- + # Charts section + # ------------------------------------------------------------------------- + + def create_charts_section(self, data: ComplianceData) -> list: + """Create the CIS charts section.""" + elements = [] + + elements.append(Paragraph("Compliance Analysis", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + # --- Pie chart: overall Pass / Fail / Manual --- + stats = self._compute_statistics(data) + pie_labels = [] + pie_values = [] + pie_colors = [] + if stats["passed"] > 0: + pie_labels.append(f"Pass ({stats['passed']})") + pie_values.append(stats["passed"]) + pie_colors.append(CHART_COLOR_GREEN_1) + if stats["failed"] > 0: + pie_labels.append(f"Fail ({stats['failed']})") + pie_values.append(stats["failed"]) + pie_colors.append(CHART_COLOR_RED) + if stats["manual"] > 0: + pie_labels.append(f"Manual ({stats['manual']})") + pie_values.append(stats["manual"]) + pie_colors.append(CHART_COLOR_YELLOW) + + if pie_values: + elements.append(Paragraph("Overall Status Distribution", self.styles["h2"])) + elements.append(Spacer(1, 0.1 * inch)) + pie_buffer = create_pie_chart( + labels=pie_labels, + values=pie_values, + colors=pie_colors, + ) + pie_buffer.seek(0) + elements.append(Image(pie_buffer, width=4.5 * inch, height=4.5 * inch)) + elements.append(Spacer(1, 0.2 * inch)) + + # --- Horizontal bar: pass rate by section --- + section_stats = stats["section_stats"] + if section_stats: + elements.append(PageBreak()) + elements.append(Paragraph("Compliance by Section", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + "The following chart shows compliance percentage for each CIS " + "section based on automated checks:", + self.styles["normal_center"], + ) + ) + elements.append(Spacer(1, 0.1 * inch)) + + # Sort sections by pass rate descending for readability + sorted_sections = sorted( + section_stats.items(), + key=lambda item: ( + (item[1]["passed"] / (item[1]["passed"] + item[1]["failed"]) * 100) + if (item[1]["passed"] + item[1]["failed"]) > 0 + else 100 + ), + reverse=True, + ) + bar_labels = [] + bar_values = [] + for section_label, section_data in sorted_sections: + total = section_data["passed"] + section_data["failed"] + if total == 0: + continue + pct = (section_data["passed"] / total) * 100 + bar_labels.append(truncate_text(section_label, 60)) + bar_values.append(pct) + + if bar_values: + bar_buffer = create_horizontal_bar_chart( + labels=bar_labels, + values=bar_values, + xlabel="Compliance (%)", + color_func=get_chart_color_for_percentage, + label_fontsize=9, + ) + bar_buffer.seek(0) + elements.append(Image(bar_buffer, width=6.5 * inch, height=5 * inch)) + + # --- Stacked bar: Level 1 vs Level 2 pass/fail --- + profile_counts = stats["profile_counts"] + has_profile_data = any( + (counts["passed"] + counts["failed"]) > 0 + for counts in profile_counts.values() + ) + if has_profile_data: + elements.append(PageBreak()) + elements.append(Paragraph("Profile Breakdown", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + elements.append( + Paragraph( + "Distribution of Pass / Fail / Manual across CIS profile levels.", + self.styles["normal_center"], + ) + ) + elements.append(Spacer(1, 0.1 * inch)) + + profile_labels = [] + pass_series = [] + fail_series = [] + manual_series = [] + for bucket in _PROFILE_BUCKET_ORDER: + counts = profile_counts.get(bucket) + if not counts: + continue + total = counts["passed"] + counts["failed"] + counts["manual"] + if total == 0: + continue + profile_labels.append(_profile_badge_text(bucket)) + pass_series.append(counts["passed"]) + fail_series.append(counts["failed"]) + manual_series.append(counts["manual"]) + + if profile_labels: + stacked_buffer = create_stacked_bar_chart( + labels=profile_labels, + data_series={ + "Pass": pass_series, + "Fail": fail_series, + "Manual": manual_series, + }, + xlabel="Profile", + ylabel="Requirements", + ) + stacked_buffer.seek(0) + elements.append(Image(stacked_buffer, width=6 * inch, height=4 * inch)) + + return elements + + # ------------------------------------------------------------------------- + # Requirements Index + # ------------------------------------------------------------------------- + + def create_requirements_index(self, data: ComplianceData) -> list: + """Create the CIS requirements index grouped by dynamic section.""" + elements = [] + + elements.append(Paragraph("Requirements Index", self.styles["h1"])) + elements.append(Spacer(1, 0.1 * inch)) + + sections = self._derive_sections(data) + by_section: dict[str, list[dict]] = defaultdict(list) + for req in data.requirements: + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + section = "Other" + profile_bucket = "Other" + assessment = "" + if meta: + section = getattr(meta, "Section", "Other") or "Other" + profile_bucket = _normalize_profile(getattr(meta, "Profile", None)) + assessment_enum = getattr(meta, "AssessmentStatus", None) + assessment = getattr(assessment_enum, "value", None) or str( + assessment_enum or "" + ) + by_section[section].append( + { + "id": req.id, + "description": truncate_text(req.description, 80), + "profile": _profile_badge_text(profile_bucket), + "assessment": assessment or "-", + "status": (req.status or "").upper(), + } + ) + + columns = [ + ColumnConfig("ID", 0.9 * inch, "id", align="LEFT"), + ColumnConfig("Description", 3.0 * inch, "description", align="LEFT"), + ColumnConfig("Profile", 0.9 * inch, "profile"), + ColumnConfig("Assessment", 1 * inch, "assessment"), + ColumnConfig("Status", 0.9 * inch, "status"), + ] + + for section in sections: + rows = by_section.get(section, []) + if not rows: + continue + elements.append(Paragraph(truncate_text(section, 90), self.styles["h2"])) + elements.append(Spacer(1, 0.05 * inch)) + table = create_data_table( + data=rows, + columns=columns, + header_color=self.config.primary_color, + normal_style=self.styles["normal_center"], + ) + elements.append(table) + elements.append(Spacer(1, 0.15 * inch)) + + return elements + + # ------------------------------------------------------------------------- + # Detailed findings hook — inject CIS-specific rationale / audit content + # ------------------------------------------------------------------------- + + def _render_requirement_detail_extras( + self, req: RequirementData, data: ComplianceData + ) -> list: + """Render CIS rationale, impact, audit, remediation and references.""" + extras = [] + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if meta is None: + return extras + + field_map = [ + ("Rationale", "RationaleStatement"), + ("Impact", "ImpactStatement"), + ("Audit Procedure", "AuditProcedure"), + ("Remediation", "RemediationProcedure"), + ("References", "References"), + ] + + for label, attr_name in field_map: + value = getattr(meta, attr_name, None) + if not value: + continue + text = str(value).strip() + if not text: + continue + extras.append(Paragraph(f"{label}:", self.styles["h3"])) + extras.append(Paragraph(escape_html(text), self.styles["normal"])) + extras.append(Spacer(1, 0.08 * inch)) + + return extras + + # ------------------------------------------------------------------------- + # Private helpers + # ------------------------------------------------------------------------- + + def _derive_sections(self, data: ComplianceData) -> list[str]: + """Extract ordered unique Section names from loaded compliance data.""" + seen: dict[str, bool] = {} + for req in data.requirements: + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if meta is None: + continue + section = getattr(meta, "Section", None) or "Other" + if section not in seen: + seen[section] = True + return list(seen.keys()) + + def _compute_statistics(self, data: ComplianceData) -> dict: + """Aggregate all statistics needed for summary and charts. + + Memoized per-``ComplianceData`` instance via ``_stats_cache_*``: the + executive summary and the charts section both need the same numbers, + so they would otherwise re-iterate the requirements twice. We key on + ``id(data)`` because ``ComplianceData`` is a dataclass and its + instances are not hashable. + + Returns a dict with: + - total, passed, failed, manual: int + - overall_compliance: float (percentage) + - profile_counts: {"L1": {"passed", "failed", "manual"}, ...} + - assessment_counts: {"Automated": {...}, "Manual": {...}} + - section_stats: {section_name: {"passed", "failed", "manual"}, ...} + - top_failing_sections: list[(section_name, stats)] (up to 5) + """ + cache_key = id(data) + if self._stats_cache_key == cache_key and self._stats_cache_value is not None: + return self._stats_cache_value + stats = self._compute_statistics_uncached(data) + self._stats_cache_key = cache_key + self._stats_cache_value = stats + return stats + + def _compute_statistics_uncached(self, data: ComplianceData) -> dict: + """Actual aggregation kernel; call ``_compute_statistics`` instead.""" + total = len(data.requirements) + passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS) + failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL) + manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL) + + evaluated = passed + failed + overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100.0 + + profile_counts: dict[str, dict[str, int]] = { + "L1": {"passed": 0, "failed": 0, "manual": 0}, + "L2": {"passed": 0, "failed": 0, "manual": 0}, + "Other": {"passed": 0, "failed": 0, "manual": 0}, + } + assessment_counts: dict[str, dict[str, int]] = { + "Automated": {"passed": 0, "failed": 0, "manual": 0}, + "Manual": {"passed": 0, "failed": 0, "manual": 0}, + } + section_stats: dict[str, dict[str, int]] = defaultdict( + lambda: {"passed": 0, "failed": 0, "manual": 0} + ) + + for req in data.requirements: + meta = get_requirement_metadata(req.id, data.attributes_by_requirement_id) + if meta is None: + continue + + profile_bucket = _normalize_profile(getattr(meta, "Profile", None)) + assessment_enum = getattr(meta, "AssessmentStatus", None) + assessment_value = getattr(assessment_enum, "value", None) or str( + assessment_enum or "" + ) + assessment_bucket = ( + "Automated" if assessment_value == "Automated" else "Manual" + ) + section = getattr(meta, "Section", None) or "Other" + + status_key = { + StatusChoices.PASS: "passed", + StatusChoices.FAIL: "failed", + StatusChoices.MANUAL: "manual", + }.get(req.status) + if status_key is None: + continue + + profile_counts[profile_bucket][status_key] += 1 + assessment_counts[assessment_bucket][status_key] += 1 + section_stats[section][status_key] += 1 + + # Top 5 sections with lowest pass rate (only sections with evaluated reqs) + def _section_rate(item): + _, stats_ = item + evaluated_ = stats_["passed"] + stats_["failed"] + if evaluated_ == 0: + return 101 # sort evaluated=0 to the bottom + return stats_["passed"] / evaluated_ * 100 + + top_failing_sections = sorted( + ( + item + for item in section_stats.items() + if (item[1]["passed"] + item[1]["failed"]) > 0 + ), + key=_section_rate, + )[:5] + + return { + "total": total, + "passed": passed, + "failed": failed, + "manual": manual, + "overall_compliance": overall_compliance, + "profile_counts": profile_counts, + "assessment_counts": assessment_counts, + "section_stats": dict(section_stats), + "top_failing_sections": top_failing_sections, + } diff --git a/api/src/backend/tasks/jobs/reports/components.py b/api/src/backend/tasks/jobs/reports/components.py index 323c4547e64..049cc043d3c 100644 --- a/api/src/backend/tasks/jobs/reports/components.py +++ b/api/src/backend/tasks/jobs/reports/components.py @@ -26,6 +26,52 @@ ) +def truncate_text(text: str, max_len: int) -> str: + """Truncate ``text`` to ``max_len`` characters, appending an ellipsis if cut. + + Used by report generators that need to squeeze long descriptions, section + titles or finding titles into a fixed-width table cell. + + Args: + text: Source string. ``None`` and non-string values are treated as empty. + max_len: Maximum output length including the ellipsis. Values < 4 are + clamped so the result never grows beyond ``max_len``. + + Returns: + The original string if short enough, otherwise ``text[: max_len - 3] + "..."``. + When ``max_len < 4`` a plain substring of length ``max_len`` is returned + so callers never get a string longer than they asked for. + """ + if not text: + return "" + text = str(text) + if len(text) <= max_len: + return text + if max_len < 4: + return text[:max_len] + return text[: max_len - 3] + "..." + + +def escape_html(text: str) -> str: + """Escape the minimal HTML entities required for safe ReportLab Paragraph rendering. + + ReportLab's ``Paragraph`` parses a small HTML subset, so raw ``<``, ``>`` + and ``&`` in user-provided content (rationale, remediation, etc.) would + break layout or be interpreted as tags. This helper mirrors + ``html.escape`` but avoids pulling in the stdlib dependency and keeps the + output deterministic. + + Args: + text: Untrusted source string. + + Returns: + A string safe to embed inside a ReportLab Paragraph. + """ + return ( + str(text or "").replace("&", "&").replace("<", "<").replace(">", ">") + ) + + def get_color_for_risk_level(risk_level: int) -> colors.Color: """ Get color based on risk level. diff --git a/api/src/backend/tasks/jobs/reports/config.py b/api/src/backend/tasks/jobs/reports/config.py index fe0326980d4..669f31c2871 100644 --- a/api/src/backend/tasks/jobs/reports/config.py +++ b/api/src/backend/tasks/jobs/reports/config.py @@ -313,6 +313,32 @@ class FrameworkConfig: has_niveles=False, has_weight=False, ), + "cis": FrameworkConfig( + name="cis", + display_name="CIS Benchmark", + logo_filename=None, + primary_color=COLOR_BLUE, + secondary_color=COLOR_LIGHT_BLUE, + bg_color=COLOR_BG_BLUE, + attribute_fields=[ + "Section", + "SubSection", + "Profile", + "AssessmentStatus", + "Description", + "RationaleStatement", + "ImpactStatement", + "RemediationProcedure", + "AuditProcedure", + "References", + ], + sections=None, # Derived dynamically per CIS variant (section names differ across versions/providers) + language="en", + has_risk_levels=False, + has_dimensions=False, + has_niveles=False, + has_weight=False, + ), } @@ -336,5 +362,7 @@ def get_framework_config(compliance_id: str) -> FrameworkConfig | None: return FRAMEWORK_REGISTRY["nis2"] if "csa" in compliance_lower or "ccm" in compliance_lower: return FRAMEWORK_REGISTRY["csa_ccm"] + if compliance_lower.startswith("cis_") or "cis" in compliance_lower: + return FRAMEWORK_REGISTRY["cis"] return None diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index b4a8f91668f..e22eb2d14de 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -1000,13 +1000,17 @@ def jira_integration_task( @handle_provider_deletion def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str): """ - Optimized task to generate ThreatScore, ENS, NIS2, and CSA CCM reports with shared queries. + Optimized task to generate ThreatScore, ENS, NIS2, CSA CCM and CIS reports with shared queries. This task is more efficient than running separate report tasks because it reuses database queries: - Provider object fetched once (instead of multiple times) - Requirement statistics aggregated once (instead of multiple times) - Can reduce database load by up to 50-70% + CIS emits a single PDF per run: the one matching the highest CIS version + available for the scan's provider, picked dynamically from + ``Compliance.get_bulk`` (no hard-coded provider → version mapping). + Args: tenant_id (str): The tenant identifier. scan_id (str): The scan identifier. @@ -1023,6 +1027,7 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: generate_ens=True, generate_nis2=True, generate_csa=True, + generate_cis=True, ) diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py index 858f4c06ca0..a25df876f3b 100644 --- a/api/src/backend/tasks/tests/test_reports.py +++ b/api/src/backend/tasks/tests/test_reports.py @@ -4,7 +4,11 @@ import matplotlib import pytest from reportlab.lib import colors -from tasks.jobs.report import generate_compliance_reports, generate_threatscore_report +from tasks.jobs.report import ( + _pick_latest_cis_variant, + generate_compliance_reports, + generate_threatscore_report, +) from tasks.jobs.reports import ( CHART_COLOR_GREEN_1, CHART_COLOR_GREEN_2, @@ -422,6 +426,266 @@ def test_no_findings_returns_early_for_both_reports( mock_ens.assert_not_called() mock_nis2.assert_not_called() + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + def test_no_findings_returns_flat_cis_entry( + self, + mock_cis, + mock_upload, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Scan with no findings and ``generate_cis=True`` must yield a flat + ``{"upload": False, "path": ""}`` entry, consistent with the other + frameworks (no nested dict, no sentinel keys).""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + result = generate_compliance_reports( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + provider_id=str(provider.id), + generate_threatscore=False, + generate_ens=False, + generate_nis2=False, + generate_csa=False, + generate_cis=True, + ) + + assert result["cis"] == {"upload": False, "path": ""} + mock_cis.assert_not_called() + + +@pytest.mark.django_db +class TestGenerateComplianceReportsCIS: + """Test suite covering the CIS branch of generate_compliance_reports.""" + + def _force_scan_has_findings(self, monkeypatch): + """Bypass the ScanSummary.exists() early-return guard.""" + + class _FakeManager: + def filter(self, **kwargs): + class _Q: + def exists(self): + return True + + return _Q() + + monkeypatch.setattr("tasks.jobs.report.ScanSummary.objects", _FakeManager()) + + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.Compliance.get_bulk") + def test_cis_picks_latest_version( + self, + mock_get_bulk, + mock_cis, + mock_upload, + mock_stats, + monkeypatch, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """CIS branch should generate a single PDF for the highest version. + + The returned ``results["cis"]`` must have the same flat shape as the + other single-version frameworks (``{"upload", "path"}``) — the picked + variant is an internal detail and is not exposed in the result. + """ + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + self._force_scan_has_findings(monkeypatch) + + mock_stats.return_value = {} + # Multiple CIS variants + a non-CIS framework that must be ignored. + # Includes 1.10 to verify the selection is not lexicographic. + mock_get_bulk.return_value = { + "cis_1.4_aws": Mock(), + "cis_1.10_aws": Mock(), + "cis_2.0_aws": Mock(), + "cis_5.0_aws": Mock(), + "ens_rd2022_aws": Mock(), + } + mock_upload.return_value = "s3://bucket/path" + + result = generate_compliance_reports( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + provider_id=str(provider.id), + generate_threatscore=False, + generate_ens=False, + generate_nis2=False, + generate_csa=False, + generate_cis=True, + ) + + # Exactly one call for the latest version, never for older variants + # or non-CIS frameworks. + assert mock_cis.call_count == 1 + assert mock_cis.call_args.kwargs["compliance_id"] == "cis_5.0_aws" + + assert result["cis"]["upload"] is True + assert result["cis"]["path"] == "s3://bucket/path" + assert "compliance_id" not in result["cis"] + + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.Compliance.get_bulk") + def test_cis_latest_variant_failure_captured_in_results( + self, + mock_get_bulk, + mock_cis, + mock_upload, + mock_stats, + monkeypatch, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """A failure in the latest CIS variant must be surfaced in the flat results entry.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + self._force_scan_has_findings(monkeypatch) + + mock_stats.return_value = {} + mock_get_bulk.return_value = { + "cis_1.4_aws": Mock(), + "cis_5.0_aws": Mock(), + } + mock_cis.side_effect = RuntimeError("boom") + + result = generate_compliance_reports( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + provider_id=str(provider.id), + generate_threatscore=False, + generate_ens=False, + generate_nis2=False, + generate_csa=False, + generate_cis=True, + ) + + # Only the latest variant is attempted; its failure lands in a flat + # entry keyed under "cis" with the same shape as sibling frameworks. + assert mock_cis.call_count == 1 + assert result["cis"]["upload"] is False + assert result["cis"]["error"] == "boom" + assert "compliance_id" not in result["cis"] + + @patch("tasks.jobs.report._aggregate_requirement_statistics_from_database") + @patch("tasks.jobs.report._upload_to_s3") + @patch("tasks.jobs.report.generate_cis_report") + @patch("tasks.jobs.report.Compliance.get_bulk") + def test_cis_provider_without_cis_skipped_cleanly( + self, + mock_get_bulk, + mock_cis, + mock_upload, + mock_stats, + monkeypatch, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """When ``Compliance.get_bulk`` returns no CIS entry the CIS branch + must skip cleanly and record a flat ``{"upload": False, "path": ""}`` + entry — no hard-coded provider whitelist is consulted.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + self._force_scan_has_findings(monkeypatch) + mock_stats.return_value = {} + # No ``cis_*`` keys in the bulk → no variant picked. + mock_get_bulk.return_value = {"ens_rd2022_aws": Mock()} + + result = generate_compliance_reports( + tenant_id=str(tenant.id), + scan_id=str(scan.id), + provider_id=str(provider.id), + generate_threatscore=False, + generate_ens=False, + generate_nis2=False, + generate_csa=False, + generate_cis=True, + ) + + assert result["cis"] == {"upload": False, "path": ""} + mock_cis.assert_not_called() + + +class TestPickLatestCisVariant: + """Unit tests for `_pick_latest_cis_variant` helper.""" + + def test_empty_returns_none(self): + assert _pick_latest_cis_variant([]) is None + + def test_single_variant(self): + assert _pick_latest_cis_variant(["cis_5.0_aws"]) == "cis_5.0_aws" + + def test_numeric_not_lexicographic(self): + """1.10 must beat 1.2 (lex sort would pick 1.2).""" + variants = ["cis_1.2_kubernetes", "cis_1.10_kubernetes"] + assert _pick_latest_cis_variant(variants) == "cis_1.10_kubernetes" + + def test_major_version_wins(self): + variants = ["cis_1.4_aws", "cis_2.0_aws", "cis_5.0_aws", "cis_6.0_aws"] + assert _pick_latest_cis_variant(variants) == "cis_6.0_aws" + + def test_minor_version_breaks_tie(self): + variants = ["cis_3.0_aws", "cis_3.1_aws", "cis_2.9_aws"] + assert _pick_latest_cis_variant(variants) == "cis_3.1_aws" + + def test_three_part_version(self): + """Versions like 3.0.1 must win over 3.0.""" + variants = ["cis_3.0_aws", "cis_3.0.1_aws"] + assert _pick_latest_cis_variant(variants) == "cis_3.0.1_aws" + + def test_malformed_names_ignored(self): + variants = ["notcis_1.0_aws", "cis_abc_aws", "cis_5.0_aws"] + assert _pick_latest_cis_variant(variants) == "cis_5.0_aws" + + def test_only_malformed_returns_none(self): + variants = ["notcis_1.0_aws", "cis_abc_aws"] + assert _pick_latest_cis_variant(variants) is None + + def test_multidigit_provider_name(self): + """Provider name with underscores (e.g. googleworkspace) must parse.""" + variants = ["cis_1.3_googleworkspace"] + assert _pick_latest_cis_variant(variants) == "cis_1.3_googleworkspace" + + def test_accepts_iterator(self): + """The helper must accept any iterable, not just lists.""" + + def _gen(): + yield "cis_1.4_aws" + yield "cis_5.0_aws" + + assert _pick_latest_cis_variant(_gen()) == "cis_5.0_aws" + + def test_rejects_single_integer_version(self): + """The regex requires at least one dotted component. ``cis_5_aws`` + without a minor version is malformed per the backend contract.""" + assert _pick_latest_cis_variant(["cis_5_aws"]) is None + + def test_rejects_trailing_dot(self): + """Inputs like ``cis_5._aws`` must be rejected at the regex stage + instead of silently normalising to ``(5, 0)``.""" + assert _pick_latest_cis_variant(["cis_5._aws", "cis_1.0_aws"]) == "cis_1.0_aws" + + def test_rejects_lone_dot_version(self): + """``cis_._aws`` has no numeric component and must be skipped.""" + assert _pick_latest_cis_variant(["cis_._aws", "cis_1.0_aws"]) == "cis_1.0_aws" + class TestOptimizationImprovements: """Test suite for optimization-related functionality.""" diff --git a/api/src/backend/tasks/tests/test_reports_cis.py b/api/src/backend/tasks/tests/test_reports_cis.py new file mode 100644 index 00000000000..2d4528c82d0 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_cis.py @@ -0,0 +1,532 @@ +from unittest.mock import Mock, patch + +import pytest +from reportlab.platypus import Image, LongTable, Paragraph, Table +from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData +from tasks.jobs.reports.cis import ( + CISReportGenerator, + _normalize_profile, + _profile_badge_text, +) + +from api.models import StatusChoices + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def cis_generator(): + """Create a CISReportGenerator instance for testing.""" + config = FRAMEWORK_REGISTRY["cis"] + return CISReportGenerator(config) + + +def _make_attr( + section: str, + profile_value: str = "Level 1", + assessment_value: str = "Automated", + sub_section: str = "", + **extras, +) -> Mock: + """Build a mock CIS_Requirement_Attribute with duck-typed fields.""" + attr = Mock() + attr.Section = section + attr.SubSection = sub_section + # CIS enums have `.value`. Use a simple Mock that exposes `.value`. + attr.Profile = Mock(value=profile_value) + attr.AssessmentStatus = Mock(value=assessment_value) + attr.Description = extras.get("description", "desc") + attr.RationaleStatement = extras.get("rationale", "the rationale") + attr.ImpactStatement = extras.get("impact", "the impact") + attr.RemediationProcedure = extras.get("remediation", "the remediation") + attr.AuditProcedure = extras.get("audit", "the audit") + attr.AdditionalInformation = "" + attr.DefaultValue = "" + attr.References = extras.get("references", "https://example.com") + return attr + + +@pytest.fixture +def basic_cis_compliance_data(): + """Create basic ComplianceData for CIS testing (no requirements).""" + return ComplianceData( + tenant_id="tenant-123", + scan_id="scan-456", + provider_id="provider-789", + compliance_id="cis_5.0_aws", + framework="CIS", + name="CIS Amazon Web Services Foundations Benchmark v5.0.0", + version="5.0", + description="Center for Internet Security AWS Foundations Benchmark", + ) + + +@pytest.fixture +def populated_cis_compliance_data(basic_cis_compliance_data): + """CIS data with mixed requirements across 2 sections, Profile L1/L2, Pass/Fail/Manual.""" + data = basic_cis_compliance_data + data.requirements = [ + RequirementData( + id="1.1", + description="Maintain current contact details", + status=StatusChoices.PASS, + passed_findings=5, + failed_findings=0, + total_findings=5, + checks=["aws_check_1"], + ), + RequirementData( + id="1.2", + description="Ensure root account has no access keys", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=3, + total_findings=3, + checks=["aws_check_2"], + ), + RequirementData( + id="1.3", + description="Ensure MFA is enabled for all IAM users", + status=StatusChoices.MANUAL, + checks=[], + ), + RequirementData( + id="2.1", + description="Ensure S3 Buckets are logging", + status=StatusChoices.PASS, + passed_findings=2, + failed_findings=0, + total_findings=2, + checks=["aws_check_3"], + ), + RequirementData( + id="2.2", + description="Ensure encryption at rest is enabled", + status=StatusChoices.FAIL, + passed_findings=0, + failed_findings=4, + total_findings=4, + checks=["aws_check_4"], + ), + ] + data.attributes_by_requirement_id = { + "1.1": { + "attributes": { + "req_attributes": [ + _make_attr( + "1 Identity and Access Management", + profile_value="Level 1", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_1"], + } + }, + "1.2": { + "attributes": { + "req_attributes": [ + _make_attr( + "1 Identity and Access Management", + profile_value="Level 1", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_2"], + } + }, + "1.3": { + "attributes": { + "req_attributes": [ + _make_attr( + "1 Identity and Access Management", + profile_value="Level 2", + assessment_value="Manual", + ) + ], + "checks": [], + } + }, + "2.1": { + "attributes": { + "req_attributes": [ + _make_attr( + "2 Storage", + profile_value="Level 2", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_3"], + } + }, + "2.2": { + "attributes": { + "req_attributes": [ + _make_attr( + "2 Storage", + profile_value="Level 1", + assessment_value="Automated", + ) + ], + "checks": ["aws_check_4"], + } + }, + } + return data + + +# ============================================================================= +# Helper function tests +# ============================================================================= + + +class TestNormalizeProfile: + """Test suite for _normalize_profile helper.""" + + def test_level_1_string(self): + assert _normalize_profile(Mock(value="Level 1")) == "L1" + + def test_level_2_string(self): + assert _normalize_profile(Mock(value="Level 2")) == "L2" + + def test_e3_level_1(self): + assert _normalize_profile(Mock(value="E3 Level 1")) == "L1" + + def test_e5_level_2(self): + assert _normalize_profile(Mock(value="E5 Level 2")) == "L2" + + def test_none_returns_other(self): + assert _normalize_profile(None) == "Other" + + def test_substring_trap_rejected(self): + """Unrelated tokens containing the literal ``L2`` must NOT map to L2.""" + # A future enum value like "CL2 Kubernetes Worker" would be silently + # misclassified by a naive substring check. + assert _normalize_profile(Mock(value="CL2 Worker")) == "Other" + assert _normalize_profile(Mock(value="HL2 Legacy")) == "Other" + + def test_raw_string_level_1(self): + # Mock without .value falls back to str(profile); use a real string + class NoValue: + def __str__(self): + return "Level 1" + + assert _normalize_profile(NoValue()) == "L1" + + def test_unknown_profile_returns_other(self): + assert _normalize_profile(Mock(value="Custom Profile")) == "Other" + + +class TestProfileBadgeText: + def test_l1_label(self): + assert _profile_badge_text("L1") == "Level 1" + + def test_l2_label(self): + assert _profile_badge_text("L2") == "Level 2" + + def test_other_label(self): + assert _profile_badge_text("Other") == "Other" + + +# ============================================================================= +# Generator initialization +# ============================================================================= + + +class TestCISGeneratorInitialization: + def test_generator_created(self, cis_generator): + assert cis_generator is not None + assert cis_generator.config.name == "cis" + + def test_generator_language(self, cis_generator): + assert cis_generator.config.language == "en" + + def test_generator_sections_dynamic(self, cis_generator): + # CIS sections differ per variant so config.sections MUST be None + assert cis_generator.config.sections is None + + def test_attribute_fields_contain_cis_specific(self, cis_generator): + for field in ("Profile", "AssessmentStatus", "RationaleStatement"): + assert field in cis_generator.config.attribute_fields + + +# ============================================================================= +# _derive_sections +# ============================================================================= + + +class TestDeriveSections: + def test_preserves_first_seen_order( + self, cis_generator, populated_cis_compliance_data + ): + sections = cis_generator._derive_sections(populated_cis_compliance_data) + assert sections == [ + "1 Identity and Access Management", + "2 Storage", + ] + + def test_deduplicates_sections(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [ + RequirementData(id="1.1", description="a", status=StatusChoices.PASS), + RequirementData(id="1.2", description="b", status=StatusChoices.PASS), + ] + attr = _make_attr("1 IAM") + basic_cis_compliance_data.attributes_by_requirement_id = { + "1.1": {"attributes": {"req_attributes": [attr], "checks": []}}, + "1.2": {"attributes": {"req_attributes": [attr], "checks": []}}, + } + assert cis_generator._derive_sections(basic_cis_compliance_data) == ["1 IAM"] + + def test_empty_data_returns_empty(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [] + basic_cis_compliance_data.attributes_by_requirement_id = {} + assert cis_generator._derive_sections(basic_cis_compliance_data) == [] + + +# ============================================================================= +# _compute_statistics +# ============================================================================= + + +class TestComputeStatistics: + def test_totals(self, cis_generator, populated_cis_compliance_data): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + assert stats["total"] == 5 + assert stats["passed"] == 2 + assert stats["failed"] == 2 + assert stats["manual"] == 1 + + def test_overall_compliance_excludes_manual( + self, cis_generator, populated_cis_compliance_data + ): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + # 2 passed / 4 evaluated (pass + fail) = 50% + assert stats["overall_compliance"] == pytest.approx(50.0) + + def test_overall_compliance_all_manual( + self, cis_generator, basic_cis_compliance_data + ): + basic_cis_compliance_data.requirements = [ + RequirementData(id="x", description="d", status=StatusChoices.MANUAL), + ] + attr = _make_attr("1 IAM", profile_value="Level 1", assessment_value="Manual") + basic_cis_compliance_data.attributes_by_requirement_id = { + "x": {"attributes": {"req_attributes": [attr], "checks": []}}, + } + stats = cis_generator._compute_statistics(basic_cis_compliance_data) + # No evaluated → defaults to 100% + assert stats["overall_compliance"] == 100.0 + + def test_profile_counts(self, cis_generator, populated_cis_compliance_data): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + profile = stats["profile_counts"] + # From fixture: + # L1: 1.1 (PASS, Auto), 1.2 (FAIL, Auto), 2.2 (FAIL, Auto) → pass=1, fail=2, manual=0 + # L2: 1.3 (MANUAL, Manual), 2.1 (PASS, Auto) → pass=1, fail=0, manual=1 + assert profile["L1"] == {"passed": 1, "failed": 2, "manual": 0} + assert profile["L2"] == {"passed": 1, "failed": 0, "manual": 1} + + def test_assessment_counts(self, cis_generator, populated_cis_compliance_data): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + assessment = stats["assessment_counts"] + # Automated: 1.1 PASS, 1.2 FAIL, 2.1 PASS, 2.2 FAIL → pass=2, fail=2, manual=0 + # Manual: 1.3 MANUAL → pass=0, fail=0, manual=1 + assert assessment["Automated"] == {"passed": 2, "failed": 2, "manual": 0} + assert assessment["Manual"] == {"passed": 0, "failed": 0, "manual": 1} + + def test_top_failing_sections_includes_all_evaluated( + self, cis_generator, populated_cis_compliance_data + ): + stats = cis_generator._compute_statistics(populated_cis_compliance_data) + top = stats["top_failing_sections"] + # Both sections have 1 PASS + 1 FAIL evaluated → tied at 50%. The + # sort is stable, so both must appear and both must be capped at + # 5 entries. + assert len(top) == 2 + section_names = {name for name, _ in top} + assert section_names == { + "1 Identity and Access Management", + "2 Storage", + } + + def test_compute_statistics_is_memoized( + self, cis_generator, populated_cis_compliance_data + ): + """Calling ``_compute_statistics`` twice with the same data must + reuse the cached value and not re-run the uncached kernel.""" + with patch.object( + CISReportGenerator, + "_compute_statistics_uncached", + wraps=cis_generator._compute_statistics_uncached, + ) as spy: + cis_generator._compute_statistics(populated_cis_compliance_data) + cis_generator._compute_statistics(populated_cis_compliance_data) + assert spy.call_count == 1 + + +# ============================================================================= +# Executive summary +# ============================================================================= + + +class TestCISExecutiveSummary: + def test_title_present(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_executive_summary(populated_cis_compliance_data) + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + text = " ".join(str(p.text) for p in paragraphs) + assert "Executive Summary" in text + + def test_tables_rendered(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_executive_summary(populated_cis_compliance_data) + tables = [e for e in elements if isinstance(e, Table)] + # Exact count: Summary, Profile, Assessment, Top Failing Sections = 4. + assert len(tables) == 4 + + def test_no_requirements(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [] + basic_cis_compliance_data.attributes_by_requirement_id = {} + elements = cis_generator.create_executive_summary(basic_cis_compliance_data) + # With no requirements: Summary table always renders, and both Profile + # and Assessment breakdown tables render with a 0-filled default row, + # but Top Failing Sections is suppressed → exactly 3 tables. + tables = [e for e in elements if isinstance(e, Table)] + assert len(tables) == 3 + + +# ============================================================================= +# Charts section +# ============================================================================= + + +class TestCISChartsSection: + def test_charts_rendered(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_charts_section(populated_cis_compliance_data) + # At least 1 image for the pie + 1 for section bar + 1 for stacked + images = [e for e in elements if isinstance(e, Image)] + assert len(images) >= 1 + + def test_charts_no_data_no_crash(self, cis_generator, basic_cis_compliance_data): + basic_cis_compliance_data.requirements = [] + basic_cis_compliance_data.attributes_by_requirement_id = {} + elements = cis_generator.create_charts_section(basic_cis_compliance_data) + # Must not raise; may or may not have any Image + assert isinstance(elements, list) + + +# ============================================================================= +# Requirements index +# ============================================================================= + + +class TestCISRequirementsIndex: + def test_title_present(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_requirements_index( + populated_cis_compliance_data + ) + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + text = " ".join(str(p.text) for p in paragraphs) + assert "Requirements Index" in text + + def test_groups_by_section(self, cis_generator, populated_cis_compliance_data): + elements = cis_generator.create_requirements_index( + populated_cis_compliance_data + ) + paragraphs = [e for e in elements if isinstance(e, Paragraph)] + text = " ".join(str(p.text) for p in paragraphs) + assert "1 Identity and Access Management" in text + assert "2 Storage" in text + + def test_renders_tables_per_section( + self, cis_generator, populated_cis_compliance_data + ): + elements = cis_generator.create_requirements_index( + populated_cis_compliance_data + ) + # One table per section with requirements. ``create_data_table`` + # returns a LongTable when the row count exceeds its threshold and a + # plain Table otherwise — both are valid. + tables = [e for e in elements if isinstance(e, (Table, LongTable))] + assert len(tables) == 2 + + +# ============================================================================= +# Detailed findings extras hook +# ============================================================================= + + +class TestRenderRequirementDetailExtras: + def test_inserts_all_fields(self, cis_generator, populated_cis_compliance_data): + req = populated_cis_compliance_data.requirements[1] # 1.2 FAIL + extras = cis_generator._render_requirement_detail_extras( + req, populated_cis_compliance_data + ) + text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph)) + assert "Rationale" in text + assert "Impact" in text + assert "Audit Procedure" in text + assert "Remediation" in text + assert "References" in text + + def test_missing_metadata_returns_empty( + self, cis_generator, basic_cis_compliance_data + ): + basic_cis_compliance_data.attributes_by_requirement_id = {} + req = RequirementData(id="99", description="unknown", status=StatusChoices.FAIL) + extras = cis_generator._render_requirement_detail_extras( + req, basic_cis_compliance_data + ) + assert extras == [] + + def test_escapes_html_chars(self, cis_generator, basic_cis_compliance_data): + attr = _make_attr( + "1 IAM", + rationale="", + ) + basic_cis_compliance_data.attributes_by_requirement_id = { + "1.1": {"attributes": {"req_attributes": [attr], "checks": []}} + } + req = RequirementData(id="1.1", description="d", status=StatusChoices.FAIL) + extras = cis_generator._render_requirement_detail_extras( + req, basic_cis_compliance_data + ) + text = " ".join(str(p.text) for p in extras if isinstance(p, Paragraph)) + assert "