refactor(attack-paths): complete migration to private graph labels and properties (phase 2) (#10268)

This commit is contained in:
Josema Camacho
2026-03-16 12:34:58 +01:00
committed by GitHub
parent 361f8548bf
commit ad02801c74
12 changed files with 66 additions and 97 deletions

View File

@@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🔄 Changed
- Attack Paths: Migrate network exposure queries from APOC to standard openCypher for Neo4j and Neptune compatibility [(#10266)](https://github.com/prowler-cloud/prowler/pull/10266)
- Attack Paths: Complete migration to private graph labels and properties, removing deprecated dual-write support [(#10268)](https://github.com/prowler-cloud/prowler/pull/10268)
- `POST /api/v1/providers` returns `409 Conflict` if already exists [(#10293)](https://github.com/prowler-cloud/prowler/pull/10293)
### 🐞 Fixed

View File

@@ -17,7 +17,8 @@ from api.attack_paths.retryable_session import RetryableSession
from config.env import env
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
DEPRECATED_PROVIDER_RESOURCE_LABEL,
PROVIDER_ID_PROPERTY,
PROVIDER_RESOURCE_LABEL,
)
# Without this Celery goes crazy with Neo4j logging
@@ -178,7 +179,7 @@ def drop_subgraph(database: str, provider_id: str) -> int:
while deleted_count > 0:
result = session.run(
f"""
MATCH (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}})
MATCH (n:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ID_PROPERTY}: $provider_id}})
WITH n LIMIT $batch_size
DETACH DELETE n
RETURN COUNT(n) AS deleted_nodes_count

View File

@@ -3,7 +3,7 @@ from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
)
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
from tasks.jobs.attack_paths.config import PROVIDER_ID_PROPERTY, PROWLER_FINDING_LABEL
# Custom Attack Path Queries
@@ -16,7 +16,7 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
description="Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read tagged sensitive S3 buckets despite bucket-level public access blocks.",
provider="aws",
cypher=f"""
OPTIONAL MATCH (internet:Internet {{_provider_id: $provider_id}})
OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}})
MATCH path_s3 = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)--(t:AWSTag)
WHERE toLower(t.key) = toLower($tag_key) AND toLower(t.value) = toLower($tag_value)
@@ -179,7 +179,7 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition(
description="Find EC2 instances flagged as exposed to the internet within the selected account.",
provider="aws",
cypher=f"""
OPTIONAL MATCH (internet:Internet {{_provider_id: $provider_id}})
OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}})
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)
WHERE ec2.exposed_internet = true
@@ -201,7 +201,7 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition(
description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.",
provider="aws",
cypher=f"""
OPTIONAL MATCH (internet:Internet {{_provider_id: $provider_id}})
OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}})
// Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0)
MATCH path_ec2 = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange)
@@ -225,7 +225,7 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition(
description="Find Classic Load Balancers exposed to the internet along with their listeners.",
provider="aws",
cypher=f"""
OPTIONAL MATCH (internet:Internet {{_provider_id: $provider_id}})
OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}})
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener)
WHERE elb.exposed_internet = true
@@ -247,7 +247,7 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition(
description="Find ELBv2 load balancers exposed to the internet along with their listeners.",
provider="aws",
cypher=f"""
OPTIONAL MATCH (internet:Internet {{_provider_id: $provider_id}})
OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}})
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener)
WHERE elbv2.exposed_internet = true
@@ -269,7 +269,7 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition(
description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.",
provider="aws",
cypher=f"""
OPTIONAL MATCH (internet:Internet {{_provider_id: $provider_id}})
OPTIONAL MATCH (internet:Internet {{{PROVIDER_ID_PROPERTY}: $provider_id}})
MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x)-[q]-(y)
WHERE (x:EC2PrivateIp AND x.public_ip = $ip)

View File

@@ -1,7 +1,7 @@
from tasks.jobs.attack_paths.config import DEPRECATED_PROVIDER_RESOURCE_LABEL
from tasks.jobs.attack_paths.config import PROVIDER_ID_PROPERTY, PROVIDER_RESOURCE_LABEL
CARTOGRAPHY_SCHEMA_METADATA = f"""
MATCH (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}})
MATCH (n:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ID_PROPERTY}: $provider_id}})
WHERE n._module_name STARTS WITH 'cartography:'
AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler']
AND n._module_version IS NOT NULL

View File

@@ -13,7 +13,11 @@ from api.attack_paths.queries.schema import (
RAW_SCHEMA_URL,
)
from config.custom_logging import BackendLogger
from tasks.jobs.attack_paths.config import INTERNAL_LABELS, INTERNAL_PROPERTIES
from tasks.jobs.attack_paths.config import (
INTERNAL_LABELS,
INTERNAL_PROPERTIES,
PROVIDER_ID_PROPERTY,
)
logger = logging.getLogger(BackendLogger.API)
@@ -253,7 +257,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
nodes = []
kept_node_ids = set()
for node in graph.nodes:
if node._properties.get("provider_id") != provider_id:
if node._properties.get(PROVIDER_ID_PROPERTY) != provider_id:
continue
kept_node_ids.add(node.element_id)
@@ -273,7 +277,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
relationships = []
for relationship in graph.relationships:
if relationship._properties.get("provider_id") != provider_id:
if relationship._properties.get(PROVIDER_ID_PROPERTY) != provider_id:
continue
if (

View File

@@ -9,6 +9,10 @@ from rest_framework.exceptions import APIException, PermissionDenied, Validation
from api.attack_paths import database as graph_database
from api.attack_paths import views_helpers
from tasks.jobs.attack_paths.config import (
PROVIDER_ELEMENT_ID_PROPERTY,
PROVIDER_ID_PROPERTY,
)
def _make_neo4j_error(message, code):
@@ -108,7 +112,7 @@ def test_execute_query_serializes_graph(
labels=["AWSAccount"],
properties={
"name": "account",
"provider_id": provider_id,
PROVIDER_ID_PROPERTY: provider_id,
"complex": {
"items": [
attack_paths_graph_stub_classes.NativeValue("value"),
@@ -118,14 +122,14 @@ def test_execute_query_serializes_graph(
},
)
node_2 = attack_paths_graph_stub_classes.Node(
"node-2", ["RDSInstance"], {"provider_id": provider_id}
"node-2", ["RDSInstance"], {PROVIDER_ID_PROPERTY: provider_id}
)
relationship = attack_paths_graph_stub_classes.Relationship(
element_id="rel-1",
rel_type="OWNS",
start_node=node,
end_node=node_2,
properties={"weight": 1, "provider_id": provider_id},
properties={"weight": 1, PROVIDER_ID_PROPERTY: provider_id},
)
graph = SimpleNamespace(nodes=[node, node_2], relationships=[relationship])
@@ -213,20 +217,20 @@ def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes)
provider_id = "provider-keep"
node_keep = attack_paths_graph_stub_classes.Node(
"n1", ["AWSAccount"], {"provider_id": provider_id}
"n1", ["AWSAccount"], {PROVIDER_ID_PROPERTY: provider_id}
)
node_drop = attack_paths_graph_stub_classes.Node(
"n2", ["AWSAccount"], {"provider_id": "provider-other"}
"n2", ["AWSAccount"], {PROVIDER_ID_PROPERTY: "provider-other"}
)
rel_keep = attack_paths_graph_stub_classes.Relationship(
"r1", "OWNS", node_keep, node_keep, {"provider_id": provider_id}
"r1", "OWNS", node_keep, node_keep, {PROVIDER_ID_PROPERTY: provider_id}
)
rel_drop_by_provider = attack_paths_graph_stub_classes.Relationship(
"r2", "OWNS", node_keep, node_drop, {"provider_id": "provider-other"}
"r2", "OWNS", node_keep, node_drop, {PROVIDER_ID_PROPERTY: "provider-other"}
)
rel_drop_orphaned = attack_paths_graph_stub_classes.Relationship(
"r3", "OWNS", node_keep, node_drop, {"provider_id": provider_id}
"r3", "OWNS", node_keep, node_drop, {PROVIDER_ID_PROPERTY: provider_id}
)
graph = SimpleNamespace(
@@ -350,10 +354,8 @@ def test_serialize_properties_filters_internal_fields():
"_module_name": "cartography:aws",
"_module_version": "0.98.0",
# Provider isolation
"_provider_id": "42",
"_provider_element_id": "42:abc123",
"provider_id": "42",
"provider_element_id": "42:abc123",
PROVIDER_ID_PROPERTY: "42",
PROVIDER_ELEMENT_ID_PROPERTY: "42:abc123",
}
result = views_helpers._serialize_properties(properties)
@@ -440,13 +442,13 @@ def test_execute_custom_query_serializes_graph(
):
provider_id = "test-provider-123"
node_1 = attack_paths_graph_stub_classes.Node(
"node-1", ["AWSAccount"], {"provider_id": provider_id}
"node-1", ["AWSAccount"], {PROVIDER_ID_PROPERTY: provider_id}
)
node_2 = attack_paths_graph_stub_classes.Node(
"node-2", ["RDSInstance"], {"provider_id": provider_id}
"node-2", ["RDSInstance"], {PROVIDER_ID_PROPERTY: provider_id}
)
relationship = attack_paths_graph_stub_classes.Relationship(
"rel-1", "OWNS", node_1, node_2, {"provider_id": provider_id}
"rel-1", "OWNS", node_1, node_2, {PROVIDER_ID_PROPERTY: provider_id}
)
graph_result = MagicMock()

View File

@@ -10,16 +10,12 @@ from tasks.jobs.attack_paths import aws
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
# Neo4j internal labels (Prowler-specific, not provider-specific)
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources
# - `_ProviderResource`: Added to ALL synced nodes for provider isolation and drop/query ops
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
INTERNET_NODE_LABEL = "Internet"
PROWLER_FINDING_LABEL = "ProwlerFinding"
PROVIDER_RESOURCE_LABEL = "_ProviderResource"
INTERNET_NODE_LABEL = "Internet"
# Phase 1 dual-write: deprecated label kept for drop_subgraph and infrastructure queries
# Remove in Phase 2 once all nodes use the private label exclusively
DEPRECATED_PROVIDER_RESOURCE_LABEL = "ProviderResource"
@dataclass(frozen=True)
@@ -31,7 +27,6 @@ class ProviderConfig:
uid_field: str # e.g., "arn"
# Label for resources connected to the account node, enabling indexed finding lookups.
resource_label: str # e.g., "_AWSResource"
deprecated_resource_label: str # e.g., "AWSResource"
ingestion_function: Callable
@@ -43,7 +38,6 @@ AWS_CONFIG = ProviderConfig(
root_node_label="AWSAccount",
uid_field="arn",
resource_label="_AWSResource",
deprecated_resource_label="AWSResource",
ingestion_function=aws.start_aws_ingestion,
)
@@ -56,18 +50,16 @@ PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
INTERNAL_LABELS: list[str] = [
"Tenant", # From Cartography, but it looks like it's ours
PROVIDER_RESOURCE_LABEL,
DEPRECATED_PROVIDER_RESOURCE_LABEL,
# Add all provider-specific resource labels
*[config.resource_label for config in PROVIDER_CONFIGS.values()],
*[config.deprecated_resource_label for config in PROVIDER_CONFIGS.values()],
]
# Provider isolation properties
PROVIDER_ID_PROPERTY = "_provider_id"
PROVIDER_ELEMENT_ID_PROPERTY = "_provider_element_id"
PROVIDER_ISOLATION_PROPERTIES: list[str] = [
"_provider_id",
"_provider_element_id",
"provider_id",
"provider_element_id",
PROVIDER_ID_PROPERTY,
PROVIDER_ELEMENT_ID_PROPERTY,
]
# Cartography bookkeeping metadata
@@ -115,9 +107,3 @@ def get_provider_resource_label(provider_type: str) -> str:
"""Get the resource label for a provider type (e.g., `_AWSResource`)."""
config = PROVIDER_CONFIGS.get(provider_type)
return config.resource_label if config else "_UnknownProviderResource"
def get_deprecated_provider_resource_label(provider_type: str) -> str:
"""Get the deprecated resource label for a provider type (e.g., `AWSResource`)."""
config = PROVIDER_CONFIGS.get(provider_type)
return config.deprecated_resource_label if config else "UnknownProviderResource"

View File

@@ -25,7 +25,6 @@ from api.models import Provider, ResourceFindingMapping
from prowler.config import config as ProwlerConfig
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
get_deprecated_provider_resource_label,
get_node_uid_field,
get_provider_resource_label,
get_root_node_label,
@@ -153,9 +152,6 @@ def add_resource_label(
{
"__ROOT_LABEL__": get_root_node_label(provider_type),
"__RESOURCE_LABEL__": get_provider_resource_label(provider_type),
"__DEPRECATED_RESOURCE_LABEL__": get_deprecated_provider_resource_label(
provider_type
),
},
)

View File

@@ -6,9 +6,10 @@ from cartography.client.core.tx import run_write_query
from celery.utils.log import get_task_logger
from tasks.jobs.attack_paths.config import (
DEPRECATED_PROVIDER_RESOURCE_LABEL,
INTERNET_NODE_LABEL,
PROWLER_FINDING_LABEL,
PROVIDER_ELEMENT_ID_PROPERTY,
PROVIDER_ID_PROPERTY,
PROVIDER_RESOURCE_LABEL,
)
@@ -27,8 +28,6 @@ FINDINGS_INDEX_STATEMENTS = [
# Resource indexes for Prowler Finding lookups
"CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:_AWSResource) ON (n.arn);",
"CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:_AWSResource) ON (n.id);",
"CREATE INDEX deprecated_aws_resource_arn IF NOT EXISTS FOR (n:AWSResource) ON (n.arn);",
"CREATE INDEX deprecated_aws_resource_id IF NOT EXISTS FOR (n:AWSResource) ON (n.id);",
# Prowler Finding indexes
f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);",
f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);",
@@ -40,10 +39,8 @@ FINDINGS_INDEX_STATEMENTS = [
# Indexes for provider resource sync operations
SYNC_INDEX_STATEMENTS = [
f"CREATE INDEX provider_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n._provider_element_id);",
f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n._provider_id);",
f"CREATE INDEX deprecated_provider_element_id IF NOT EXISTS FOR (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL}) ON (n.provider_element_id);",
f"CREATE INDEX deprecated_provider_resource_provider_id IF NOT EXISTS FOR (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL}) ON (n.provider_id);",
f"CREATE INDEX provider_resource_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.{PROVIDER_ELEMENT_ID_PROPERTY});",
f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.{PROVIDER_ID_PROPERTY});",
]

View File

@@ -2,6 +2,8 @@
from tasks.jobs.attack_paths.config import (
INTERNET_NODE_LABEL,
PROWLER_FINDING_LABEL,
PROVIDER_ELEMENT_ID_PROPERTY,
PROVIDER_ID_PROPERTY,
PROVIDER_RESOURCE_LABEL,
)
@@ -26,7 +28,7 @@ ADD_RESOURCE_LABEL_TEMPLATE = """
MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r)
WHERE NOT r:__ROOT_LABEL__ AND NOT r:__RESOURCE_LABEL__
WITH r LIMIT $batch_size
SET r:__RESOURCE_LABEL__:__DEPRECATED_RESOURCE_LABEL__
SET r:__RESOURCE_LABEL__
RETURN COUNT(r) AS labeled_count
"""
@@ -149,22 +151,18 @@ RELATIONSHIPS_FETCH_QUERY = """
LIMIT $batch_size
"""
NODE_SYNC_TEMPLATE = """
NODE_SYNC_TEMPLATE = f"""
UNWIND $rows AS row
MERGE (n:__NODE_LABELS__ {_provider_element_id: row.provider_element_id})
MERGE (n:__NODE_LABELS__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}})
SET n += row.props
SET n._provider_id = $provider_id
SET n.provider_element_id = row.provider_element_id
SET n.provider_id = $provider_id
""" # The last two lines are deprecated properties
SET n.{PROVIDER_ID_PROPERTY} = $provider_id
"""
RELATIONSHIP_SYNC_TEMPLATE = f"""
UNWIND $rows AS row
MATCH (s:{PROVIDER_RESOURCE_LABEL} {{_provider_element_id: row.start_element_id}})
MATCH (t:{PROVIDER_RESOURCE_LABEL} {{_provider_element_id: row.end_element_id}})
MERGE (s)-[r:__REL_TYPE__ {{_provider_element_id: row.provider_element_id}}]->(t)
MATCH (s:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.start_element_id}})
MATCH (t:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.end_element_id}})
MERGE (s)-[r:__REL_TYPE__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}]->(t)
SET r += row.props
SET r._provider_id = $provider_id
SET r.provider_element_id = row.provider_element_id
SET r.provider_id = $provider_id
""" # The last two lines are deprecated properties
SET r.{PROVIDER_ID_PROPERTY} = $provider_id
"""

View File

@@ -13,7 +13,6 @@ from celery.utils.log import get_task_logger
from api.attack_paths import database as graph_database
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
DEPRECATED_PROVIDER_RESOURCE_LABEL,
PROVIDER_ISOLATION_PROPERTIES,
PROVIDER_RESOURCE_LABEL,
)
@@ -113,7 +112,6 @@ def sync_nodes(
for labels, batch in grouped.items():
label_set = set(labels)
label_set.add(PROVIDER_RESOURCE_LABEL)
label_set.add(DEPRECATED_PROVIDER_RESOURCE_LABEL)
node_labels = ":".join(f"`{label}`" for label in sorted(label_set))
query = render_cypher_template(

View File

@@ -6,9 +6,6 @@ import pytest
from tasks.jobs.attack_paths import findings as findings_module
from tasks.jobs.attack_paths import internet as internet_module
from tasks.jobs.attack_paths import sync as sync_module
from tasks.jobs.attack_paths.config import (
get_deprecated_provider_resource_label,
)
from tasks.jobs.attack_paths.scan import run as attack_paths_run
from api.models import (
@@ -648,7 +645,7 @@ class TestAttackPathsFindingsHelpers:
),
patch(
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
return_value="AWSResource",
return_value="_AWSResource",
),
):
findings_module.load_findings(
@@ -1069,7 +1066,7 @@ class TestAttackPathsFindingsHelpers:
),
patch(
"tasks.jobs.attack_paths.findings.get_provider_resource_label",
return_value="AWSResource",
return_value="_AWSResource",
),
):
findings_module.load_findings(mock_session, empty_gen(), provider, config)
@@ -1077,19 +1074,8 @@ class TestAttackPathsFindingsHelpers:
mock_session.run.assert_not_called()
class TestProviderConfigAccessors:
def test_get_deprecated_provider_resource_label_known_provider(self):
assert get_deprecated_provider_resource_label("aws") == "AWSResource"
def test_get_deprecated_provider_resource_label_unknown_provider(self):
assert (
get_deprecated_provider_resource_label("unknown")
== "UnknownProviderResource"
)
class TestAddResourceLabel:
def test_add_resource_label_applies_both_labels(self):
def test_add_resource_label_applies_private_label(self):
mock_session = MagicMock()
first_result = MagicMock()
@@ -1104,11 +1090,11 @@ class TestAddResourceLabel:
assert mock_session.run.call_count == 2
query = mock_session.run.call_args_list[0].args[0]
assert "_AWSResource" in query
assert "AWSResource" in query
assert "AWSResource" not in query.replace("_AWSResource", "")
class TestSyncNodes:
def test_sync_nodes_adds_both_labels(self):
def test_sync_nodes_adds_private_label(self):
mock_source_session = MagicMock()
mock_target_session = MagicMock()
@@ -1137,7 +1123,7 @@ class TestSyncNodes:
assert total == 1
query = mock_target_session.run.call_args.args[0]
assert "_ProviderResource" in query
assert "ProviderResource" in query
assert "ProviderResource" not in query.replace("_ProviderResource", "")
class TestInternetAnalysis: