diff --git a/.github/labeler.yml b/.github/labeler.yml index b9abb1dfd0..d3d89ceaee 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -82,6 +82,11 @@ provider/linode: - any-glob-to-any-file: "prowler/providers/linode/**" - any-glob-to-any-file: "tests/providers/linode/**" +provider/oracledb: + - changed-files: + - any-glob-to-any-file: "prowler/providers/oracledb/**" + - any-glob-to-any-file: "tests/providers/oracledb/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" @@ -121,6 +126,8 @@ mutelist: - any-glob-to-any-file: "tests/providers/vercel/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/okta/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/okta/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/oracledb/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/oracledb/lib/mutelist/**" integration/s3: - changed-files: diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 3f312e8f0b..06fffe8857 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -351,6 +351,30 @@ jobs: flags: prowler-py${{ matrix.python-version }}-okta files: ./okta_coverage.xml + # Oracle Database Provider + - name: Check if Oracle Database files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-oracledb + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/oracledb/** + ./tests/**/oracledb/** + ./uv.lock + + - name: Run Oracle Database tests + if: steps.changed-oracledb.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/oracledb --cov-report=xml:oracledb_coverage.xml tests/providers/oracledb + + - name: Upload Oracle Database coverage to Codecov + if: steps.changed-oracledb.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-oracledb + files: ./oracledb_coverage.xml + # NHN Provider - name: Check if NHN files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/prowler/__main__.py b/prowler/__main__.py index 12f4b24cac..dde78ebe73 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -156,6 +156,7 @@ from prowler.providers.nhn.models import NHNOutputOptions from prowler.providers.okta.models import OktaOutputOptions from prowler.providers.openstack.models import OpenStackOutputOptions from prowler.providers.oraclecloud.models import OCIOutputOptions +from prowler.providers.oracledb.models import OracledbOutputOptions from prowler.providers.scaleway.models import ScalewayOutputOptions from prowler.providers.stackit.models import StackITOutputOptions from prowler.providers.vercel.models import VercelOutputOptions @@ -441,6 +442,10 @@ def prowler(): output_options = OktaOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "oracledb": + output_options = OracledbOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) elif provider == "scaleway": output_options = ScalewayOutputOptions( args, bulk_checks_metadata, global_provider.identity diff --git a/prowler/changelog.d/oracledb-provider.added.md b/prowler/changelog.d/oracledb-provider.added.md new file mode 100644 index 0000000000..de7b12dffa --- /dev/null +++ b/prowler/changelog.d/oracledb-provider.added.md @@ -0,0 +1 @@ +Oracle Database provider (`oracledb`) with 20 security checks based on the Oracle Database Security Assessment Tool (DBSAT) covering users, privileges, auditing, encryption and configuration diff --git a/prowler/config/config.py b/prowler/config/config.py index 5bec954def..96cd831b2a 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -79,6 +79,7 @@ class Provider(str, Enum): SCALEWAY = "scaleway" VERCEL = "vercel" OKTA = "okta" + ORACLEDB = "oracledb" STACKIT = "stackit" LINODE = "linode" E2ENETWORKS = "e2enetworks" diff --git a/prowler/config/oracledb_mutelist_example.yaml b/prowler/config/oracledb_mutelist_example.yaml new file mode 100644 index 0000000000..82fcb24dd4 --- /dev/null +++ b/prowler/config/oracledb_mutelist_example.yaml @@ -0,0 +1,20 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### The value of the GLOBAL_NAME view, as shown by prowler oracledb. +### Region is always "*" — a database has no regional concept. +### Resources matches against the resource name (e.g. a username, a +### tablespace name or an initialization parameter name). +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Accounts, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "ORCL.EXAMPLE.COM": + Checks: + "users_sample_schemas_removed": + Regions: + - "*" + Resources: + - "ORCL.EXAMPLE.COM" diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index c0c6a02e5d..7932e6ae6a 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -797,6 +797,10 @@ def execute( is_finding_muted_args["org_domain"] = ( global_provider.identity.org_domain ) + elif global_provider.type == "oracledb": + is_finding_muted_args["database_name"] = ( + global_provider.identity.database_name + ) elif global_provider.type == "linode": is_finding_muted_args["account_id"] = ( global_provider.identity.account_id diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 5f92ecf481..1646c8ffa6 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -993,6 +993,41 @@ class CheckReportOkta(Check_Report): self.region = region +@dataclass +class CheckReportOracledb(Check_Report): + """Contains the Oracle Database Check's finding information.""" + + resource_name: str + resource_id: str + database_name: str + region: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str = None, + resource_id: str = None, + database_name: str = None, + region: str = "global", + ) -> None: + """Initialize the Oracle Database Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. + resource_name: The name of the resource related with the finding. + resource_id: The id of the resource related with the finding. + database_name: The database global name related with the finding. + region: Always "global" — a database has no regional concept. + """ + super().__init__(metadata, resource) + self.resource_name = resource_name or getattr(resource, "name", "") + self.resource_id = resource_id or getattr(resource, "id", "") + self.database_name = database_name or getattr(resource, "database_name", "") + self.region = region + + @dataclass class CheckReportGoogleWorkspace(Check_Report): """Contains the Google Workspace Check's finding information.""" diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 38661e6445..fbf67fdfbc 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -50,6 +50,7 @@ class ProwlerArgumentParser: "vercel", "e2enetworks", "okta", + "oracledb", "scaleway", "stackit", "linode", @@ -75,10 +76,10 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks,dashboard,iac,image,llm{extra_providers_csv}}} ...", + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,oracledb,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks,dashboard,iac,image,llm{extra_providers_csv}}} ...", epilog=f""" Available Cloud Providers: - {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks{extra_providers_csv}}} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,oracledb,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -89,6 +90,7 @@ Available Cloud Providers: okta Okta Provider cloudflare Cloudflare Provider oraclecloud Oracle Cloud Infrastructure Provider + oracledb Oracle Database Provider openstack OpenStack Provider stackit StackIT Provider alibabacloud Alibaba Cloud Provider diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 7231572571..6ea7e5658f 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -468,6 +468,18 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = "global" + elif provider.type == "oracledb": + output_data["auth_method"] = provider.auth_method + output_data["account_uid"] = get_nested_attribute( + provider, "identity.database_name" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.database_name" + ) + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = "global" + elif provider.type == "scaleway": output_data["auth_method"] = "api_key" output_data["account_uid"] = get_nested_attribute( diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 3463014232..5578972af6 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1552,6 +1552,59 @@ class HTML(Output): ) return "" + @staticmethod + def get_oracledb_assessment_summary(provider: Provider) -> str: + """ + get_oracledb_assessment_summary gets the HTML assessment summary for the Oracle Database provider + + Args: + provider (Provider): the Oracle Database provider object + + Returns: + str: HTML assessment summary for the Oracle Database provider + """ + try: + assessment_items = f""" +
  • + Oracle Database: {provider.identity.database_name} +
  • +
  • + DSN: {provider.identity.dsn} +
  • """ + + credentials_items = f""" +
  • + Authentication: {provider.auth_method} +
  • +
  • + User: {provider.identity.user} +
  • """ + + return f""" +
    +
    +
    + Oracle Database Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Oracle Database Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_scaleway_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index 05421ac584..15837da44d 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -46,6 +46,8 @@ def stdout_report(finding, color, verbose, status, fix, provider=None): details = finding.region elif finding.check_metadata.Provider == "okta": details = finding.region + elif finding.check_metadata.Provider == "oracledb": + details = finding.region elif finding.check_metadata.Provider == "scaleway": details = finding.region elif finding.check_metadata.Provider == "linode": diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 2d8da80597..6486bbbf03 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -121,6 +121,9 @@ def display_summary_table( elif provider.type == "okta": entity_type = "Okta Org" audited_entities = provider.identity.org_domain + elif provider.type == "oracledb": + entity_type = "Oracle Database" + audited_entities = provider.identity.database_name elif provider.type == "scaleway": entity_type = "Organization" audited_entities = provider.identity.organization_id diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index d4793adbe7..f818644ac0 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -654,6 +654,20 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif arguments.provider == "oracledb": + provider_class( + oracledb_user=getattr(arguments, "oracledb_user", ""), + oracledb_password=getattr(arguments, "oracledb_password", ""), + oracledb_dsn=getattr(arguments, "oracledb_dsn", ""), + oracledb_host=getattr(arguments, "oracledb_host", ""), + oracledb_port=getattr(arguments, "oracledb_port", None), + oracledb_service_name=getattr( + arguments, "oracledb_service_name", "" + ), + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) elif arguments.provider == "scaleway": # Credentials are read from the SCW_ACCESS_KEY / # SCW_SECRET_KEY env vars by the provider itself; there diff --git a/prowler/providers/oracledb/__init__.py b/prowler/providers/oracledb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/exceptions/__init__.py b/prowler/providers/oracledb/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/exceptions/exceptions.py b/prowler/providers/oracledb/exceptions/exceptions.py new file mode 100644 index 0000000000..53fda26899 --- /dev/null +++ b/prowler/providers/oracledb/exceptions/exceptions.py @@ -0,0 +1,112 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 20000 to 20999 are reserved for Oracle Database exceptions +class OracledbBaseException(ProwlerException): + """Base class for Oracle Database Errors.""" + + ORACLEDB_ERROR_CODES = { + (20000, "OracledbEnvironmentVariableError"): { + "message": "Oracle Database environment variable error", + "remediation": "Check the Oracle Database environment variables and ensure they are properly set.", + }, + (20001, "OracledbSetUpSessionError"): { + "message": "Error setting up Oracle Database session", + "remediation": "Check the connection credentials (user, password, DSN) and ensure the database is reachable.", + }, + (20002, "OracledbSetUpIdentityError"): { + "message": "Oracle Database identity setup error due to bad credentials", + "remediation": "Check the connection credentials and confirm the user can query the database dictionary views.", + }, + (20003, "OracledbInvalidCredentialsError"): { + "message": "Oracle Database credentials are not valid", + "remediation": "Check the user and password for the Oracle Database connection.", + }, + (20004, "OracledbConnectionError"): { + "message": "Could not connect to the Oracle Database", + "remediation": "Check the DSN (host:port/service_name), network connectivity and listener status.", + }, + (20005, "OracledbInvalidProviderIdError"): { + "message": "The provided provider_id does not match the connected database", + "remediation": "Check the provider_id (Oracle Database global name) and ensure it matches the database the credentials connect to.", + }, + (20006, "OracledbInsufficientPrivilegesError"): { + "message": "Oracle Database user is missing required privileges", + "remediation": "Grant the SELECT ANY DICTIONARY system privilege (or the SELECT_CATALOG_ROLE role) to the assessment user.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "OracleDB" + error_info = self.ORACLEDB_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Oracle Database error.", + "remediation": "Check the Oracle Database documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class OracledbCredentialsError(OracledbBaseException): + """Base class for Oracle Database credentials errors.""" + + def __init__(self, code, file=None, original_exception=None, message=None): + super().__init__(code, file, original_exception, message) + + +class OracledbEnvironmentVariableError(OracledbCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 20000, file=file, original_exception=original_exception, message=message + ) + + +class OracledbSetUpSessionError(OracledbCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 20001, file=file, original_exception=original_exception, message=message + ) + + +class OracledbSetUpIdentityError(OracledbCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 20002, file=file, original_exception=original_exception, message=message + ) + + +class OracledbInvalidCredentialsError(OracledbCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 20003, file=file, original_exception=original_exception, message=message + ) + + +class OracledbConnectionError(OracledbCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 20004, file=file, original_exception=original_exception, message=message + ) + + +class OracledbInvalidProviderIdError(OracledbCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 20005, file=file, original_exception=original_exception, message=message + ) + + +class OracledbInsufficientPrivilegesError(OracledbCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 20006, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/oracledb/lib/__init__.py b/prowler/providers/oracledb/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/lib/arguments/__init__.py b/prowler/providers/oracledb/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/lib/arguments/arguments.py b/prowler/providers/oracledb/lib/arguments/arguments.py new file mode 100644 index 0000000000..d2cb92f28b --- /dev/null +++ b/prowler/providers/oracledb/lib/arguments/arguments.py @@ -0,0 +1,71 @@ +SENSITIVE_ARGUMENTS = frozenset({"--oracledb-password"}) + + +def init_parser(self): + """Init the Oracle Database Provider CLI parser. + + The provider authenticates with a database user and password over a + python-oracledb thin-mode connection. The password should be supplied + via the `ORACLEDB_PASSWORD` environment variable; the flag accepts a + value only for backward compatibility and its value is redacted in + outputs. + """ + oracledb_parser = self.subparsers.add_parser( + "oracledb", + parents=[self.common_providers_parser], + help="Oracle Database Provider", + ) + oracledb_auth_subparser = oracledb_parser.add_argument_group("Authentication") + oracledb_auth_subparser.add_argument( + "--oracledb-user", + nargs="?", + default=None, + metavar="ORACLEDB_USER", + help=( + "Oracle Database user to connect with. Needs read access to the " + "data dictionary (SELECT ANY DICTIONARY or SELECT_CATALOG_ROLE)." + ), + ) + oracledb_auth_subparser.add_argument( + "--oracledb-password", + nargs="?", + default=None, + metavar="ORACLEDB_PASSWORD", + help=( + "Password for the Oracle Database user. Use the ORACLEDB_PASSWORD " + "environment variable instead of passing the value directly." + ), + ) + oracledb_connection_subparser = oracledb_parser.add_argument_group("Connection") + oracledb_connection_subparser.add_argument( + "--oracledb-dsn", + nargs="?", + default=None, + metavar="ORACLEDB_DSN", + help=( + "Connect string, e.g. host:1521/service_name or a full Easy " + "Connect string. Takes precedence over --oracledb-host/" + "--oracledb-port/--oracledb-service-name." + ), + ) + oracledb_connection_subparser.add_argument( + "--oracledb-host", + nargs="?", + default=None, + metavar="ORACLEDB_HOST", + help="Database listener host, used with --oracledb-service-name.", + ) + oracledb_connection_subparser.add_argument( + "--oracledb-port", + type=int, + default=None, + metavar="ORACLEDB_PORT", + help="Database listener port. Default: 1521.", + ) + oracledb_connection_subparser.add_argument( + "--oracledb-service-name", + nargs="?", + default=None, + metavar="ORACLEDB_SERVICE_NAME", + help="Database service name, used with --oracledb-host.", + ) diff --git a/prowler/providers/oracledb/lib/mutelist/__init__.py b/prowler/providers/oracledb/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/lib/mutelist/mutelist.py b/prowler/providers/oracledb/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..0d1ddf28f9 --- /dev/null +++ b/prowler/providers/oracledb/lib/mutelist/mutelist.py @@ -0,0 +1,16 @@ +from prowler.lib.check.models import CheckReportOracledb +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class OracledbMutelist(Mutelist): + def is_finding_muted( + self, finding: CheckReportOracledb, database_name: str + ) -> bool: + return self.is_muted( + database_name, + finding.check_metadata.CheckID, + "*", + finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/oracledb/lib/service/__init__.py b/prowler/providers/oracledb/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/lib/service/service.py b/prowler/providers/oracledb/lib/service/service.py new file mode 100644 index 0000000000..1e08d7630e --- /dev/null +++ b/prowler/providers/oracledb/lib/service/service.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from prowler.providers.oracledb.oracledb_provider import OracledbProvider + + +class OracledbService: + """Base class for Oracle Database service implementations. + + Every service shares the single authenticated python-oracledb connection + opened by the provider — Oracle sessions are expensive and the data + dictionary queries the checks need are all read-only. + """ + + def __init__(self, service: str, provider: "OracledbProvider"): + self.provider = provider + self.service = service + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.connection = provider.session.connection + self.database_name = provider.identity.database_name + + def _execute_query(self, query: str, parameters: dict = None) -> list[tuple]: + """Run a read-only data dictionary query and return every row. + + Args: + query: The SQL statement to execute. + parameters: Optional bind variables for the statement. + + Returns: + list[tuple]: All rows returned by the query. + """ + with self.connection.cursor() as cursor: + cursor.execute(query, parameters or {}) + return cursor.fetchall() diff --git a/prowler/providers/oracledb/models.py b/prowler/providers/oracledb/models.py new file mode 100644 index 0000000000..af55d907c1 --- /dev/null +++ b/prowler/providers/oracledb/models.py @@ -0,0 +1,44 @@ +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class OracledbSession(BaseModel): + """Holds the live python-oracledb connection plus the non-secret + connection coordinates. The password is intentionally not stored — + services only need the already-authenticated connection object.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + user: str + dsn: str + # python-oracledb Connection; Any so pydantic does not try to validate it. + connection: Any + + +class OracledbIdentityInfo(BaseModel): + user: str + dsn: str + # Database global name (GLOBAL_NAME view), e.g. ORCL.EXAMPLE.COM. Used as + # the account UID in outputs and as the mutelist account key. + database_name: str + # Full version string from PRODUCT_COMPONENT_VERSION; empty when the + # connected user cannot query it. + version: Optional[str] = "" + + +class OracledbOutputOptions(ProviderOutputOptions): + def __init__(self, arguments, bulk_checks_metadata, identity): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + self.output_filename = ( + f"prowler-output-{identity.database_name}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/oracledb/oracledb_provider.py b/prowler/providers/oracledb/oracledb_provider.py new file mode 100644 index 0000000000..24403cdaf0 --- /dev/null +++ b/prowler/providers/oracledb/oracledb_provider.py @@ -0,0 +1,365 @@ +import os +from os import environ + +import oracledb +from colorama import Fore, Style + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.oracledb.exceptions.exceptions import ( + OracledbConnectionError, + OracledbEnvironmentVariableError, + OracledbInvalidCredentialsError, + OracledbInvalidProviderIdError, + OracledbSetUpIdentityError, + OracledbSetUpSessionError, +) +from prowler.providers.oracledb.lib.mutelist.mutelist import OracledbMutelist +from prowler.providers.oracledb.models import OracledbIdentityInfo, OracledbSession + +DEFAULT_PORT = 1521 + + +class OracledbProvider(Provider): + """Oracle Database Provider class. + + Connects to a single Oracle Database (on-premises, in a VM or a cloud + managed service) with python-oracledb in thin mode — no Oracle Client + libraries are required — and runs security assessment checks inspired by + the Oracle Database Security Assessment Tool (DBSAT) against the data + dictionary (DBA_* and V$* views). + + Attributes: + _type (str): The type of the provider. + _auth_method (str): The authentication method used by the provider. + _session (OracledbSession): The session object for the provider. + _identity (OracledbIdentityInfo): The identity information for the provider. + _audit_config (dict): The audit configuration for the provider. + _fixer_config (dict): The fixer configuration for the provider. + _mutelist (Mutelist): The mutelist for the provider. + audit_metadata (Audit_Metadata): The audit metadata for the provider. + """ + + _type: str = "oracledb" + sdk_only: bool = False + _auth_method: str = "User / Password" + _session: OracledbSession + _identity: OracledbIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: Mutelist + audit_metadata: Audit_Metadata + + def __init__( + self, + oracledb_user: str = "", + oracledb_password: str = "", + oracledb_dsn: str = "", + oracledb_host: str = "", + oracledb_port: int = None, + oracledb_service_name: str = "", + config_path: str = None, + config_content: dict = None, + fixer_config: dict = {}, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + """Oracle Database Provider constructor.""" + logger.info("Instantiating Oracle Database Provider...") + + OracledbProvider.validate_arguments( + oracledb_user=oracledb_user, + oracledb_password=oracledb_password, + oracledb_dsn=oracledb_dsn, + oracledb_host=oracledb_host, + oracledb_service_name=oracledb_service_name, + ) + self._session = OracledbProvider.setup_session( + user=oracledb_user, + password=oracledb_password, + dsn=oracledb_dsn, + host=oracledb_host, + port=oracledb_port, + service_name=oracledb_service_name, + ) + self._identity = OracledbProvider.setup_identity(self._session) + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._fixer_config = fixer_config + + if mutelist_content: + self._mutelist = OracledbMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = OracledbMutelist(mutelist_path=mutelist_path) + + Provider.set_global_provider(self) + + @property + def auth_method(self): + return self._auth_method + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def type(self): + return self._type + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> OracledbMutelist: + return self._mutelist + + @staticmethod + def resolve_dsn( + dsn: str = "", host: str = "", port: int = None, service_name: str = "" + ) -> str: + """Return the connect string, built from host/port/service when no DSN + is given. An explicit DSN always wins so users can pass full Easy + Connect strings (including protocol and wallet options) untouched.""" + dsn = dsn or environ.get("ORACLEDB_DSN", "") + if dsn: + return dsn.strip() + host = host or environ.get("ORACLEDB_HOST", "") + service_name = service_name or environ.get("ORACLEDB_SERVICE_NAME", "") + if host and service_name: + port = port or int(environ.get("ORACLEDB_PORT", DEFAULT_PORT)) + return f"{host.strip()}:{port}/{service_name.strip()}" + return "" + + @staticmethod + def validate_arguments( + oracledb_user: str = "", + oracledb_password: str = "", + oracledb_dsn: str = "", + oracledb_host: str = "", + oracledb_service_name: str = "", + ): + """Validate that all required connection values are provided. + + Falls back to the matching `ORACLEDB_*` environment variables when a + CLI argument is not supplied. Raises a single combined error if any + required value is missing. + """ + user = oracledb_user or environ.get("ORACLEDB_USER", "") + password = oracledb_password or environ.get("ORACLEDB_PASSWORD", "") + dsn = OracledbProvider.resolve_dsn( + dsn=oracledb_dsn, host=oracledb_host, service_name=oracledb_service_name + ) + + missing = [] + if not user: + missing.append("--oracledb-user / ORACLEDB_USER") + if not password: + missing.append("ORACLEDB_PASSWORD") + if not dsn: + missing.append( + "--oracledb-dsn / ORACLEDB_DSN (or --oracledb-host and " + "--oracledb-service-name)" + ) + if missing: + raise OracledbEnvironmentVariableError( + file=os.path.basename(__file__), + message=( + "Oracle Database provider requires the connection " + "credentials. Missing: " + ", ".join(missing) + ), + ) + + @staticmethod + def setup_session( + user: str = "", + password: str = "", + dsn: str = "", + host: str = "", + port: int = None, + service_name: str = "", + ) -> OracledbSession: + """Open a python-oracledb thin-mode connection from CLI args, falling + back to environment variables. + + The password is read from `ORACLEDB_PASSWORD` when not supplied — + secrets should be passed through environment variables, never CLI + values. + """ + try: + user = user or environ.get("ORACLEDB_USER", "") + password = password or environ.get("ORACLEDB_PASSWORD", "") + resolved_dsn = OracledbProvider.resolve_dsn( + dsn=dsn, host=host, port=port, service_name=service_name + ) + + connection = oracledb.connect( + user=user, password=password, dsn=resolved_dsn + ) + return OracledbSession(user=user, dsn=resolved_dsn, connection=connection) + except oracledb.DatabaseError as error: + # ORA-01017: invalid username/password — a credential problem, not + # a connectivity one; keep the two remediation paths separate. + if "ORA-01017" in str(error): + raise OracledbInvalidCredentialsError( + file=os.path.basename(__file__), + original_exception=error, + message=f"Invalid Oracle Database credentials: {error}", + ) + raise OracledbConnectionError( + file=os.path.basename(__file__), + original_exception=error, + message=f"Could not connect to Oracle Database '{dsn}': {error}", + ) + except (OracledbInvalidCredentialsError, OracledbConnectionError): + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + raise OracledbSetUpSessionError(original_exception=error) + + @staticmethod + def setup_identity(session: OracledbSession) -> OracledbIdentityInfo: + """Build the identity from the connected database. + + The database global name (GLOBAL_NAME view, readable by any user) + identifies the audited database in outputs and the mutelist. The + version is best-effort: PRODUCT_COMPONENT_VERSION may be restricted, + in which case it is left empty rather than failing the scan. + """ + try: + with session.connection.cursor() as cursor: + cursor.execute("SELECT global_name FROM global_name") + database_name = cursor.fetchone()[0] + + version = "" + try: + with session.connection.cursor() as cursor: + cursor.execute( + "SELECT version_full FROM product_component_version " + "WHERE product LIKE 'Oracle%'" + ) + row = cursor.fetchone() + if row: + version = row[0] + except Exception as error: + logger.warning( + f"Could not read the Oracle Database version: " + f"{error.__class__.__name__}: {error}" + ) + + return OracledbIdentityInfo( + user=session.user, + dsn=session.dsn, + database_name=database_name, + version=version, + ) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + raise OracledbSetUpIdentityError(original_exception=error) + + def print_credentials(self): + report_lines = [ + f"Oracle Database: {Fore.YELLOW}{self.identity.database_name}{Style.RESET_ALL}", + f"DSN: {Fore.YELLOW}{self.identity.dsn}{Style.RESET_ALL}", + f"User: {Fore.YELLOW}{self.identity.user}{Style.RESET_ALL}", + ] + if self.identity.version: + report_lines.append( + f"Version: {Fore.YELLOW}{self.identity.version}{Style.RESET_ALL}" + ) + report_title = f"{Style.BRIGHT}Using the Oracle Database credentials below:{Style.RESET_ALL}" + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + oracledb_user: str = "", + oracledb_password: str = "", + oracledb_dsn: str = "", + oracledb_host: str = "", + oracledb_port: int = None, + oracledb_service_name: str = "", + raise_on_exception: bool = True, + provider_id: str = None, + ) -> Connection: + """Test the connection to an Oracle Database with the provided credentials. + + Args: + provider_id: The provider ID (Oracle Database global name). When + supplied, the connected database global name must match it — + guards against the stored provider UID drifting from the + database the credentials actually connect to. Compared + case-insensitively; GLOBAL_NAME is stored uppercase. + """ + try: + OracledbProvider.validate_arguments( + oracledb_user=oracledb_user, + oracledb_password=oracledb_password, + oracledb_dsn=oracledb_dsn, + oracledb_host=oracledb_host, + oracledb_service_name=oracledb_service_name, + ) + session = OracledbProvider.setup_session( + user=oracledb_user, + password=oracledb_password, + dsn=oracledb_dsn, + host=oracledb_host, + port=oracledb_port, + service_name=oracledb_service_name, + ) + try: + identity = OracledbProvider.setup_identity(session) + + if ( + provider_id + and provider_id.strip().upper() != identity.database_name.upper() + ): + raise OracledbInvalidProviderIdError( + file=os.path.basename(__file__), + message=( + f"The provider ID '{provider_id}' does not match " + f"the connected Oracle Database global name " + f"'{identity.database_name}'." + ), + ) + finally: + try: + session.connection.close() + except Exception: + pass + + return Connection(is_connected=True) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise error + return Connection(error=error) diff --git a/prowler/providers/oracledb/services/__init__.py b/prowler/providers/oracledb/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/audit/__init__.py b/prowler/providers/oracledb/services/audit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/audit/audit_client.py b/prowler/providers/oracledb/services/audit/audit_client.py new file mode 100644 index 0000000000..523bb1ee91 --- /dev/null +++ b/prowler/providers/oracledb/services/audit/audit_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.oracledb.services.audit.audit_service import Audit + +audit_client = Audit(Provider.get_global_provider()) diff --git a/prowler/providers/oracledb/services/audit/audit_service.py b/prowler/providers/oracledb/services/audit/audit_service.py new file mode 100644 index 0000000000..b18bf67bc4 --- /dev/null +++ b/prowler/providers/oracledb/services/audit/audit_service.py @@ -0,0 +1,70 @@ +from typing import Optional + +from prowler.lib.logger import logger +from prowler.providers.oracledb.lib.service.service import OracledbService +from prowler.providers.oracledb.oracledb_provider import OracledbProvider + + +class Audit(OracledbService): + """Oracle Database auditing service. + + Reads the auditing initialization parameters, the Unified Auditing + option and the enabled unified audit policies, mirroring the auditing + findings of the Oracle Database Security Assessment Tool (DBSAT). + """ + + def __init__(self, provider: OracledbProvider): + super().__init__(__class__.__name__, provider) + self.audit_trail: Optional[str] = None + self.audit_sys_operations: Optional[str] = None + self._get_audit_parameters() + self.unified_auditing = self._get_unified_auditing() + self.enabled_unified_policies = self._list_enabled_unified_policies() + + def _get_audit_parameters(self): + """Read the AUDIT_TRAIL and AUDIT_SYS_OPERATIONS parameters.""" + logger.info("Audit - Reading auditing initialization parameters...") + try: + rows = self._execute_query( + "SELECT name, value FROM v$parameter " + "WHERE name IN ('audit_trail', 'audit_sys_operations')" + ) + for name, value in rows: + if name == "audit_trail": + self.audit_trail = value + elif name == "audit_sys_operations": + self.audit_sys_operations = value + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_unified_auditing(self) -> bool: + """Return True when the database runs with pure Unified Auditing.""" + logger.info("Audit - Reading the Unified Auditing option...") + try: + rows = self._execute_query( + "SELECT value FROM v$option WHERE parameter = 'Unified Auditing'" + ) + if rows: + return rows[0][0] == "TRUE" + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return False + + def _list_enabled_unified_policies(self) -> list[str]: + """List the enabled unified audit policies.""" + logger.info("Audit - Listing enabled unified audit policies...") + policies = [] + try: + rows = self._execute_query( + "SELECT DISTINCT policy_name FROM audit_unified_enabled_policies" + ) + policies = sorted(row[0] for row in rows) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return policies diff --git a/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/__init__.py b/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled.metadata.json b/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled.metadata.json new file mode 100644 index 0000000000..95eb086b16 --- /dev/null +++ b/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "audit_sys_operations_enabled", + "CheckTitle": "Auditing of SYS administrative operations is enabled", + "CheckType": [], + "ServiceName": "audit", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabase", + "ResourceGroup": "monitoring", + "Description": "Checks that **AUDIT_SYS_OPERATIONS** is TRUE so statements run by SYS and users connecting AS SYSDBA/SYSOPER are audited (DBSAT finding AUDIT.ADMINACTIONS).", + "Risk": "SYS bypasses standard auditing. Without AUDIT_SYS_OPERATIONS, the most privileged actions in the database — including tampering with the audit trail itself — leave no trace.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/introduction-to-auditing.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET audit_sys_operations=TRUE SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Run ALTER SYSTEM SET audit_sys_operations=TRUE SCOPE=SPFILE\n2. Restart the database\n3. Ship the generated OS audit files to a centralized, tamper-resistant log store", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set AUDIT_SYS_OPERATIONS to TRUE so administrative activity by SYS is written to the operating system audit trail, and forward those files to a SIEM.", + "Url": "https://hub.prowler.com/check/audit_sys_operations_enabled" + } + }, + "Categories": [ + "logging", + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled.py b/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled.py new file mode 100644 index 0000000000..5acc125a0d --- /dev/null +++ b/prowler/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.audit.audit_client import audit_client + + +class audit_sys_operations_enabled(Check): + """Check that auditing of SYS administrative operations is enabled.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=audit_client.database_name, + resource_id=audit_client.database_name, + ) + if (audit_client.audit_sys_operations or "").upper() == "TRUE": + report.status = "PASS" + report.status_extended = ( + f"Database {audit_client.database_name} audits SYS " + "administrative operations (AUDIT_SYS_OPERATIONS=TRUE)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database {audit_client.database_name} does not audit SYS " + "administrative operations (AUDIT_SYS_OPERATIONS=FALSE)." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/audit/audit_trail_enabled/__init__.py b/prowler/providers/oracledb/services/audit/audit_trail_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled.metadata.json b/prowler/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled.metadata.json new file mode 100644 index 0000000000..7165207c71 --- /dev/null +++ b/prowler/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "audit_trail_enabled", + "CheckTitle": "Database auditing is enabled", + "CheckType": [], + "ServiceName": "audit", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabase", + "ResourceGroup": "monitoring", + "Description": "Checks that database auditing is active, either through pure **Unified Auditing** or a traditional **AUDIT_TRAIL** setting other than NONE (DBSAT finding AUDIT.ENABLED).", + "Risk": "With auditing disabled there is no record of logins, privilege use or data access. Attacks and misuse become undetectable, forensics is impossible and most regulatory frameworks are violated.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/introduction-to-auditing.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET audit_trail=DB,EXTENDED SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Prefer Unified Auditing: relink the database with the uniaud_on option or rely on mixed mode policies\n2. For traditional auditing, run ALTER SYSTEM SET audit_trail=DB,EXTENDED SCOPE=SPFILE and restart\n3. Enable at least the ORA_SECURECONFIG and ORA_LOGON_FAILURES unified audit policies", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Unified Auditing (or at minimum set AUDIT_TRAIL to a value other than NONE) and enable baseline audit policies so security-relevant activity is recorded.", + "Url": "https://hub.prowler.com/check/audit_trail_enabled" + } + }, + "Categories": [ + "logging", + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled.py b/prowler/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled.py new file mode 100644 index 0000000000..2357afdeaf --- /dev/null +++ b/prowler/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.audit.audit_client import audit_client + + +class audit_trail_enabled(Check): + """Check that database auditing is enabled.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=audit_client.database_name, + resource_id=audit_client.database_name, + ) + audit_trail = (audit_client.audit_trail or "NONE").upper() + if audit_client.unified_auditing: + report.status = "PASS" + report.status_extended = ( + f"Database {audit_client.database_name} has Unified " + "Auditing enabled." + ) + elif audit_trail != "NONE": + report.status = "PASS" + report.status_extended = ( + f"Database {audit_client.database_name} has traditional " + f"auditing enabled (AUDIT_TRAIL={audit_trail})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database {audit_client.database_name} has auditing " + "disabled (AUDIT_TRAIL=NONE and no Unified Auditing)." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/configuration/__init__.py b/prowler/providers/oracledb/services/configuration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/configuration/configuration_client.py b/prowler/providers/oracledb/services/configuration/configuration_client.py new file mode 100644 index 0000000000..722db7ba48 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.oracledb.services.configuration.configuration_service import ( + Configuration, +) + +configuration_client = Configuration(Provider.get_global_provider()) diff --git a/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/__init__.py b/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled.metadata.json b/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled.metadata.json new file mode 100644 index 0000000000..43facc9957 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "configuration_dictionary_accessibility_disabled", + "CheckTitle": "Access to data dictionary objects is restricted (O7_DICTIONARY_ACCESSIBILITY is FALSE)", + "CheckType": [], + "ServiceName": "configuration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabaseParameter", + "ResourceGroup": "database", + "Description": "Checks that **O7_DICTIONARY_ACCESSIBILITY** is FALSE so ANY-style object privileges (SELECT ANY TABLE, etc.) do not extend to SYS-owned data dictionary objects (DBSAT finding CONF.SYSOBJ).", + "Risk": "With the parameter set to TRUE, users holding ANY privileges can read or modify dictionary base tables — including password verifiers — enabling credential theft and full database compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/O7_DICTIONARY_ACCESSIBILITY.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET o7_dictionary_accessibility=FALSE SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Run ALTER SYSTEM SET o7_dictionary_accessibility=FALSE SCOPE=SPFILE\n2. Restart the database\n3. Grant SELECT_CATALOG_ROLE or specific dictionary views to users who genuinely need dictionary access", + "Terraform": "" + }, + "Recommendation": { + "Text": "Keep O7_DICTIONARY_ACCESSIBILITY at its default FALSE and give dictionary access through SELECT_CATALOG_ROLE or explicit view grants instead.", + "Url": "https://hub.prowler.com/check/configuration_dictionary_accessibility_disabled" + } + }, + "Categories": [ + "trust-boundaries", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled.py b/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled.py new file mode 100644 index 0000000000..fccfd58592 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.configuration.configuration_client import ( + configuration_client, +) + + +class configuration_dictionary_accessibility_disabled(Check): + """Check that access to data dictionary objects is restricted.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database, or none when the + initialization parameters could not be read. + """ + findings = [] + if not configuration_client.parameters: + return findings + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name="o7_dictionary_accessibility", + resource_id=f"{configuration_client.database_name}/o7_dictionary_accessibility", + ) + value = ( + configuration_client.parameters.get("o7_dictionary_accessibility") or "" + ).upper() + if value != "TRUE": + report.status = "PASS" + report.status_extended = f"Database {configuration_client.database_name} restricts access to data dictionary objects (O7_DICTIONARY_ACCESSIBILITY=FALSE)." + else: + report.status = "FAIL" + report.status_extended = f"Database {configuration_client.database_name} allows ANY-style privileges on data dictionary objects (O7_DICTIONARY_ACCESSIBILITY=TRUE)." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/__init__.py b/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure.metadata.json b/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure.metadata.json new file mode 100644 index 0000000000..0d90b9eb2d --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "configuration_remote_login_passwordfile_secure", + "CheckTitle": "The password file is not shared between databases", + "CheckType": [], + "ServiceName": "configuration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabaseParameter", + "ResourceGroup": "IAM", + "Description": "Checks that **REMOTE_LOGIN_PASSWORDFILE** is EXCLUSIVE (or NONE), so the password file that stores SYSDBA/SYSOPER credentials is dedicated to this database and not SHARED (DBSAT finding CONF.PASSWORDFILE).", + "Risk": "A SHARED password file is reused by several databases and only supports SYS, encouraging shared administrative credentials: one compromised database exposes administrative access to all of them.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/REMOTE_LOGIN_PASSWORDFILE.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET remote_login_passwordfile=EXCLUSIVE SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Run ALTER SYSTEM SET remote_login_passwordfile=EXCLUSIVE SCOPE=SPFILE\n2. Restart the database\n3. Recreate the password file with orapwd if it was shared, granting administrative privileges per named user", + "Terraform": "" + }, + "Recommendation": { + "Text": "Use an EXCLUSIVE password file (or NONE to disable remote administrative logins entirely) so administrative credentials are per-database and per-user.", + "Url": "https://hub.prowler.com/check/configuration_remote_login_passwordfile_secure" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure.py b/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure.py new file mode 100644 index 0000000000..97215909d8 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.configuration.configuration_client import ( + configuration_client, +) + + +class configuration_remote_login_passwordfile_secure(Check): + """Check that the password file is not shared between databases.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database, or none when the + initialization parameters could not be read. + """ + findings = [] + if not configuration_client.parameters: + return findings + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name="remote_login_passwordfile", + resource_id=f"{configuration_client.database_name}/remote_login_passwordfile", + ) + value = ( + configuration_client.parameters.get("remote_login_passwordfile") or "" + ).upper() + if value in ("EXCLUSIVE", "NONE"): + report.status = "PASS" + report.status_extended = f"Database {configuration_client.database_name} uses a dedicated password file (REMOTE_LOGIN_PASSWORDFILE={value})." + else: + report.status = "FAIL" + report.status_extended = f"Database {configuration_client.database_name} shares its password file with other databases (REMOTE_LOGIN_PASSWORDFILE={value})." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/__init__.py b/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled.metadata.json b/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled.metadata.json new file mode 100644 index 0000000000..8d16a06815 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "configuration_remote_os_authentication_disabled", + "CheckTitle": "Remote operating system authentication is disabled", + "CheckType": [], + "ServiceName": "configuration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "OracleDatabaseParameter", + "ResourceGroup": "IAM", + "Description": "Checks that **REMOTE_OS_AUTHENT** is FALSE (or absent — the parameter was desupported in 12c) so the database never trusts the client machine's OS username for authentication (DBSAT finding CONF.EXTERNALOSAUTH).", + "Risk": "With remote OS authentication enabled, any attacker who controls a client machine can create a local OS user matching an externally identified database account and log in without a password.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/REMOTE_OS_AUTHENT.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET remote_os_authent=FALSE SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Run ALTER SYSTEM SET remote_os_authent=FALSE SCOPE=SPFILE\n2. Restart the database\n3. Convert IDENTIFIED EXTERNALLY accounts that relied on it to password, Kerberos or certificate authentication", + "Terraform": "" + }, + "Recommendation": { + "Text": "Keep REMOTE_OS_AUTHENT disabled and use strong, centrally managed authentication (passwords with profiles, Kerberos or TLS certificates) for remote users.", + "Url": "https://hub.prowler.com/check/configuration_remote_os_authentication_disabled" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled.py b/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled.py new file mode 100644 index 0000000000..839b43b79e --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.configuration.configuration_client import ( + configuration_client, +) + + +class configuration_remote_os_authentication_disabled(Check): + """Check that remote operating system authentication is disabled.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database, or none when the + initialization parameters could not be read. + """ + findings = [] + if not configuration_client.parameters: + return findings + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name="remote_os_authent", + resource_id=f"{configuration_client.database_name}/remote_os_authent", + ) + value = (configuration_client.parameters.get("remote_os_authent") or "").upper() + if value != "TRUE": + report.status = "PASS" + report.status_extended = f"Database {configuration_client.database_name} does not trust client operating system authentication (REMOTE_OS_AUTHENT=FALSE)." + else: + report.status = "FAIL" + report.status_extended = f"Database {configuration_client.database_name} trusts client operating system authentication (REMOTE_OS_AUTHENT=TRUE)." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/__init__.py b/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled.metadata.json b/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled.metadata.json new file mode 100644 index 0000000000..742059502b --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "configuration_remote_os_roles_disabled", + "CheckTitle": "Remote operating system role management is disabled", + "CheckType": [], + "ServiceName": "configuration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabaseParameter", + "ResourceGroup": "IAM", + "Description": "Checks that **REMOTE_OS_ROLES** is FALSE so database roles are never enabled based on the operating system groups of a remote client (DBSAT finding CONF.EXTERNALOSAUTH).", + "Risk": "With remote OS roles enabled, an attacker controlling a client machine can grant themselves OS groups that map to powerful database roles, escalating privileges without any database-side change.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/REMOTE_OS_ROLES.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET remote_os_roles=FALSE SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Run ALTER SYSTEM SET remote_os_roles=FALSE SCOPE=SPFILE\n2. Restart the database\n3. Manage roles inside the database (GRANT/SET ROLE) or through a central directory instead", + "Terraform": "" + }, + "Recommendation": { + "Text": "Keep REMOTE_OS_ROLES at its default FALSE and manage role grants inside the database or via enterprise user security.", + "Url": "https://hub.prowler.com/check/configuration_remote_os_roles_disabled" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled.py b/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled.py new file mode 100644 index 0000000000..5b76d5bfee --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.configuration.configuration_client import ( + configuration_client, +) + + +class configuration_remote_os_roles_disabled(Check): + """Check that remote operating system role management is disabled.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database, or none when the + initialization parameters could not be read. + """ + findings = [] + if not configuration_client.parameters: + return findings + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name="remote_os_roles", + resource_id=f"{configuration_client.database_name}/remote_os_roles", + ) + value = (configuration_client.parameters.get("remote_os_roles") or "").upper() + if value != "TRUE": + report.status = "PASS" + report.status_extended = f"Database {configuration_client.database_name} does not enable roles based on remote client operating system groups (REMOTE_OS_ROLES=FALSE)." + else: + report.status = "FAIL" + report.status_extended = f"Database {configuration_client.database_name} enables roles based on remote client operating system groups (REMOTE_OS_ROLES=TRUE)." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/__init__.py b/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled.metadata.json b/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled.metadata.json new file mode 100644 index 0000000000..95e31617f4 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "configuration_sec_return_server_release_banner_disabled", + "CheckTitle": "The server release banner is not returned to clients before authentication", + "CheckType": [], + "ServiceName": "configuration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "OracleDatabaseParameter", + "ResourceGroup": "database", + "Description": "Checks that **SEC_RETURN_SERVER_RELEASE_BANNER** is FALSE so the database does not disclose its full release information to unauthenticated clients (DBSAT finding CONF.NETWORK).", + "Risk": "The full release banner tells attackers the exact patch level of the database, letting them pick known CVEs and exploits that match the unpatched version.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/SEC_RETURN_SERVER_RELEASE_BANNER.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET sec_return_server_release_banner=FALSE SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Run ALTER SYSTEM SET sec_return_server_release_banner=FALSE SCOPE=SPFILE\n2. Restart the database", + "Terraform": "" + }, + "Recommendation": { + "Text": "Keep SEC_RETURN_SERVER_RELEASE_BANNER at its default FALSE so only a generic version is disclosed before authentication.", + "Url": "https://hub.prowler.com/check/configuration_sec_return_server_release_banner_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled.py b/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled.py new file mode 100644 index 0000000000..b22945ec19 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled.py @@ -0,0 +1,37 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.configuration.configuration_client import ( + configuration_client, +) + + +class configuration_sec_return_server_release_banner_disabled(Check): + """Check that the server release banner is not returned to clients.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database, or none when the + initialization parameters could not be read. + """ + findings = [] + if not configuration_client.parameters: + return findings + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name="sec_return_server_release_banner", + resource_id=f"{configuration_client.database_name}/sec_return_server_release_banner", + ) + value = ( + configuration_client.parameters.get("sec_return_server_release_banner") + or "" + ).upper() + if value != "TRUE": + report.status = "PASS" + report.status_extended = f"Database {configuration_client.database_name} does not return the full release banner to unauthenticated clients (SEC_RETURN_SERVER_RELEASE_BANNER=FALSE)." + else: + report.status = "FAIL" + report.status_extended = f"Database {configuration_client.database_name} returns the full release banner to unauthenticated clients (SEC_RETURN_SERVER_RELEASE_BANNER=TRUE)." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/configuration/configuration_service.py b/prowler/providers/oracledb/services/configuration/configuration_service.py new file mode 100644 index 0000000000..e5c2465598 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_service.py @@ -0,0 +1,49 @@ +from prowler.lib.logger import logger +from prowler.providers.oracledb.lib.service.service import OracledbService +from prowler.providers.oracledb.oracledb_provider import OracledbProvider + +# Security-relevant initialization parameters evaluated by the configuration +# checks (DBSAT CONF.* findings). +SECURITY_PARAMETERS = ( + "o7_dictionary_accessibility", + "sql92_security", + "remote_os_authent", + "remote_os_roles", + "remote_login_passwordfile", + "sec_return_server_release_banner", +) + + +class Configuration(OracledbService): + """Oracle Database configuration service. + + Reads security-relevant initialization parameters from V$PARAMETER, + mirroring the database configuration findings of the Oracle Database + Security Assessment Tool (DBSAT). + """ + + def __init__(self, provider: OracledbProvider): + super().__init__(__class__.__name__, provider) + self.parameters = self._get_parameters() + + def _get_parameters(self) -> dict: + """Read the security-relevant initialization parameters. + + Returns: + dict: {parameter_name: value}. Parameters removed in newer + releases (e.g. REMOTE_OS_AUTHENT was desupported in 12c) are + simply absent; checks treat absence as the secure default. + """ + logger.info("Configuration - Reading initialization parameters...") + parameters = {} + try: + in_list = ", ".join(f"'{parameter}'" for parameter in SECURITY_PARAMETERS) + rows = self._execute_query( + f"SELECT name, value FROM v$parameter WHERE name IN ({in_list})" + ) + parameters = {name: value for name, value in rows} + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return parameters diff --git a/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/__init__.py b/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled.metadata.json b/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled.metadata.json new file mode 100644 index 0000000000..7f7f4568d4 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "configuration_sql92_security_enabled", + "CheckTitle": "SQL92 security is enabled for UPDATE and DELETE statements", + "CheckType": [], + "ServiceName": "configuration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabaseParameter", + "ResourceGroup": "database", + "Description": "Checks that **SQL92_SECURITY** is TRUE so UPDATE and DELETE statements whose WHERE clause reads a column also require SELECT privilege on that column (DBSAT finding CONF.SQL92SECURITY).", + "Risk": "With SQL92_SECURITY disabled, a user with only UPDATE or DELETE privilege can infer column values they cannot SELECT by observing which rows a conditional statement affects — a data inference attack.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/refrn/SQL92_SECURITY.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER SYSTEM SET sql92_security=TRUE SCOPE=SPFILE;", + "NativeIaC": "", + "Other": "1. Run ALTER SYSTEM SET sql92_security=TRUE SCOPE=SPFILE\n2. Restart the database\n3. Verify applications that rely on UPDATE/DELETE without SELECT still hold the required privileges", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set SQL92_SECURITY to TRUE (the default since Oracle 12.2) so conditional UPDATE and DELETE statements cannot be used to infer protected data.", + "Url": "https://hub.prowler.com/check/configuration_sql92_security_enabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled.py b/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled.py new file mode 100644 index 0000000000..5e65dfce82 --- /dev/null +++ b/prowler/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.configuration.configuration_client import ( + configuration_client, +) + + +class configuration_sql92_security_enabled(Check): + """Check that SQL92 security is enabled for UPDATE/DELETE statements.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database, or none when the + initialization parameters could not be read. + """ + findings = [] + if not configuration_client.parameters: + return findings + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name="sql92_security", + resource_id=f"{configuration_client.database_name}/sql92_security", + ) + value = (configuration_client.parameters.get("sql92_security") or "").upper() + if value == "TRUE": + report.status = "PASS" + report.status_extended = f"Database {configuration_client.database_name} requires SELECT privilege on columns referenced by conditional UPDATE/DELETE statements (SQL92_SECURITY=TRUE)." + else: + report.status = "FAIL" + report.status_extended = f"Database {configuration_client.database_name} allows data inference through conditional UPDATE/DELETE statements (SQL92_SECURITY=FALSE)." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/encryption/__init__.py b/prowler/providers/oracledb/services/encryption/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/encryption/encryption_client.py b/prowler/providers/oracledb/services/encryption/encryption_client.py new file mode 100644 index 0000000000..54045b40e2 --- /dev/null +++ b/prowler/providers/oracledb/services/encryption/encryption_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.oracledb.services.encryption.encryption_service import ( + Encryption, +) + +encryption_client = Encryption(Provider.get_global_provider()) diff --git a/prowler/providers/oracledb/services/encryption/encryption_service.py b/prowler/providers/oracledb/services/encryption/encryption_service.py new file mode 100644 index 0000000000..3ad9beebc5 --- /dev/null +++ b/prowler/providers/oracledb/services/encryption/encryption_service.py @@ -0,0 +1,94 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.oracledb.lib.service.service import OracledbService +from prowler.providers.oracledb.oracledb_provider import OracledbProvider + +# Oracle-managed tablespaces excluded from the TDE tablespace check: they hold +# the data dictionary or transient data and are reported separately by DBSAT. +SYSTEM_TABLESPACES = frozenset({"SYSTEM", "SYSAUX"}) + + +class Encryption(OracledbService): + """Oracle Database encryption service. + + Reads V$ENCRYPTION_WALLET and DBA_TABLESPACES to evaluate Transparent + Data Encryption (TDE) posture, mirroring the encryption findings of the + Oracle Database Security Assessment Tool (DBSAT). + """ + + def __init__(self, provider: OracledbProvider): + super().__init__(__class__.__name__, provider) + self.wallets = self._list_wallets() + self.tablespaces = self._list_tablespaces() + + def _list_wallets(self) -> list["Wallet"]: + """List the TDE keystore(s) configured for the database.""" + logger.info("Encryption - Listing TDE keystores...") + wallets = [] + try: + rows = self._execute_query( + "SELECT wrl_type, wrl_parameter, status, wallet_type " + "FROM v$encryption_wallet" + ) + for wrl_type, wrl_parameter, status, wallet_type in rows: + wallets.append( + Wallet( + wrl_type=wrl_type or "", + wrl_parameter=wrl_parameter or "", + status=status or "", + wallet_type=wallet_type or "", + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return wallets + + def _list_tablespaces(self) -> list["Tablespace"]: + """List permanent tablespaces and their encryption status.""" + logger.info("Encryption - Listing tablespaces...") + tablespaces = [] + try: + rows = self._execute_query( + "SELECT tablespace_name, encrypted, contents FROM dba_tablespaces" + ) + for name, encrypted, contents in rows: + tablespaces.append( + Tablespace( + name=name, + encrypted=encrypted == "YES", + contents=contents or "", + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + logger.info(f"Found {len(tablespaces)} tablespaces") + return tablespaces + + +class Wallet(BaseModel): + """Oracle Database TDE keystore model (V$ENCRYPTION_WALLET row).""" + + wrl_type: str = "" + wrl_parameter: str = "" + status: str = "" + wallet_type: str = "" + + +class Tablespace(BaseModel): + """Oracle Database tablespace model.""" + + name: str + encrypted: bool = False + contents: str = "" + + @property + def is_user_permanent(self) -> bool: + """True for permanent, non-Oracle-managed tablespaces — the ones the + TDE check evaluates. Temporary and undo tablespaces hold transient + data and follow the encryption of the data they stage.""" + return self.contents == "PERMANENT" and self.name not in SYSTEM_TABLESPACES diff --git a/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/__init__.py b/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted.metadata.json b/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted.metadata.json new file mode 100644 index 0000000000..4af7c1df7d --- /dev/null +++ b/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "encryption_tablespaces_encrypted", + "CheckTitle": "Permanent tablespaces are encrypted with TDE", + "CheckType": [], + "ServiceName": "encryption", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabaseTablespace", + "ResourceGroup": "storage", + "Description": "Checks that each permanent application tablespace is encrypted with **Transparent Data Encryption (TDE)**. SYSTEM and SYSAUX are excluded and reported separately by DBSAT (finding ENCRYPT.TDE).", + "Risk": "Unencrypted tablespaces expose their data to anyone who can read the underlying files: OS administrators, stolen backups or disks, and direct data file parsing all bypass database authentication entirely.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/asoag/encrypting-tablespaces.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "CREATE TABLESPACE DATAFILE ... ENCRYPTION USING 'AES256' DEFAULT STORAGE (ENCRYPT);", + "NativeIaC": "", + "Other": "1. Configure and open the TDE keystore\n2. Encrypt existing tablespaces online: ALTER TABLESPACE ENCRYPTION ONLINE USING 'AES256' ENCRYPT\n3. Create new tablespaces encrypted by default (ENCRYPT_NEW_TABLESPACES=ALWAYS)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Encrypt all permanent tablespaces with TDE, use online conversion for existing ones and set ENCRYPT_NEW_TABLESPACES so future tablespaces are encrypted automatically.", + "Url": "https://hub.prowler.com/check/encryption_tablespaces_encrypted" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted.py b/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted.py new file mode 100644 index 0000000000..cd529ebf36 --- /dev/null +++ b/prowler/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted.py @@ -0,0 +1,37 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.encryption.encryption_client import ( + encryption_client, +) + + +class encryption_tablespaces_encrypted(Check): + """Check that permanent tablespaces are encrypted with TDE.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A list of reports, one per permanent application tablespace. + """ + findings = [] + for tablespace in encryption_client.tablespaces: + if not tablespace.is_user_permanent: + continue + report = CheckReportOracledb( + metadata=self.metadata(), + resource=tablespace, + resource_name=tablespace.name, + resource_id=tablespace.name, + ) + if tablespace.encrypted: + report.status = "PASS" + report.status_extended = ( + f"Tablespace {tablespace.name} is encrypted with TDE." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Tablespace {tablespace.name} is not encrypted with TDE." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/__init__.py b/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured.metadata.json b/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured.metadata.json new file mode 100644 index 0000000000..41b715f321 --- /dev/null +++ b/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "encryption_wallet_configured", + "CheckTitle": "A TDE keystore is configured and open", + "CheckType": [], + "ServiceName": "encryption", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabase", + "ResourceGroup": "security", + "Description": "Checks that a Transparent Data Encryption (TDE) keystore (wallet) is configured and **OPEN**, so the database can encrypt tablespaces and columns (DBSAT finding ENCRYPT.WALLET).", + "Risk": "Without a configured TDE keystore no data-at-rest encryption is possible: stolen data files, backups or disks can be read directly with standard tools, bypassing every access control in the database.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/asoag/configuring-transparent-data-encryption.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ADMINISTER KEY MANAGEMENT CREATE KEYSTORE IDENTIFIED BY ;", + "NativeIaC": "", + "Other": "1. Set WALLET_ROOT and TDE_CONFIGURATION initialization parameters\n2. Create the keystore: ADMINISTER KEY MANAGEMENT CREATE KEYSTORE IDENTIFIED BY \n3. Open it and set a master key: ADMINISTER KEY MANAGEMENT SET KEY IDENTIFIED BY WITH BACKUP\n4. Consider an auto-login keystore for unattended restarts", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a TDE keystore, set a master encryption key and keep the keystore open (auto-login where appropriate) so tablespaces and backups can be encrypted.", + "Url": "https://hub.prowler.com/check/encryption_wallet_configured" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured.py b/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured.py new file mode 100644 index 0000000000..9d111df809 --- /dev/null +++ b/prowler/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured.py @@ -0,0 +1,46 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.encryption.encryption_client import ( + encryption_client, +) + + +class encryption_wallet_configured(Check): + """Check that a TDE keystore is configured and open.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=encryption_client.database_name, + resource_id=encryption_client.database_name, + ) + open_wallets = [ + wallet for wallet in encryption_client.wallets if wallet.status == "OPEN" + ] + if open_wallets: + report.status = "PASS" + report.status_extended = ( + f"Database {encryption_client.database_name} has an open TDE " + f"keystore ({open_wallets[0].wrl_type}, " + f"{open_wallets[0].wallet_type})." + ) + else: + statuses = ( + ", ".join( + sorted({wallet.status for wallet in encryption_client.wallets}) + ) + or "NOT_AVAILABLE" + ) + report.status = "FAIL" + report.status_extended = ( + f"Database {encryption_client.database_name} does not have an " + f"open TDE keystore (status: {statuses})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/privileges/__init__.py b/prowler/providers/oracledb/services/privileges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/privileges/privileges_client.py b/prowler/providers/oracledb/services/privileges/privileges_client.py new file mode 100644 index 0000000000..349954821c --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.oracledb.services.privileges.privileges_service import ( + Privileges, +) + +privileges_client = Privileges(Provider.get_global_provider()) diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/__init__.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages.metadata.json b/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages.metadata.json new file mode 100644 index 0000000000..7332306b94 --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "privileges_public_no_encryption_packages", + "CheckTitle": "Encryption PL/SQL packages are not executable by PUBLIC", + "CheckType": [], + "ServiceName": "privileges", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabase", + "ResourceGroup": "security", + "Description": "Checks that encryption PL/SQL packages (**DBMS_CRYPTO, DBMS_OBFUSCATION_TOOLKIT, DBMS_RANDOM**) are not granted EXECUTE to PUBLIC (DBSAT finding PRIV.ENCRYPTPACKAGEPUBLIC).", + "Risk": "Broad access to encryption primitives lets attackers encrypt stolen data to defeat inspection, build ransomware-style attacks inside the database, or misuse weak random generation in security-relevant code.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/managing-security-for-application-developers.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "REVOKE EXECUTE ON DBMS_CRYPTO FROM PUBLIC;", + "NativeIaC": "", + "Other": "1. List PUBLIC execute grants on the encryption packages in DBA_TAB_PRIVS\n2. Revoke each: REVOKE EXECUTE ON FROM PUBLIC\n3. Grant EXECUTE only to the schemas whose code performs cryptographic operations", + "Terraform": "" + }, + "Recommendation": { + "Text": "Revoke PUBLIC execute on the encryption packages and grant them only to the application schemas that legitimately perform cryptography.", + "Url": "https://hub.prowler.com/check/privileges_public_no_encryption_packages" + } + }, + "Categories": [ + "encryption", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages.py new file mode 100644 index 0000000000..007e271881 --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.privileges.privileges_client import ( + privileges_client, +) +from prowler.providers.oracledb.services.privileges.privileges_service import ( + ENCRYPTION_PACKAGES, +) + + +class privileges_public_no_encryption_packages(Check): + """Check that encryption PL/SQL packages are not executable by PUBLIC.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=privileges_client.database_name, + resource_id=privileges_client.database_name, + ) + exposed_packages = sorted( + set(privileges_client.public_execute_packages) & ENCRYPTION_PACKAGES + ) + if exposed_packages: + report.status = "FAIL" + report.status_extended = ( + f"Database {privileges_client.database_name} grants PUBLIC " + f"execute on encryption packages: " + f"{', '.join(exposed_packages)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Database {privileges_client.database_name} does not grant " + f"PUBLIC execute on any encryption package." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/__init__.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages.metadata.json b/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages.metadata.json new file mode 100644 index 0000000000..ace5e1d43a --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "privileges_public_no_file_packages", + "CheckTitle": "File system PL/SQL packages are not executable by PUBLIC", + "CheckType": [], + "ServiceName": "privileges", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabase", + "ResourceGroup": "storage", + "Description": "Checks that file system PL/SQL packages (**UTL_FILE, DBMS_LOB, DBMS_ADVISOR**) are not granted EXECUTE to PUBLIC, limiting who can read and write files on the database server (DBSAT finding PRIV.FILESYSTEMPACKAGEPUBLIC).", + "Risk": "Any user able to run these packages can read or overwrite files accessible through directory objects — including data files, scripts and wallets — leading to data theft, tampering or code execution on the server.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/managing-security-for-application-developers.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "REVOKE EXECUTE ON UTL_FILE FROM PUBLIC;", + "NativeIaC": "", + "Other": "1. List PUBLIC execute grants on the file packages in DBA_TAB_PRIVS\n2. Revoke each: REVOKE EXECUTE ON FROM PUBLIC\n3. Grant EXECUTE only to the schemas that need it and restrict directory objects to the minimum paths required", + "Terraform": "" + }, + "Recommendation": { + "Text": "Revoke PUBLIC execute on the file system packages and control file access through per-user grants and tightly scoped directory objects.", + "Url": "https://hub.prowler.com/check/privileges_public_no_file_packages" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages.py new file mode 100644 index 0000000000..d4a93a14a9 --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.privileges.privileges_client import ( + privileges_client, +) +from prowler.providers.oracledb.services.privileges.privileges_service import ( + FILE_PACKAGES, +) + + +class privileges_public_no_file_packages(Check): + """Check that file system PL/SQL packages are not executable by PUBLIC.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=privileges_client.database_name, + resource_id=privileges_client.database_name, + ) + exposed_packages = sorted( + set(privileges_client.public_execute_packages) & FILE_PACKAGES + ) + if exposed_packages: + report.status = "FAIL" + report.status_extended = ( + f"Database {privileges_client.database_name} grants PUBLIC " + f"execute on file system packages: " + f"{', '.join(exposed_packages)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Database {privileges_client.database_name} does not grant " + f"PUBLIC execute on any file system package." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/__init__.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages.metadata.json b/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages.metadata.json new file mode 100644 index 0000000000..e5969f86c6 --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "privileges_public_no_network_packages", + "CheckTitle": "Network PL/SQL packages are not executable by PUBLIC", + "CheckType": [], + "ServiceName": "privileges", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabase", + "ResourceGroup": "network", + "Description": "Checks that network PL/SQL packages (**UTL_HTTP, UTL_TCP, UTL_SMTP, UTL_MAIL, UTL_INADDR, DBMS_LDAP**) are not granted EXECUTE to PUBLIC, limiting who can open network connections from inside the database (DBSAT finding PRIV.NETPACKAGEPUBLIC).", + "Risk": "Any user able to run these packages can exfiltrate data over HTTP/TCP/SMTP, scan internal networks from the database host and reach services behind the database's network position.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/managing-fine-grained-access-in-pl-sql-packages-and-types.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "REVOKE EXECUTE ON UTL_HTTP FROM PUBLIC;", + "NativeIaC": "", + "Other": "1. List PUBLIC execute grants on the network packages in DBA_TAB_PRIVS\n2. Revoke each: REVOKE EXECUTE ON FROM PUBLIC\n3. Grant EXECUTE to the specific application schemas that need it and define network ACLs (DBMS_NETWORK_ACL_ADMIN) for those users", + "Terraform": "" + }, + "Recommendation": { + "Text": "Revoke PUBLIC execute on the network packages and combine per-user grants with fine-grained network ACLs so only authorized code can open outbound connections.", + "Url": "https://hub.prowler.com/check/privileges_public_no_network_packages" + } + }, + "Categories": [ + "trust-boundaries", + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages.py new file mode 100644 index 0000000000..c6f9910e45 --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.privileges.privileges_client import ( + privileges_client, +) +from prowler.providers.oracledb.services.privileges.privileges_service import ( + NETWORK_PACKAGES, +) + + +class privileges_public_no_network_packages(Check): + """Check that network PL/SQL packages are not executable by PUBLIC.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=privileges_client.database_name, + resource_id=privileges_client.database_name, + ) + exposed_packages = sorted( + set(privileges_client.public_execute_packages) & NETWORK_PACKAGES + ) + if exposed_packages: + report.status = "FAIL" + report.status_extended = ( + f"Database {privileges_client.database_name} grants PUBLIC " + f"execute on network packages: " + f"{', '.join(exposed_packages)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Database {privileges_client.database_name} does not grant " + f"PUBLIC execute on any network package." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/__init__.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges.metadata.json b/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges.metadata.json new file mode 100644 index 0000000000..91d300ad65 --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "oracledb", + "CheckID": "privileges_public_no_system_privileges", + "CheckTitle": "No system privileges are granted to PUBLIC", + "CheckType": [], + "ServiceName": "privileges", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabase", + "ResourceGroup": "IAM", + "Description": "Checks that no system privileges are granted to the **PUBLIC** role. Anything granted to PUBLIC is available to every database user, so system privileges must never be granted to it (DBSAT finding PRIV.SYSPUBLIC).", + "Risk": "A system privilege granted to PUBLIC is effectively granted to every current and future user, including compromised low-privilege accounts, enabling privilege escalation and broad unauthorized access.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-privilege-and-role-authorization.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "REVOKE FROM PUBLIC;", + "NativeIaC": "", + "Other": "1. List the grants: SELECT privilege FROM dba_sys_privs WHERE grantee = 'PUBLIC'\n2. Revoke each one: REVOKE FROM PUBLIC\n3. Grant the privilege to the specific users or roles that need it", + "Terraform": "" + }, + "Recommendation": { + "Text": "Revoke all system privileges from PUBLIC and grant them only to the specific users or roles that require them.", + "Url": "https://hub.prowler.com/check/privileges_public_no_system_privileges" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges.py b/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges.py new file mode 100644 index 0000000000..e3882e5683 --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges.py @@ -0,0 +1,37 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.privileges.privileges_client import ( + privileges_client, +) + + +class privileges_public_no_system_privileges(Check): + """Check that no system privileges are granted to PUBLIC.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=privileges_client.database_name, + resource_id=privileges_client.database_name, + ) + public_privileges = privileges_client.public_system_privileges + if public_privileges: + report.status = "FAIL" + report.status_extended = ( + f"Database {privileges_client.database_name} grants system " + f"privileges to PUBLIC: {', '.join(public_privileges)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Database {privileges_client.database_name} does not grant " + "any system privilege to PUBLIC." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/privileges/privileges_service.py b/prowler/providers/oracledb/services/privileges/privileges_service.py new file mode 100644 index 0000000000..f5f4d78b3f --- /dev/null +++ b/prowler/providers/oracledb/services/privileges/privileges_service.py @@ -0,0 +1,65 @@ +from prowler.lib.logger import logger +from prowler.providers.oracledb.lib.service.service import OracledbService +from prowler.providers.oracledb.oracledb_provider import OracledbProvider + +# SYS-owned PL/SQL packages that DBSAT flags when they are executable by +# PUBLIC, grouped by the DBSAT finding they belong to. +NETWORK_PACKAGES = frozenset( + {"UTL_HTTP", "UTL_TCP", "UTL_SMTP", "UTL_MAIL", "UTL_INADDR", "DBMS_LDAP"} +) # PRIV.NETPACKAGEPUBLIC +FILE_PACKAGES = frozenset( + {"UTL_FILE", "DBMS_LOB", "DBMS_ADVISOR"} +) # PRIV.FILESYSTEMPACKAGEPUBLIC +ENCRYPTION_PACKAGES = frozenset( + {"DBMS_CRYPTO", "DBMS_OBFUSCATION_TOOLKIT", "DBMS_RANDOM"} +) # PRIV.ENCRYPTPACKAGEPUBLIC + + +class Privileges(OracledbService): + """Oracle Database privileges service. + + Reads DBA_SYS_PRIVS and DBA_TAB_PRIVS to surface privileges granted to + the PUBLIC role, mirroring the privileges and roles findings of the + Oracle Database Security Assessment Tool (DBSAT). + """ + + def __init__(self, provider: OracledbProvider): + super().__init__(__class__.__name__, provider) + self.public_system_privileges = self._list_public_system_privileges() + self.public_execute_packages = self._list_public_execute_packages() + + def _list_public_system_privileges(self) -> list[str]: + """List system privileges granted to PUBLIC (DBSAT PRIV.SYSPUBLIC).""" + logger.info("Privileges - Listing system privileges granted to PUBLIC...") + privileges = [] + try: + rows = self._execute_query( + "SELECT privilege FROM dba_sys_privs WHERE grantee = 'PUBLIC'" + ) + privileges = sorted(row[0] for row in rows) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return privileges + + def _list_public_execute_packages(self) -> list[str]: + """List SYS-owned packages executable by PUBLIC. + + The checks intersect this list with the DBSAT package groups + (network, file system, encryption) defined above. + """ + logger.info("Privileges - Listing SYS packages executable by PUBLIC...") + packages = [] + try: + rows = self._execute_query( + "SELECT table_name FROM dba_tab_privs " + "WHERE grantee = 'PUBLIC' AND privilege = 'EXECUTE' " + "AND owner = 'SYS'" + ) + packages = sorted(row[0] for row in rows) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return packages diff --git a/prowler/providers/oracledb/services/users/__init__.py b/prowler/providers/oracledb/services/users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/users/users_client.py b/prowler/providers/oracledb/services/users/users_client.py new file mode 100644 index 0000000000..3d0363c2b7 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.oracledb.services.users.users_service import Users + +users_client = Users(Provider.get_global_provider()) diff --git a/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/__init__.py b/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system.metadata.json b/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system.metadata.json new file mode 100644 index 0000000000..d6757fbd1d --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "users_default_tablespace_not_system", + "CheckTitle": "Application users do not use SYSTEM or SYSAUX as their default tablespace", + "CheckType": [], + "ServiceName": "users", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabaseUser", + "ResourceGroup": "database", + "Description": "Checks that non-Oracle-maintained database users do not have **SYSTEM** or **SYSAUX** as their default tablespace, keeping user data out of the tablespaces reserved for the data dictionary (DBSAT finding USER.TABLESPACE).", + "Risk": "User objects in SYSTEM or SYSAUX can exhaust space needed by the data dictionary, causing database-wide outages, and blur the boundary between application data and dictionary objects that stricter protections rely on.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/admin/managing-tablespaces.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER USER DEFAULT TABLESPACE users;", + "NativeIaC": "", + "Other": "1. Create or choose an application tablespace\n2. Run ALTER USER DEFAULT TABLESPACE \n3. Move any existing objects out of SYSTEM/SYSAUX with ALTER TABLE ... MOVE / ALTER INDEX ... REBUILD", + "Terraform": "" + }, + "Recommendation": { + "Text": "Give every application user a dedicated default tablespace and move existing user objects out of SYSTEM and SYSAUX.", + "Url": "https://hub.prowler.com/check/users_default_tablespace_not_system" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system.py b/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system.py new file mode 100644 index 0000000000..a3a7d0cc82 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.users.users_client import users_client + +RESERVED_TABLESPACES = ("SYSTEM", "SYSAUX") + + +class users_default_tablespace_not_system(Check): + """Check that application users do not default to SYSTEM or SYSAUX.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A list of reports, one per non-Oracle-maintained user. + """ + findings = [] + for user in users_client.users: + if user.oracle_maintained: + continue + report = CheckReportOracledb( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.name, + ) + if user.default_tablespace in RESERVED_TABLESPACES: + report.status = "FAIL" + report.status_extended = ( + f"User {user.name} uses reserved tablespace " + f"{user.default_tablespace} as its default tablespace." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"User {user.name} uses tablespace " + f"{user.default_tablespace} as its default tablespace." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/__init__.py b/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited.metadata.json b/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited.metadata.json new file mode 100644 index 0000000000..8e2d1bbb73 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "users_failed_login_attempts_limited", + "CheckTitle": "Database users lock after a limited number of failed login attempts", + "CheckType": [], + "ServiceName": "users", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabaseUser", + "ResourceGroup": "IAM", + "Description": "Checks that each non-Oracle-maintained database user has a profile with a limited **FAILED_LOGIN_ATTEMPTS**, so accounts lock after repeated authentication failures (DBSAT finding USER.NOLOCK).", + "Risk": "Without an account lockout threshold, attackers can brute-force passwords online without limit. A single weak password then exposes the account and every privilege granted to it.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-authentication.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER PROFILE LIMIT FAILED_LOGIN_ATTEMPTS 10;", + "NativeIaC": "", + "Other": "1. Identify the profile of each failing user in DBA_USERS\n2. Set a lockout threshold: ALTER PROFILE LIMIT FAILED_LOGIN_ATTEMPTS 10\n3. Optionally set PASSWORD_LOCK_TIME to control how long the account stays locked", + "Terraform": "" + }, + "Recommendation": { + "Text": "Assign every user a profile with a finite FAILED_LOGIN_ATTEMPTS (Oracle's default profile uses 10) so brute-force attempts lock the account.", + "Url": "https://hub.prowler.com/check/users_failed_login_attempts_limited" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited.py b/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited.py new file mode 100644 index 0000000000..863a3c6ded --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.users.users_client import users_client + + +class users_failed_login_attempts_limited(Check): + """Check that database users lock after a limited number of failed login attempts.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A list of reports, one per non-Oracle-maintained user. + """ + findings = [] + for user in users_client.users: + if user.oracle_maintained: + continue + report = CheckReportOracledb( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.name, + ) + if user.failed_login_attempts == "UNLIMITED": + report.status = "FAIL" + report.status_extended = f"User {user.name} allows unlimited failed login attempts (profile {user.profile})." + else: + report.status = "PASS" + report.status_extended = f"User {user.name} locks after {user.failed_login_attempts} failed login attempts (profile {user.profile})." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/__init__.py b/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured.metadata.json b/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured.metadata.json new file mode 100644 index 0000000000..5b29ed6ed2 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "users_inactive_account_lock_configured", + "CheckTitle": "Database users are locked after a period of inactivity", + "CheckType": [], + "ServiceName": "users", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabaseUser", + "ResourceGroup": "IAM", + "Description": "Checks that each non-Oracle-maintained database user has a profile with a limited **INACTIVE_ACCOUNT_TIME**, so unused accounts are locked automatically (DBSAT finding USER.INACTIVE).", + "Risk": "Dormant accounts are rarely monitored, keep their old privileges and often belong to departed users. They are prime targets for takeover because misuse is unlikely to be noticed.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-authentication.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER PROFILE LIMIT INACTIVE_ACCOUNT_TIME 90;", + "NativeIaC": "", + "Other": "1. Identify the profile of each failing user in DBA_USERS\n2. Set an inactivity limit: ALTER PROFILE LIMIT INACTIVE_ACCOUNT_TIME 90\n3. Review and drop accounts that are no longer needed", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure INACTIVE_ACCOUNT_TIME in every profile so accounts that stay unused for the defined number of days are locked automatically.", + "Url": "https://hub.prowler.com/check/users_inactive_account_lock_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured.py b/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured.py new file mode 100644 index 0000000000..e78b60e7a1 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.users.users_client import users_client + + +class users_inactive_account_lock_configured(Check): + """Check that database users are locked after a period of inactivity.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A list of reports, one per non-Oracle-maintained user. + """ + findings = [] + for user in users_client.users: + if user.oracle_maintained: + continue + report = CheckReportOracledb( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.name, + ) + if user.inactive_account_time in (None, "UNLIMITED"): + report.status = "FAIL" + report.status_extended = f"User {user.name} is never locked for inactivity (profile {user.profile})." + else: + report.status = "PASS" + report.status_extended = f"User {user.name} is locked after {user.inactive_account_time} days of inactivity (profile {user.profile})." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/users/users_password_expiration_configured/__init__.py b/prowler/providers/oracledb/services/users/users_password_expiration_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured.metadata.json b/prowler/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured.metadata.json new file mode 100644 index 0000000000..1b8592371a --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "users_password_expiration_configured", + "CheckTitle": "Database users have a limited password lifetime", + "CheckType": [], + "ServiceName": "users", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabaseUser", + "ResourceGroup": "IAM", + "Description": "Checks that each non-Oracle-maintained database user has a profile with a limited **PASSWORD_LIFE_TIME**, so passwords expire and must be rotated (DBSAT finding USER.NOEXPIRE).", + "Risk": "Passwords that never expire stay valid indefinitely, so a leaked or cracked credential grants persistent access. Long-lived passwords also increase the window for offline cracking of stolen verifiers.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-authentication.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER PROFILE LIMIT PASSWORD_LIFE_TIME 90;", + "NativeIaC": "", + "Other": "1. Identify the profile of each failing user in DBA_USERS\n2. Set a finite password lifetime: ALTER PROFILE LIMIT PASSWORD_LIFE_TIME 90\n3. For service accounts, consider gradual password rollover (PASSWORD_ROLLOVER_TIME) to rotate without downtime", + "Terraform": "" + }, + "Recommendation": { + "Text": "Assign every user a profile with a finite PASSWORD_LIFE_TIME so passwords are rotated periodically, and use gradual password rollover for application accounts.", + "Url": "https://hub.prowler.com/check/users_password_expiration_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured.py b/prowler/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured.py new file mode 100644 index 0000000000..e711e14d94 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.users.users_client import users_client + + +class users_password_expiration_configured(Check): + """Check that database users have a limited password lifetime.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A list of reports, one per non-Oracle-maintained user. + """ + findings = [] + for user in users_client.users: + if user.oracle_maintained: + continue + report = CheckReportOracledb( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.name, + ) + if user.password_life_time == "UNLIMITED": + report.status = "FAIL" + report.status_extended = f"User {user.name} has an unlimited password lifetime (profile {user.profile})." + else: + report.status = "PASS" + report.status_extended = f"User {user.name} has a password lifetime of {user.password_life_time} days (profile {user.profile})." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/users/users_password_verify_function_configured/__init__.py b/prowler/providers/oracledb/services/users/users_password_verify_function_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured.metadata.json b/prowler/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured.metadata.json new file mode 100644 index 0000000000..40dba44833 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "users_password_verify_function_configured", + "CheckTitle": "Database users have a password complexity verification function", + "CheckType": [], + "ServiceName": "users", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OracleDatabaseUser", + "ResourceGroup": "IAM", + "Description": "Checks that each non-Oracle-maintained database user has a profile with a **PASSWORD_VERIFY_FUNCTION**, enforcing password complexity rules at password change time (DBSAT finding USER.PASSWORDFUNCTION).", + "Risk": "Without a verification function users can choose trivial passwords (short, dictionary words, the username itself), making online guessing and offline cracking of stolen verifiers far easier.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-authentication.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "ALTER PROFILE LIMIT PASSWORD_VERIFY_FUNCTION ora12c_verify_function;", + "NativeIaC": "", + "Other": "1. Identify the profile of each failing user in DBA_USERS\n2. Assign a verification function: ALTER PROFILE LIMIT PASSWORD_VERIFY_FUNCTION ora12c_verify_function\n3. Use ora12c_stig_verify_function or a custom function for stricter policies", + "Terraform": "" + }, + "Recommendation": { + "Text": "Assign a password verification function (for example ora12c_verify_function) to every profile so password complexity is enforced when passwords are set or changed.", + "Url": "https://hub.prowler.com/check/users_password_verify_function_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured.py b/prowler/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured.py new file mode 100644 index 0000000000..2db1930425 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.users.users_client import users_client + + +class users_password_verify_function_configured(Check): + """Check that database users have a password complexity verification function.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A list of reports, one per non-Oracle-maintained user. + """ + findings = [] + for user in users_client.users: + if user.oracle_maintained: + continue + report = CheckReportOracledb( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.name, + ) + if user.password_verify_function in (None, "NULL"): + report.status = "FAIL" + report.status_extended = f"User {user.name} has no password verification function (profile {user.profile})." + else: + report.status = "PASS" + report.status_extended = f"User {user.name} uses the password verification function {user.password_verify_function} (profile {user.profile})." + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/users/users_sample_schemas_removed/__init__.py b/prowler/providers/oracledb/services/users/users_sample_schemas_removed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed.metadata.json b/prowler/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed.metadata.json new file mode 100644 index 0000000000..d3e930f9cb --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "oracledb", + "CheckID": "users_sample_schemas_removed", + "CheckTitle": "Oracle sample schemas are not installed in the database", + "CheckType": [], + "ServiceName": "users", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OracleDatabase", + "ResourceGroup": "IAM", + "Description": "Checks that Oracle **sample schemas** (SCOTT, HR, OE, SH, PM, IX, BI...) are not present in the database. Sample schemas are demo content with well-known passwords and objects and must not exist in production databases (DBSAT finding USER.SAMPLE).", + "Risk": "Sample schemas use published default passwords and predictable objects, giving attackers an easy initial foothold. Once inside, they can enumerate the data dictionary and escalate through granted privileges.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/database/oracle/oracle-database/19/comsc/introduction-to-sample-schemas.html", + "https://docs.oracle.com/en/database/oracle/security-assessment-tool/3.0.0/satug/" + ], + "Remediation": { + "Code": { + "CLI": "DROP USER SCOTT CASCADE;", + "NativeIaC": "", + "Other": "1. Connect as a DBA user\n2. Review the sample schemas reported by the check\n3. Run DROP USER CASCADE for each sample schema after confirming it holds no business data", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove Oracle sample schemas from production databases. If a schema must stay temporarily, lock and expire the account and remove it as soon as possible.", + "Url": "https://hub.prowler.com/check/users_sample_schemas_removed" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed.py b/prowler/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed.py new file mode 100644 index 0000000000..8fa4ea584c --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed.py @@ -0,0 +1,38 @@ +from prowler.lib.check.models import Check, CheckReportOracledb +from prowler.providers.oracledb.services.users.users_client import users_client +from prowler.providers.oracledb.services.users.users_service import SAMPLE_SCHEMAS + + +class users_sample_schemas_removed(Check): + """Check that Oracle sample schemas are not installed in the database.""" + + def execute(self) -> list[CheckReportOracledb]: + """Execute the check logic. + + Returns: + A single report for the audited database. + """ + findings = [] + sample_users = sorted( + user.name for user in users_client.users if user.name in SAMPLE_SCHEMAS + ) + report = CheckReportOracledb( + metadata=self.metadata(), + resource={}, + resource_name=users_client.database_name, + resource_id=users_client.database_name, + ) + if sample_users: + report.status = "FAIL" + report.status_extended = ( + f"Database {users_client.database_name} has sample schemas " + f"installed: {', '.join(sample_users)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Database {users_client.database_name} has no sample " + "schemas installed." + ) + findings.append(report) + return findings diff --git a/prowler/providers/oracledb/services/users/users_service.py b/prowler/providers/oracledb/services/users/users_service.py new file mode 100644 index 0000000000..5ba0268373 --- /dev/null +++ b/prowler/providers/oracledb/services/users/users_service.py @@ -0,0 +1,136 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.oracledb.lib.service.service import OracledbService +from prowler.providers.oracledb.oracledb_provider import OracledbProvider + +# Sample schema accounts shipped with Oracle Database and its demo scripts +# (DBSAT finding USER.SAMPLE). They use well-known passwords and objects and +# must not exist in a production database. +SAMPLE_SCHEMAS = frozenset( + {"SCOTT", "HR", "OE", "SH", "PM", "IX", "BI", "ADAMS", "BLAKE", "CLARK", "JONES"} +) + +# Profile password/resource settings the user checks evaluate. +PROFILE_RESOURCES = ( + "PASSWORD_LIFE_TIME", + "FAILED_LOGIN_ATTEMPTS", + "INACTIVE_ACCOUNT_TIME", + "PASSWORD_VERIFY_FUNCTION", +) + + +class Users(OracledbService): + """Oracle Database users and profiles service. + + Reads DBA_USERS and DBA_PROFILES and resolves each user's effective + password management settings through its profile, mirroring the user + account findings of the Oracle Database Security Assessment Tool (DBSAT). + """ + + def __init__(self, provider: OracledbProvider): + super().__init__(__class__.__name__, provider) + self.profiles = self._list_profiles() + self.users = self._list_users() + + def _list_profiles(self) -> dict: + """Read the password/resource limits of every profile. + + Returns: + dict: {profile_name: {resource_name: limit}} + """ + logger.info("Users - Listing Oracle Database profiles...") + profiles = {} + try: + in_list = ", ".join(f"'{resource}'" for resource in PROFILE_RESOURCES) + rows = self._execute_query( + "SELECT profile, resource_name, limit FROM dba_profiles " + f"WHERE resource_name IN ({in_list})" + ) + for profile, resource_name, limit in rows: + profiles.setdefault(profile, {})[resource_name] = limit + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return profiles + + def _effective_limit(self, profile: str, resource_name: str) -> Optional[str]: + """Resolve a profile limit, following DEFAULT indirection. + + A profile setting of ``DEFAULT`` inherits the value of the DEFAULT + profile, which is how Oracle resolves it at authentication time. + """ + value = self.profiles.get(profile, {}).get(resource_name) + if value == "DEFAULT" and profile != "DEFAULT": + value = self.profiles.get("DEFAULT", {}).get(resource_name) + return value + + def _list_users(self) -> list["User"]: + """List database users with their effective profile settings. + + ORACLE_MAINTAINED exists since Oracle Database 12.1, the oldest + release python-oracledb thin mode can connect to, so the column is + always available. + """ + logger.info("Users - Listing Oracle Database users...") + users = [] + try: + rows = self._execute_query( + "SELECT username, account_status, profile, default_tablespace, " + "oracle_maintained FROM dba_users" + ) + for ( + username, + account_status, + profile, + default_tablespace, + oracle_maintained, + ) in rows: + users.append( + User( + name=username, + account_status=account_status or "", + profile=profile or "", + default_tablespace=default_tablespace or "", + oracle_maintained=oracle_maintained == "Y", + password_life_time=self._effective_limit( + profile, "PASSWORD_LIFE_TIME" + ), + failed_login_attempts=self._effective_limit( + profile, "FAILED_LOGIN_ATTEMPTS" + ), + inactive_account_time=self._effective_limit( + profile, "INACTIVE_ACCOUNT_TIME" + ), + password_verify_function=self._effective_limit( + profile, "PASSWORD_VERIFY_FUNCTION" + ), + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + logger.info(f"Found {len(users)} Oracle Database users") + return users + + +class User(BaseModel): + """Oracle Database user model with effective profile limits.""" + + name: str + account_status: str + profile: str + default_tablespace: str = "" + oracle_maintained: bool = False + password_life_time: Optional[str] = None + failed_login_attempts: Optional[str] = None + inactive_account_time: Optional[str] = None + password_verify_function: Optional[str] = None + + @property + def is_open(self) -> bool: + return self.account_status.startswith("OPEN") diff --git a/pyproject.toml b/pyproject.toml index 2bd08999b6..02163de74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ dependencies = [ "msgraph-sdk==1.55.0", "okta==3.4.2", "openstacksdk==4.2.0", + "oracledb==4.0.1", "pandas==2.2.3", "py-ocsf-models==0.8.1", "pydantic==2.12.5", diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index 549ca17727..27de69648e 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks,dashboard,iac,image,llm} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,oracledb,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,e2enetworks,dashboard,iac,image,llm} ..." def mock_get_available_providers(): diff --git a/tests/providers/oracledb/exceptions/oracledb_exceptions_test.py b/tests/providers/oracledb/exceptions/oracledb_exceptions_test.py new file mode 100644 index 0000000000..0e81df5a0d --- /dev/null +++ b/tests/providers/oracledb/exceptions/oracledb_exceptions_test.py @@ -0,0 +1,43 @@ +import pytest + +from prowler.providers.oracledb.exceptions.exceptions import ( + OracledbBaseException, + OracledbConnectionError, + OracledbEnvironmentVariableError, + OracledbInsufficientPrivilegesError, + OracledbInvalidCredentialsError, + OracledbInvalidProviderIdError, + OracledbSetUpIdentityError, + OracledbSetUpSessionError, +) + + +class TestOracledbExceptions: + @pytest.mark.parametrize( + "exception_class,code", + [ + (OracledbEnvironmentVariableError, 20000), + (OracledbSetUpSessionError, 20001), + (OracledbSetUpIdentityError, 20002), + (OracledbInvalidCredentialsError, 20003), + (OracledbConnectionError, 20004), + (OracledbInvalidProviderIdError, 20005), + (OracledbInsufficientPrivilegesError, 20006), + ], + ) + def test_exception_code_and_source(self, exception_class, code): + exception = exception_class(file="test_file") + + assert isinstance(exception, OracledbBaseException) + assert exception.code == code + assert f"[{code}]" in str(exception) + + def test_custom_message_overrides_default(self): + exception = OracledbConnectionError(message="custom connection failure") + + assert "custom connection failure" in str(exception) + + def test_default_message_when_none_given(self): + exception = OracledbInvalidCredentialsError() + + assert "credentials are not valid" in str(exception) diff --git a/tests/providers/oracledb/lib/arguments/oracledb_arguments_test.py b/tests/providers/oracledb/lib/arguments/oracledb_arguments_test.py new file mode 100644 index 0000000000..724f66802a --- /dev/null +++ b/tests/providers/oracledb/lib/arguments/oracledb_arguments_test.py @@ -0,0 +1,47 @@ +from unittest.mock import MagicMock + +from prowler.providers.oracledb.lib.arguments import arguments + + +class TestOracledbArguments: + def setup_method(self): + self.mock_subparsers = MagicMock() + self.mock_oracledb_parser = MagicMock() + self.mock_subparsers.add_parser.return_value = self.mock_oracledb_parser + + def test_init_parser_creates_subparser(self): + mock_args = MagicMock() + mock_args.subparsers = self.mock_subparsers + mock_args.common_providers_parser = MagicMock() + + arguments.init_parser(mock_args) + + self.mock_subparsers.add_parser.assert_called_once_with( + "oracledb", + parents=[mock_args.common_providers_parser], + help="Oracle Database Provider", + ) + + def test_init_parser_registers_flags(self): + mock_args = MagicMock() + mock_args.subparsers = self.mock_subparsers + mock_args.common_providers_parser = MagicMock() + + group = MagicMock() + self.mock_oracledb_parser.add_argument_group.return_value = group + + arguments.init_parser(mock_args) + + registered = {call.args[0] for call in group.add_argument.call_args_list} + assert registered == { + "--oracledb-user", + "--oracledb-password", + "--oracledb-dsn", + "--oracledb-host", + "--oracledb-port", + "--oracledb-service-name", + } + + def test_password_flag_is_sensitive(self): + """The password flag must be redacted in outputs and warned about.""" + assert "--oracledb-password" in arguments.SENSITIVE_ARGUMENTS diff --git a/tests/providers/oracledb/lib/mutelist/fixtures/oracledb_mutelist.yaml b/tests/providers/oracledb/lib/mutelist/fixtures/oracledb_mutelist.yaml new file mode 100644 index 0000000000..d650f41444 --- /dev/null +++ b/tests/providers/oracledb/lib/mutelist/fixtures/oracledb_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "ORCL.EXAMPLE.COM": + Checks: + "users_sample_schemas_removed": + Regions: + - "*" + Resources: + - "ORCL.EXAMPLE.COM" diff --git a/tests/providers/oracledb/lib/mutelist/oracledb_mutelist_test.py b/tests/providers/oracledb/lib/mutelist/oracledb_mutelist_test.py new file mode 100644 index 0000000000..ee3572199a --- /dev/null +++ b/tests/providers/oracledb/lib/mutelist/oracledb_mutelist_test.py @@ -0,0 +1,114 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.oracledb.lib.mutelist.mutelist import OracledbMutelist + +MUTELIST_FIXTURE_PATH = ( + "tests/providers/oracledb/lib/mutelist/fixtures/oracledb_mutelist.yaml" +) + + +class TestOracledbMutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = OracledbMutelist(mutelist_path=MUTELIST_FIXTURE_PATH) + + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + assert mutelist.mutelist == mutelist_fixture + assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH + + def test_get_mutelist_file_from_local_file_non_existent(self): + mutelist_path = "tests/providers/oracledb/lib/mutelist/fixtures/not_present" + mutelist = OracledbMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = OracledbMutelist(mutelist_content=mutelist_fixture) + + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path is None + + def test_is_finding_muted_match(self): + mutelist_content = { + "Accounts": { + "ORCL.EXAMPLE.COM": { + "Checks": { + "users_sample_schemas_removed": { + "Regions": ["*"], + "Resources": ["ORCL.EXAMPLE.COM"], + } + } + } + } + } + mutelist = OracledbMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata.CheckID = "users_sample_schemas_removed" + finding.resource_name = "ORCL.EXAMPLE.COM" + finding.resource_tags = [] + + assert ( + mutelist.is_finding_muted(finding, database_name="ORCL.EXAMPLE.COM") is True + ) + + def test_is_finding_muted_no_match(self): + mutelist_content = { + "Accounts": { + "ORCL.EXAMPLE.COM": { + "Checks": { + "users_sample_schemas_removed": { + "Regions": ["*"], + "Resources": ["ORCL.EXAMPLE.COM"], + } + } + } + } + } + mutelist = OracledbMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata.CheckID = "users_password_expiration_configured" + finding.resource_name = "APPUSER" + finding.resource_tags = [] + + assert ( + mutelist.is_finding_muted(finding, database_name="ORCL.EXAMPLE.COM") + is False + ) + + def test_is_finding_muted_no_match_on_different_database(self): + mutelist_content = { + "Accounts": { + "ORCL.EXAMPLE.COM": { + "Checks": { + "users_sample_schemas_removed": { + "Regions": ["*"], + "Resources": ["*"], + } + } + } + } + } + mutelist = OracledbMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata.CheckID = "users_sample_schemas_removed" + finding.resource_name = "ORCL.EXAMPLE.COM" + finding.resource_tags = [] + + assert ( + mutelist.is_finding_muted(finding, database_name="OTHER.EXAMPLE.COM") + is False + ) diff --git a/tests/providers/oracledb/oracledb_fixtures.py b/tests/providers/oracledb/oracledb_fixtures.py new file mode 100644 index 0000000000..a77fdc65ff --- /dev/null +++ b/tests/providers/oracledb/oracledb_fixtures.py @@ -0,0 +1,51 @@ +from unittest.mock import MagicMock + +from prowler.providers.oracledb.models import OracledbIdentityInfo, OracledbSession + +ORACLEDB_USER = "PROWLER" +ORACLEDB_PASSWORD = "mock_password" # nosec B105 - test fixture only +ORACLEDB_DSN = "dbhost.example.com:1521/ORCLPDB1" +ORACLEDB_DATABASE_NAME = "ORCL.EXAMPLE.COM" +ORACLEDB_VERSION = "19.21.0.0.0" + + +def set_mocked_oracledb_provider( + session: OracledbSession = None, + identity: OracledbIdentityInfo = None, + audit_config: dict = None, +): + if session is None: + session = OracledbSession( + user=ORACLEDB_USER, + dsn=ORACLEDB_DSN, + connection=MagicMock(), + ) + if identity is None: + identity = OracledbIdentityInfo( + user=ORACLEDB_USER, + dsn=ORACLEDB_DSN, + database_name=ORACLEDB_DATABASE_NAME, + version=ORACLEDB_VERSION, + ) + + provider = MagicMock() + provider.type = "oracledb" + provider.auth_method = "User / Password" + provider.session = session + provider.identity = identity + provider.audit_config = audit_config or {} + provider.fixer_config = {} + return provider + + +def set_mocked_connection(fetchall_results: list = None, fetchone_results: list = None): + """Build a mocked python-oracledb connection whose cursor returns the + given results, in order, across successive queries.""" + connection = MagicMock() + cursor = MagicMock() + connection.cursor.return_value.__enter__.return_value = cursor + if fetchall_results is not None: + cursor.fetchall.side_effect = fetchall_results + if fetchone_results is not None: + cursor.fetchone.side_effect = fetchone_results + return connection diff --git a/tests/providers/oracledb/oracledb_provider_test.py b/tests/providers/oracledb/oracledb_provider_test.py new file mode 100644 index 0000000000..bc1504f37e --- /dev/null +++ b/tests/providers/oracledb/oracledb_provider_test.py @@ -0,0 +1,254 @@ +from unittest import mock +from unittest.mock import MagicMock, patch + +import oracledb +import pytest + +from prowler.providers.common.models import Connection +from prowler.providers.oracledb.exceptions.exceptions import ( + OracledbConnectionError, + OracledbEnvironmentVariableError, + OracledbInvalidCredentialsError, + OracledbInvalidProviderIdError, + OracledbSetUpIdentityError, +) +from prowler.providers.oracledb.models import OracledbIdentityInfo, OracledbSession +from prowler.providers.oracledb.oracledb_provider import OracledbProvider +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + ORACLEDB_DSN, + ORACLEDB_PASSWORD, + ORACLEDB_USER, + ORACLEDB_VERSION, + set_mocked_connection, +) + +PROVIDER_PATH = "prowler.providers.oracledb.oracledb_provider" + + +class TestOracledbProvider: + def test_validate_arguments_missing_all(self): + with mock.patch.dict("os.environ", {}, clear=True): + with pytest.raises(OracledbEnvironmentVariableError) as exception: + OracledbProvider.validate_arguments() + assert "--oracledb-user / ORACLEDB_USER" in str(exception.value) + assert "ORACLEDB_PASSWORD" in str(exception.value) + assert "--oracledb-dsn / ORACLEDB_DSN" in str(exception.value) + + def test_validate_arguments_missing_password(self): + with mock.patch.dict("os.environ", {}, clear=True): + with pytest.raises(OracledbEnvironmentVariableError) as exception: + OracledbProvider.validate_arguments( + oracledb_user=ORACLEDB_USER, + oracledb_dsn=ORACLEDB_DSN, + ) + assert "ORACLEDB_PASSWORD" in str(exception.value) + assert "--oracledb-user" not in str(exception.value) + + def test_validate_arguments_from_environment(self): + environment = { + "ORACLEDB_USER": ORACLEDB_USER, + "ORACLEDB_PASSWORD": ORACLEDB_PASSWORD, + "ORACLEDB_DSN": ORACLEDB_DSN, + } + with mock.patch.dict("os.environ", environment, clear=True): + assert OracledbProvider.validate_arguments() is None + + def test_resolve_dsn_explicit_dsn_wins(self): + with mock.patch.dict("os.environ", {}, clear=True): + assert ( + OracledbProvider.resolve_dsn( + dsn=ORACLEDB_DSN, host="other", service_name="OTHER" + ) + == ORACLEDB_DSN + ) + + def test_resolve_dsn_from_host_port_service(self): + with mock.patch.dict("os.environ", {}, clear=True): + assert ( + OracledbProvider.resolve_dsn( + host="dbhost.example.com", + port=1522, + service_name="ORCLPDB1", + ) + == "dbhost.example.com:1522/ORCLPDB1" + ) + + def test_resolve_dsn_default_port(self): + with mock.patch.dict("os.environ", {}, clear=True): + assert ( + OracledbProvider.resolve_dsn( + host="dbhost.example.com", service_name="ORCLPDB1" + ) + == "dbhost.example.com:1521/ORCLPDB1" + ) + + def test_resolve_dsn_empty_when_incomplete(self): + with mock.patch.dict("os.environ", {}, clear=True): + assert OracledbProvider.resolve_dsn(host="dbhost.example.com") == "" + + def test_setup_session_success(self): + with mock.patch.dict("os.environ", {}, clear=True): + connection = MagicMock() + with patch( + f"{PROVIDER_PATH}.oracledb.connect", return_value=connection + ) as mock_connect: + session = OracledbProvider.setup_session( + user=ORACLEDB_USER, + password=ORACLEDB_PASSWORD, + dsn=ORACLEDB_DSN, + ) + mock_connect.assert_called_once_with( + user=ORACLEDB_USER, password=ORACLEDB_PASSWORD, dsn=ORACLEDB_DSN + ) + assert isinstance(session, OracledbSession) + assert session.user == ORACLEDB_USER + assert session.dsn == ORACLEDB_DSN + assert session.connection is connection + + def test_setup_session_invalid_credentials(self): + with mock.patch.dict("os.environ", {}, clear=True): + with patch( + f"{PROVIDER_PATH}.oracledb.connect", + side_effect=oracledb.DatabaseError( + "ORA-01017: invalid username/password; logon denied" + ), + ): + with pytest.raises(OracledbInvalidCredentialsError): + OracledbProvider.setup_session( + user=ORACLEDB_USER, + password="wrong", + dsn=ORACLEDB_DSN, + ) + + def test_setup_session_connection_error(self): + with mock.patch.dict("os.environ", {}, clear=True): + with patch( + f"{PROVIDER_PATH}.oracledb.connect", + side_effect=oracledb.DatabaseError( + "DPY-6001: cannot connect to database" + ), + ): + with pytest.raises(OracledbConnectionError): + OracledbProvider.setup_session( + user=ORACLEDB_USER, + password=ORACLEDB_PASSWORD, + dsn=ORACLEDB_DSN, + ) + + def test_setup_identity(self): + connection = set_mocked_connection( + fetchone_results=[(ORACLEDB_DATABASE_NAME,), (ORACLEDB_VERSION,)] + ) + session = OracledbSession( + user=ORACLEDB_USER, dsn=ORACLEDB_DSN, connection=connection + ) + + identity = OracledbProvider.setup_identity(session) + + assert isinstance(identity, OracledbIdentityInfo) + assert identity.user == ORACLEDB_USER + assert identity.dsn == ORACLEDB_DSN + assert identity.database_name == ORACLEDB_DATABASE_NAME + assert identity.version == ORACLEDB_VERSION + + def test_setup_identity_version_unreadable(self): + connection = MagicMock() + cursor = MagicMock() + connection.cursor.return_value.__enter__.return_value = cursor + cursor.fetchone.return_value = (ORACLEDB_DATABASE_NAME,) + cursor.execute.side_effect = [ + None, + oracledb.DatabaseError("ORA-00942: table or view does not exist"), + ] + session = OracledbSession( + user=ORACLEDB_USER, dsn=ORACLEDB_DSN, connection=connection + ) + + identity = OracledbProvider.setup_identity(session) + + assert identity.database_name == ORACLEDB_DATABASE_NAME + assert identity.version == "" + + def test_setup_identity_error(self): + connection = MagicMock() + connection.cursor.side_effect = oracledb.DatabaseError( + "ORA-01034: ORACLE not available" + ) + session = OracledbSession( + user=ORACLEDB_USER, dsn=ORACLEDB_DSN, connection=connection + ) + + with pytest.raises(OracledbSetUpIdentityError): + OracledbProvider.setup_identity(session) + + def test_provider_init(self): + with mock.patch.dict("os.environ", {}, clear=True): + connection = set_mocked_connection( + fetchone_results=[(ORACLEDB_DATABASE_NAME,), (ORACLEDB_VERSION,)] + ) + with patch(f"{PROVIDER_PATH}.oracledb.connect", return_value=connection): + provider = OracledbProvider( + oracledb_user=ORACLEDB_USER, + oracledb_password=ORACLEDB_PASSWORD, + oracledb_dsn=ORACLEDB_DSN, + ) + + assert provider.type == "oracledb" + assert provider.auth_method == "User / Password" + assert provider.session.user == ORACLEDB_USER + assert provider.identity.database_name == ORACLEDB_DATABASE_NAME + assert provider.audit_config == {} + assert provider.mutelist.mutelist == {} + + def test_test_connection_success(self): + with mock.patch.dict("os.environ", {}, clear=True): + connection = set_mocked_connection( + fetchone_results=[(ORACLEDB_DATABASE_NAME,), (ORACLEDB_VERSION,)] + ) + with patch(f"{PROVIDER_PATH}.oracledb.connect", return_value=connection): + result = OracledbProvider.test_connection( + oracledb_user=ORACLEDB_USER, + oracledb_password=ORACLEDB_PASSWORD, + oracledb_dsn=ORACLEDB_DSN, + ) + + assert isinstance(result, Connection) + assert result.is_connected is True + connection.close.assert_called_once() + + def test_test_connection_provider_id_match(self): + with mock.patch.dict("os.environ", {}, clear=True): + connection = set_mocked_connection( + fetchone_results=[(ORACLEDB_DATABASE_NAME,), (ORACLEDB_VERSION,)] + ) + with patch(f"{PROVIDER_PATH}.oracledb.connect", return_value=connection): + result = OracledbProvider.test_connection( + oracledb_user=ORACLEDB_USER, + oracledb_password=ORACLEDB_PASSWORD, + oracledb_dsn=ORACLEDB_DSN, + provider_id=ORACLEDB_DATABASE_NAME.lower(), + ) + + assert result.is_connected is True + + def test_test_connection_provider_id_mismatch(self): + with mock.patch.dict("os.environ", {}, clear=True): + connection = set_mocked_connection( + fetchone_results=[(ORACLEDB_DATABASE_NAME,), (ORACLEDB_VERSION,)] + ) + with patch(f"{PROVIDER_PATH}.oracledb.connect", return_value=connection): + with pytest.raises(OracledbInvalidProviderIdError): + OracledbProvider.test_connection( + oracledb_user=ORACLEDB_USER, + oracledb_password=ORACLEDB_PASSWORD, + oracledb_dsn=ORACLEDB_DSN, + provider_id="OTHERDB.EXAMPLE.COM", + ) + + def test_test_connection_no_raise(self): + with mock.patch.dict("os.environ", {}, clear=True): + result = OracledbProvider.test_connection(raise_on_exception=False) + + assert result.is_connected is False + assert isinstance(result.error, OracledbEnvironmentVariableError) diff --git a/tests/providers/oracledb/services/audit/audit_service_test.py b/tests/providers/oracledb/services/audit/audit_service_test.py new file mode 100644 index 0000000000..455b9447cc --- /dev/null +++ b/tests/providers/oracledb/services/audit/audit_service_test.py @@ -0,0 +1,61 @@ +from prowler.providers.oracledb.models import OracledbSession +from prowler.providers.oracledb.services.audit.audit_service import Audit +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DSN, + ORACLEDB_USER, + set_mocked_connection, + set_mocked_oracledb_provider, +) + + +def _build_audit_service(fetchall_results): + provider = set_mocked_oracledb_provider( + session=OracledbSession( + user=ORACLEDB_USER, + dsn=ORACLEDB_DSN, + connection=set_mocked_connection(fetchall_results=fetchall_results), + ) + ) + return Audit(provider) + + +class TestAuditService: + def test_reads_audit_configuration(self): + service = _build_audit_service( + [ + [("audit_trail", "DB"), ("audit_sys_operations", "TRUE")], + [("TRUE",)], + [("ORA_SECURECONFIG",), ("ORA_LOGON_FAILURES",)], + ] + ) + + assert service.audit_trail == "DB" + assert service.audit_sys_operations == "TRUE" + assert service.unified_auditing is True + assert service.enabled_unified_policies == [ + "ORA_LOGON_FAILURES", + "ORA_SECURECONFIG", + ] + + def test_unified_auditing_disabled(self): + service = _build_audit_service( + [ + [("audit_trail", "NONE"), ("audit_sys_operations", "FALSE")], + [("FALSE",)], + [], + ] + ) + + assert service.audit_trail == "NONE" + assert service.unified_auditing is False + assert service.enabled_unified_policies == [] + + def test_query_error_leaves_defaults(self): + service = _build_audit_service( + [Exception("ORA-00942: table or view does not exist")] * 3 + ) + + assert service.audit_trail is None + assert service.audit_sys_operations is None + assert service.unified_auditing is False + assert service.enabled_unified_policies == [] diff --git a/tests/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled_test.py b/tests/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled_test.py new file mode 100644 index 0000000000..9027d98c5a --- /dev/null +++ b/tests/providers/oracledb/services/audit/audit_sys_operations_enabled/audit_sys_operations_enabled_test.py @@ -0,0 +1,62 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.audit." + "audit_sys_operations_enabled." + "audit_sys_operations_enabled.audit_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.audit.audit_sys_operations_enabled.audit_sys_operations_enabled import ( + audit_sys_operations_enabled, + ) + + return audit_sys_operations_enabled().execute() + + +def _build_client( + audit_trail=None, + audit_sys_operations=None, + unified_auditing=False, + enabled_unified_policies=None, +): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.audit_trail = audit_trail + client.audit_sys_operations = audit_sys_operations + client.unified_auditing = unified_auditing + client.enabled_unified_policies = enabled_unified_policies or [] + return client + + +class Test_audit_sys_operations_enabled: + def test_pass_when_enabled(self): + findings = _run_check(_build_client(audit_sys_operations="TRUE")) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "AUDIT_SYS_OPERATIONS=TRUE" in findings[0].status_extended + + def test_fail_when_disabled(self): + findings = _run_check(_build_client(audit_sys_operations="FALSE")) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "AUDIT_SYS_OPERATIONS=FALSE" in findings[0].status_extended + + def test_fail_when_unknown(self): + findings = _run_check(_build_client()) + assert len(findings) == 1 + assert findings[0].status == "FAIL" diff --git a/tests/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled_test.py b/tests/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled_test.py new file mode 100644 index 0000000000..2d3bd5e042 --- /dev/null +++ b/tests/providers/oracledb/services/audit/audit_trail_enabled/audit_trail_enabled_test.py @@ -0,0 +1,68 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.audit." + "audit_trail_enabled." + "audit_trail_enabled.audit_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.audit.audit_trail_enabled.audit_trail_enabled import ( + audit_trail_enabled, + ) + + return audit_trail_enabled().execute() + + +def _build_client( + audit_trail=None, + audit_sys_operations=None, + unified_auditing=False, + enabled_unified_policies=None, +): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.audit_trail = audit_trail + client.audit_sys_operations = audit_sys_operations + client.unified_auditing = unified_auditing + client.enabled_unified_policies = enabled_unified_policies or [] + return client + + +class Test_audit_trail_enabled: + def test_pass_with_unified_auditing(self): + findings = _run_check(_build_client(unified_auditing=True)) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Unified Auditing" in findings[0].status_extended + + def test_pass_with_traditional_auditing(self): + findings = _run_check(_build_client(audit_trail="DB")) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "AUDIT_TRAIL=DB" in findings[0].status_extended + + def test_fail_when_auditing_disabled(self): + findings = _run_check(_build_client(audit_trail="NONE")) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "auditing disabled" in findings[0].status_extended + + def test_fail_when_audit_trail_unknown(self): + findings = _run_check(_build_client()) + assert len(findings) == 1 + assert findings[0].status == "FAIL" diff --git a/tests/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled_test.py b/tests/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled_test.py new file mode 100644 index 0000000000..054f25cd3d --- /dev/null +++ b/tests/providers/oracledb/services/configuration/configuration_dictionary_accessibility_disabled/configuration_dictionary_accessibility_disabled_test.py @@ -0,0 +1,65 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.configuration." + "configuration_dictionary_accessibility_disabled." + "configuration_dictionary_accessibility_disabled.configuration_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.configuration.configuration_dictionary_accessibility_disabled.configuration_dictionary_accessibility_disabled import ( + configuration_dictionary_accessibility_disabled, + ) + + return configuration_dictionary_accessibility_disabled().execute() + + +def _build_client(parameters=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.parameters = parameters if parameters is not None else {} + return client + + +class Test_configuration_dictionary_accessibility_disabled: + def test_no_findings_when_parameters_unreadable(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check( + _build_client(parameters={"o7_dictionary_accessibility": "FALSE"}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_name == "o7_dictionary_accessibility" + assert ( + findings[0].resource_id + == f"{ORACLEDB_DATABASE_NAME}/o7_dictionary_accessibility" + ) + + def test_fail(self): + findings = _run_check( + _build_client(parameters={"o7_dictionary_accessibility": "TRUE"}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "O7_DICTIONARY_ACCESSIBILITY=TRUE" in findings[0].status_extended + + def test_pass_when_parameter_absent(self): + findings = _run_check(_build_client(parameters={"other_parameter": "VALUE"})) + assert len(findings) == 1 + assert findings[0].status == "PASS" diff --git a/tests/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure_test.py b/tests/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure_test.py new file mode 100644 index 0000000000..0078647f76 --- /dev/null +++ b/tests/providers/oracledb/services/configuration/configuration_remote_login_passwordfile_secure/configuration_remote_login_passwordfile_secure_test.py @@ -0,0 +1,65 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.configuration." + "configuration_remote_login_passwordfile_secure." + "configuration_remote_login_passwordfile_secure.configuration_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.configuration.configuration_remote_login_passwordfile_secure.configuration_remote_login_passwordfile_secure import ( + configuration_remote_login_passwordfile_secure, + ) + + return configuration_remote_login_passwordfile_secure().execute() + + +def _build_client(parameters=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.parameters = parameters if parameters is not None else {} + return client + + +class Test_configuration_remote_login_passwordfile_secure: + def test_no_findings_when_parameters_unreadable(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check( + _build_client(parameters={"remote_login_passwordfile": "EXCLUSIVE"}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_name == "remote_login_passwordfile" + assert ( + findings[0].resource_id + == f"{ORACLEDB_DATABASE_NAME}/remote_login_passwordfile" + ) + + def test_fail(self): + findings = _run_check( + _build_client(parameters={"remote_login_passwordfile": "SHARED"}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "shares its password file" in findings[0].status_extended + + def test_pass_when_parameter_absent(self): + findings = _run_check(_build_client(parameters={"other_parameter": "VALUE"})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" diff --git a/tests/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled_test.py b/tests/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled_test.py new file mode 100644 index 0000000000..9d6d43a3b4 --- /dev/null +++ b/tests/providers/oracledb/services/configuration/configuration_remote_os_authentication_disabled/configuration_remote_os_authentication_disabled_test.py @@ -0,0 +1,58 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.configuration." + "configuration_remote_os_authentication_disabled." + "configuration_remote_os_authentication_disabled.configuration_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.configuration.configuration_remote_os_authentication_disabled.configuration_remote_os_authentication_disabled import ( + configuration_remote_os_authentication_disabled, + ) + + return configuration_remote_os_authentication_disabled().execute() + + +def _build_client(parameters=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.parameters = parameters if parameters is not None else {} + return client + + +class Test_configuration_remote_os_authentication_disabled: + def test_no_findings_when_parameters_unreadable(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check(_build_client(parameters={"remote_os_authent": "FALSE"})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_name == "remote_os_authent" + assert findings[0].resource_id == f"{ORACLEDB_DATABASE_NAME}/remote_os_authent" + + def test_fail(self): + findings = _run_check(_build_client(parameters={"remote_os_authent": "TRUE"})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "REMOTE_OS_AUTHENT=TRUE" in findings[0].status_extended + + def test_pass_when_parameter_absent(self): + findings = _run_check(_build_client(parameters={"other_parameter": "VALUE"})) + assert len(findings) == 1 + assert findings[0].status == "PASS" diff --git a/tests/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled_test.py b/tests/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled_test.py new file mode 100644 index 0000000000..da5964a6a9 --- /dev/null +++ b/tests/providers/oracledb/services/configuration/configuration_remote_os_roles_disabled/configuration_remote_os_roles_disabled_test.py @@ -0,0 +1,58 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.configuration." + "configuration_remote_os_roles_disabled." + "configuration_remote_os_roles_disabled.configuration_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.configuration.configuration_remote_os_roles_disabled.configuration_remote_os_roles_disabled import ( + configuration_remote_os_roles_disabled, + ) + + return configuration_remote_os_roles_disabled().execute() + + +def _build_client(parameters=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.parameters = parameters if parameters is not None else {} + return client + + +class Test_configuration_remote_os_roles_disabled: + def test_no_findings_when_parameters_unreadable(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check(_build_client(parameters={"remote_os_roles": "FALSE"})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_name == "remote_os_roles" + assert findings[0].resource_id == f"{ORACLEDB_DATABASE_NAME}/remote_os_roles" + + def test_fail(self): + findings = _run_check(_build_client(parameters={"remote_os_roles": "TRUE"})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "REMOTE_OS_ROLES=TRUE" in findings[0].status_extended + + def test_pass_when_parameter_absent(self): + findings = _run_check(_build_client(parameters={"other_parameter": "VALUE"})) + assert len(findings) == 1 + assert findings[0].status == "PASS" diff --git a/tests/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled_test.py b/tests/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled_test.py new file mode 100644 index 0000000000..4d3e07a8e3 --- /dev/null +++ b/tests/providers/oracledb/services/configuration/configuration_sec_return_server_release_banner_disabled/configuration_sec_return_server_release_banner_disabled_test.py @@ -0,0 +1,65 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.configuration." + "configuration_sec_return_server_release_banner_disabled." + "configuration_sec_return_server_release_banner_disabled.configuration_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.configuration.configuration_sec_return_server_release_banner_disabled.configuration_sec_return_server_release_banner_disabled import ( + configuration_sec_return_server_release_banner_disabled, + ) + + return configuration_sec_return_server_release_banner_disabled().execute() + + +def _build_client(parameters=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.parameters = parameters if parameters is not None else {} + return client + + +class Test_configuration_sec_return_server_release_banner_disabled: + def test_no_findings_when_parameters_unreadable(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check( + _build_client(parameters={"sec_return_server_release_banner": "FALSE"}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_name == "sec_return_server_release_banner" + assert ( + findings[0].resource_id + == f"{ORACLEDB_DATABASE_NAME}/sec_return_server_release_banner" + ) + + def test_fail(self): + findings = _run_check( + _build_client(parameters={"sec_return_server_release_banner": "TRUE"}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "SEC_RETURN_SERVER_RELEASE_BANNER=TRUE" in findings[0].status_extended + + def test_pass_when_parameter_absent(self): + findings = _run_check(_build_client(parameters={"other_parameter": "VALUE"})) + assert len(findings) == 1 + assert findings[0].status == "PASS" diff --git a/tests/providers/oracledb/services/configuration/configuration_service_test.py b/tests/providers/oracledb/services/configuration/configuration_service_test.py new file mode 100644 index 0000000000..373731c577 --- /dev/null +++ b/tests/providers/oracledb/services/configuration/configuration_service_test.py @@ -0,0 +1,49 @@ +from prowler.providers.oracledb.models import OracledbSession +from prowler.providers.oracledb.services.configuration.configuration_service import ( + Configuration, +) +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DSN, + ORACLEDB_USER, + set_mocked_connection, + set_mocked_oracledb_provider, +) + + +def _build_configuration_service(fetchall_results): + provider = set_mocked_oracledb_provider( + session=OracledbSession( + user=ORACLEDB_USER, + dsn=ORACLEDB_DSN, + connection=set_mocked_connection(fetchall_results=fetchall_results), + ) + ) + return Configuration(provider) + + +class TestConfigurationService: + def test_reads_security_parameters(self): + service = _build_configuration_service( + [ + [ + ("o7_dictionary_accessibility", "FALSE"), + ("sql92_security", "TRUE"), + ("remote_login_passwordfile", "EXCLUSIVE"), + ("sec_return_server_release_banner", "FALSE"), + ] + ] + ) + + assert service.parameters == { + "o7_dictionary_accessibility": "FALSE", + "sql92_security": "TRUE", + "remote_login_passwordfile": "EXCLUSIVE", + "sec_return_server_release_banner": "FALSE", + } + + def test_query_error_returns_empty(self): + service = _build_configuration_service( + [Exception("ORA-00942: table or view does not exist")] + ) + + assert service.parameters == {} diff --git a/tests/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled_test.py b/tests/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled_test.py new file mode 100644 index 0000000000..c4553fb692 --- /dev/null +++ b/tests/providers/oracledb/services/configuration/configuration_sql92_security_enabled/configuration_sql92_security_enabled_test.py @@ -0,0 +1,58 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.configuration." + "configuration_sql92_security_enabled." + "configuration_sql92_security_enabled.configuration_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.configuration.configuration_sql92_security_enabled.configuration_sql92_security_enabled import ( + configuration_sql92_security_enabled, + ) + + return configuration_sql92_security_enabled().execute() + + +def _build_client(parameters=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.parameters = parameters if parameters is not None else {} + return client + + +class Test_configuration_sql92_security_enabled: + def test_no_findings_when_parameters_unreadable(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check(_build_client(parameters={"sql92_security": "TRUE"})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_name == "sql92_security" + assert findings[0].resource_id == f"{ORACLEDB_DATABASE_NAME}/sql92_security" + + def test_fail(self): + findings = _run_check(_build_client(parameters={"sql92_security": "FALSE"})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "SQL92_SECURITY=FALSE" in findings[0].status_extended + + def test_pass_when_parameter_absent(self): + findings = _run_check(_build_client(parameters={"other_parameter": "VALUE"})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" diff --git a/tests/providers/oracledb/services/encryption/encryption_service_test.py b/tests/providers/oracledb/services/encryption/encryption_service_test.py new file mode 100644 index 0000000000..5195114075 --- /dev/null +++ b/tests/providers/oracledb/services/encryption/encryption_service_test.py @@ -0,0 +1,58 @@ +from prowler.providers.oracledb.models import OracledbSession +from prowler.providers.oracledb.services.encryption.encryption_service import ( + Encryption, +) +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DSN, + ORACLEDB_USER, + set_mocked_connection, + set_mocked_oracledb_provider, +) + + +def _build_encryption_service(fetchall_results): + provider = set_mocked_oracledb_provider( + session=OracledbSession( + user=ORACLEDB_USER, + dsn=ORACLEDB_DSN, + connection=set_mocked_connection(fetchall_results=fetchall_results), + ) + ) + return Encryption(provider) + + +class TestEncryptionService: + def test_lists_wallets_and_tablespaces(self): + service = _build_encryption_service( + [ + [("FILE", "/opt/oracle/wallet", "OPEN", "AUTOLOGIN")], + [ + ("SYSTEM", "NO", "PERMANENT"), + ("DATA", "YES", "PERMANENT"), + ("TEMP", "NO", "TEMPORARY"), + ], + ] + ) + + assert len(service.wallets) == 1 + assert service.wallets[0].status == "OPEN" + assert service.wallets[0].wallet_type == "AUTOLOGIN" + + assert len(service.tablespaces) == 3 + data = next(ts for ts in service.tablespaces if ts.name == "DATA") + assert data.encrypted is True + assert data.is_user_permanent is True + + system = next(ts for ts in service.tablespaces if ts.name == "SYSTEM") + assert system.is_user_permanent is False + + temp = next(ts for ts in service.tablespaces if ts.name == "TEMP") + assert temp.is_user_permanent is False + + def test_query_error_returns_empty(self): + service = _build_encryption_service( + [Exception("ORA-00942: table or view does not exist")] * 2 + ) + + assert service.wallets == [] + assert service.tablespaces == [] diff --git a/tests/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted_test.py b/tests/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted_test.py new file mode 100644 index 0000000000..ae7a248f1b --- /dev/null +++ b/tests/providers/oracledb/services/encryption/encryption_tablespaces_encrypted/encryption_tablespaces_encrypted_test.py @@ -0,0 +1,68 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.encryption.encryption_service import ( + Tablespace, +) +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.encryption." + "encryption_tablespaces_encrypted." + "encryption_tablespaces_encrypted.encryption_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.encryption.encryption_tablespaces_encrypted.encryption_tablespaces_encrypted import ( + encryption_tablespaces_encrypted, + ) + + return encryption_tablespaces_encrypted().execute() + + +def _build_client(wallets=None, tablespaces=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.wallets = wallets or [] + client.tablespaces = tablespaces or [] + return client + + +class Test_encryption_tablespaces_encrypted: + def test_no_tablespaces(self): + assert _run_check(_build_client()) == [] + + def test_pass_with_encrypted_tablespace(self): + tablespace = Tablespace(name="DATA", encrypted=True, contents="PERMANENT") + findings = _run_check(_build_client(tablespaces=[tablespace])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == "DATA" + assert "is encrypted with TDE" in findings[0].status_extended + + def test_fail_with_unencrypted_tablespace(self): + tablespace = Tablespace(name="DATA", encrypted=False, contents="PERMANENT") + findings = _run_check(_build_client(tablespaces=[tablespace])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "is not encrypted with TDE" in findings[0].status_extended + + def test_system_and_transient_tablespaces_are_skipped(self): + tablespaces = [ + Tablespace(name="SYSTEM", encrypted=False, contents="PERMANENT"), + Tablespace(name="SYSAUX", encrypted=False, contents="PERMANENT"), + Tablespace(name="TEMP", encrypted=False, contents="TEMPORARY"), + Tablespace(name="UNDOTBS1", encrypted=False, contents="UNDO"), + ] + assert _run_check(_build_client(tablespaces=tablespaces)) == [] diff --git a/tests/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured_test.py b/tests/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured_test.py new file mode 100644 index 0000000000..3d3fbae008 --- /dev/null +++ b/tests/providers/oracledb/services/encryption/encryption_wallet_configured/encryption_wallet_configured_test.py @@ -0,0 +1,66 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.encryption.encryption_service import ( + Wallet, +) +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.encryption." + "encryption_wallet_configured." + "encryption_wallet_configured.encryption_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.encryption.encryption_wallet_configured.encryption_wallet_configured import ( + encryption_wallet_configured, + ) + + return encryption_wallet_configured().execute() + + +def _build_client(wallets=None, tablespaces=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.wallets = wallets or [] + client.tablespaces = tablespaces or [] + return client + + +class Test_encryption_wallet_configured: + def test_pass_with_open_wallet(self): + wallet = Wallet( + wrl_type="FILE", + wrl_parameter="/opt/oracle/wallet", + status="OPEN", + wallet_type="AUTOLOGIN", + ) + findings = _run_check(_build_client(wallets=[wallet])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "open TDE keystore" in findings[0].status_extended + + def test_fail_with_closed_wallet(self): + wallet = Wallet(wrl_type="FILE", status="CLOSED", wallet_type="UNKNOWN") + findings = _run_check(_build_client(wallets=[wallet])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "CLOSED" in findings[0].status_extended + + def test_fail_without_wallets(self): + findings = _run_check(_build_client()) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "NOT_AVAILABLE" in findings[0].status_extended diff --git a/tests/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages_test.py b/tests/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages_test.py new file mode 100644 index 0000000000..0a49385d58 --- /dev/null +++ b/tests/providers/oracledb/services/privileges/privileges_public_no_encryption_packages/privileges_public_no_encryption_packages_test.py @@ -0,0 +1,55 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.privileges." + "privileges_public_no_encryption_packages." + "privileges_public_no_encryption_packages.privileges_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.privileges.privileges_public_no_encryption_packages.privileges_public_no_encryption_packages import ( + privileges_public_no_encryption_packages, + ) + + return privileges_public_no_encryption_packages().execute() + + +def _build_client(system_privileges=None, execute_packages=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.public_system_privileges = system_privileges or [] + client.public_execute_packages = execute_packages or [] + return client + + +class Test_privileges_public_no_encryption_packages: + def test_pass_when_no_relevant_packages(self): + findings = _run_check( + _build_client(execute_packages=["DBMS_OUTPUT", "DBMS_STANDARD"]) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == ORACLEDB_DATABASE_NAME + assert "does not grant PUBLIC execute" in findings[0].status_extended + + def test_fail_when_package_is_public(self): + findings = _run_check( + _build_client(execute_packages=["DBMS_OUTPUT", "DBMS_CRYPTO"]) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "DBMS_CRYPTO" in findings[0].status_extended diff --git a/tests/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages_test.py b/tests/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages_test.py new file mode 100644 index 0000000000..f06158c9f5 --- /dev/null +++ b/tests/providers/oracledb/services/privileges/privileges_public_no_file_packages/privileges_public_no_file_packages_test.py @@ -0,0 +1,55 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.privileges." + "privileges_public_no_file_packages." + "privileges_public_no_file_packages.privileges_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.privileges.privileges_public_no_file_packages.privileges_public_no_file_packages import ( + privileges_public_no_file_packages, + ) + + return privileges_public_no_file_packages().execute() + + +def _build_client(system_privileges=None, execute_packages=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.public_system_privileges = system_privileges or [] + client.public_execute_packages = execute_packages or [] + return client + + +class Test_privileges_public_no_file_packages: + def test_pass_when_no_relevant_packages(self): + findings = _run_check( + _build_client(execute_packages=["DBMS_OUTPUT", "DBMS_STANDARD"]) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == ORACLEDB_DATABASE_NAME + assert "does not grant PUBLIC execute" in findings[0].status_extended + + def test_fail_when_package_is_public(self): + findings = _run_check( + _build_client(execute_packages=["DBMS_OUTPUT", "UTL_FILE"]) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "UTL_FILE" in findings[0].status_extended diff --git a/tests/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages_test.py b/tests/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages_test.py new file mode 100644 index 0000000000..4f7969837d --- /dev/null +++ b/tests/providers/oracledb/services/privileges/privileges_public_no_network_packages/privileges_public_no_network_packages_test.py @@ -0,0 +1,55 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.privileges." + "privileges_public_no_network_packages." + "privileges_public_no_network_packages.privileges_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.privileges.privileges_public_no_network_packages.privileges_public_no_network_packages import ( + privileges_public_no_network_packages, + ) + + return privileges_public_no_network_packages().execute() + + +def _build_client(system_privileges=None, execute_packages=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.public_system_privileges = system_privileges or [] + client.public_execute_packages = execute_packages or [] + return client + + +class Test_privileges_public_no_network_packages: + def test_pass_when_no_relevant_packages(self): + findings = _run_check( + _build_client(execute_packages=["DBMS_OUTPUT", "DBMS_STANDARD"]) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == ORACLEDB_DATABASE_NAME + assert "does not grant PUBLIC execute" in findings[0].status_extended + + def test_fail_when_package_is_public(self): + findings = _run_check( + _build_client(execute_packages=["DBMS_OUTPUT", "UTL_HTTP"]) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "UTL_HTTP" in findings[0].status_extended diff --git a/tests/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges_test.py b/tests/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges_test.py new file mode 100644 index 0000000000..fb19ea9461 --- /dev/null +++ b/tests/providers/oracledb/services/privileges/privileges_public_no_system_privileges/privileges_public_no_system_privileges_test.py @@ -0,0 +1,53 @@ +from unittest import mock +from unittest.mock import MagicMock + +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.privileges." + "privileges_public_no_system_privileges." + "privileges_public_no_system_privileges.privileges_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.privileges.privileges_public_no_system_privileges.privileges_public_no_system_privileges import ( + privileges_public_no_system_privileges, + ) + + return privileges_public_no_system_privileges().execute() + + +def _build_client(system_privileges=None, execute_packages=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.public_system_privileges = system_privileges or [] + client.public_execute_packages = execute_packages or [] + return client + + +class Test_privileges_public_no_system_privileges: + def test_pass_when_no_grants(self): + findings = _run_check(_build_client()) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == ORACLEDB_DATABASE_NAME + assert "does not grant" in findings[0].status_extended + + def test_fail_when_public_has_system_privileges(self): + findings = _run_check( + _build_client(system_privileges=["CREATE TABLE", "SELECT ANY TABLE"]) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "CREATE TABLE, SELECT ANY TABLE" in findings[0].status_extended diff --git a/tests/providers/oracledb/services/privileges/privileges_service_test.py b/tests/providers/oracledb/services/privileges/privileges_service_test.py new file mode 100644 index 0000000000..43e537c2b1 --- /dev/null +++ b/tests/providers/oracledb/services/privileges/privileges_service_test.py @@ -0,0 +1,51 @@ +from prowler.providers.oracledb.models import OracledbSession +from prowler.providers.oracledb.services.privileges.privileges_service import ( + Privileges, +) +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DSN, + ORACLEDB_USER, + set_mocked_connection, + set_mocked_oracledb_provider, +) + + +def _build_privileges_service(fetchall_results): + provider = set_mocked_oracledb_provider( + session=OracledbSession( + user=ORACLEDB_USER, + dsn=ORACLEDB_DSN, + connection=set_mocked_connection(fetchall_results=fetchall_results), + ) + ) + return Privileges(provider) + + +class TestPrivilegesService: + def test_lists_public_grants_sorted(self): + service = _build_privileges_service( + [ + [("SELECT ANY TABLE",), ("CREATE TABLE",)], + [("UTL_HTTP",), ("DBMS_CRYPTO",)], + ] + ) + + assert service.public_system_privileges == [ + "CREATE TABLE", + "SELECT ANY TABLE", + ] + assert service.public_execute_packages == ["DBMS_CRYPTO", "UTL_HTTP"] + + def test_empty_grants(self): + service = _build_privileges_service([[], []]) + + assert service.public_system_privileges == [] + assert service.public_execute_packages == [] + + def test_query_error_returns_empty(self): + service = _build_privileges_service( + [Exception("ORA-00942: table or view does not exist")] * 2 + ) + + assert service.public_system_privileges == [] + assert service.public_execute_packages == [] diff --git a/tests/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system_test.py b/tests/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system_test.py new file mode 100644 index 0000000000..af26b60b10 --- /dev/null +++ b/tests/providers/oracledb/services/users/users_default_tablespace_not_system/users_default_tablespace_not_system_test.py @@ -0,0 +1,88 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.users.users_service import User +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.users." + "users_default_tablespace_not_system." + "users_default_tablespace_not_system.users_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.users.users_default_tablespace_not_system.users_default_tablespace_not_system import ( + users_default_tablespace_not_system, + ) + + return users_default_tablespace_not_system().execute() + + +def _user(name="APPUSER", **overrides): + defaults = { + "name": name, + "account_status": "OPEN", + "profile": "DEFAULT", + "default_tablespace": "USERS", + "oracle_maintained": False, + "password_life_time": "180", + "failed_login_attempts": "10", + "inactive_account_time": "30", + "password_verify_function": "ORA12C_VERIFY_FUNCTION", + } + defaults.update(overrides) + return User(**defaults) + + +def _build_client(users=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.users = users if users is not None else [] + return client + + +class Test_users_default_tablespace_not_system: + def test_no_users(self): + assert _run_check(_build_client()) == [] + + def test_pass_with_application_tablespace(self): + findings = _run_check(_build_client([_user()])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "USERS" in findings[0].status_extended + + def test_fail_with_system_tablespace(self): + findings = _run_check(_build_client([_user(default_tablespace="SYSTEM")])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "reserved tablespace SYSTEM" in findings[0].status_extended + + def test_fail_with_sysaux_tablespace(self): + findings = _run_check(_build_client([_user(default_tablespace="SYSAUX")])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + def test_oracle_maintained_users_are_skipped(self): + findings = _run_check( + _build_client( + [ + _user( + name="SYS", + oracle_maintained=True, + default_tablespace="SYSTEM", + ) + ] + ) + ) + assert findings == [] diff --git a/tests/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited_test.py b/tests/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited_test.py new file mode 100644 index 0000000000..e348ba8b6c --- /dev/null +++ b/tests/providers/oracledb/services/users/users_failed_login_attempts_limited/users_failed_login_attempts_limited_test.py @@ -0,0 +1,85 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.users.users_service import User +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.users." + "users_failed_login_attempts_limited." + "users_failed_login_attempts_limited.users_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.users.users_failed_login_attempts_limited.users_failed_login_attempts_limited import ( + users_failed_login_attempts_limited, + ) + + return users_failed_login_attempts_limited().execute() + + +def _user(name="APPUSER", **overrides): + defaults = { + "name": name, + "account_status": "OPEN", + "profile": "DEFAULT", + "default_tablespace": "USERS", + "oracle_maintained": False, + "password_life_time": "180", + "failed_login_attempts": "10", + "inactive_account_time": "30", + "password_verify_function": "ORA12C_VERIFY_FUNCTION", + } + defaults.update(overrides) + return User(**defaults) + + +def _build_client(users=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.users = users if users is not None else [] + return client + + +class Test_users_failed_login_attempts_limited: + def test_no_users(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check(_build_client([_user()])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == "APPUSER" + assert "locks after 10 failed login attempts" in findings[0].status_extended + + def test_fail(self): + findings = _run_check(_build_client([_user(failed_login_attempts="UNLIMITED")])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_name == "APPUSER" + assert "unlimited failed login attempts" in findings[0].status_extended + + def test_oracle_maintained_users_are_skipped(self): + findings = _run_check( + _build_client( + [ + _user( + name="SYS", + oracle_maintained=True, + failed_login_attempts="UNLIMITED", + ) + ] + ) + ) + assert findings == [] diff --git a/tests/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured_test.py b/tests/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured_test.py new file mode 100644 index 0000000000..bb21b68029 --- /dev/null +++ b/tests/providers/oracledb/services/users/users_inactive_account_lock_configured/users_inactive_account_lock_configured_test.py @@ -0,0 +1,85 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.users.users_service import User +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.users." + "users_inactive_account_lock_configured." + "users_inactive_account_lock_configured.users_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.users.users_inactive_account_lock_configured.users_inactive_account_lock_configured import ( + users_inactive_account_lock_configured, + ) + + return users_inactive_account_lock_configured().execute() + + +def _user(name="APPUSER", **overrides): + defaults = { + "name": name, + "account_status": "OPEN", + "profile": "DEFAULT", + "default_tablespace": "USERS", + "oracle_maintained": False, + "password_life_time": "180", + "failed_login_attempts": "10", + "inactive_account_time": "30", + "password_verify_function": "ORA12C_VERIFY_FUNCTION", + } + defaults.update(overrides) + return User(**defaults) + + +def _build_client(users=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.users = users if users is not None else [] + return client + + +class Test_users_inactive_account_lock_configured: + def test_no_users(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check(_build_client([_user()])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == "APPUSER" + assert "locked after 30 days of inactivity" in findings[0].status_extended + + def test_fail(self): + findings = _run_check(_build_client([_user(inactive_account_time="UNLIMITED")])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_name == "APPUSER" + assert "never locked for inactivity" in findings[0].status_extended + + def test_oracle_maintained_users_are_skipped(self): + findings = _run_check( + _build_client( + [ + _user( + name="SYS", + oracle_maintained=True, + inactive_account_time="UNLIMITED", + ) + ] + ) + ) + assert findings == [] diff --git a/tests/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured_test.py b/tests/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured_test.py new file mode 100644 index 0000000000..db1dffeace --- /dev/null +++ b/tests/providers/oracledb/services/users/users_password_expiration_configured/users_password_expiration_configured_test.py @@ -0,0 +1,85 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.users.users_service import User +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.users." + "users_password_expiration_configured." + "users_password_expiration_configured.users_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.users.users_password_expiration_configured.users_password_expiration_configured import ( + users_password_expiration_configured, + ) + + return users_password_expiration_configured().execute() + + +def _user(name="APPUSER", **overrides): + defaults = { + "name": name, + "account_status": "OPEN", + "profile": "DEFAULT", + "default_tablespace": "USERS", + "oracle_maintained": False, + "password_life_time": "180", + "failed_login_attempts": "10", + "inactive_account_time": "30", + "password_verify_function": "ORA12C_VERIFY_FUNCTION", + } + defaults.update(overrides) + return User(**defaults) + + +def _build_client(users=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.users = users if users is not None else [] + return client + + +class Test_users_password_expiration_configured: + def test_no_users(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check(_build_client([_user()])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == "APPUSER" + assert "password lifetime of 180 days" in findings[0].status_extended + + def test_fail(self): + findings = _run_check(_build_client([_user(password_life_time="UNLIMITED")])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_name == "APPUSER" + assert "unlimited password lifetime" in findings[0].status_extended + + def test_oracle_maintained_users_are_skipped(self): + findings = _run_check( + _build_client( + [ + _user( + name="SYS", + oracle_maintained=True, + password_life_time="UNLIMITED", + ) + ] + ) + ) + assert findings == [] diff --git a/tests/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured_test.py b/tests/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured_test.py new file mode 100644 index 0000000000..0611e83fe2 --- /dev/null +++ b/tests/providers/oracledb/services/users/users_password_verify_function_configured/users_password_verify_function_configured_test.py @@ -0,0 +1,88 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.users.users_service import User +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.users." + "users_password_verify_function_configured." + "users_password_verify_function_configured.users_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.users.users_password_verify_function_configured.users_password_verify_function_configured import ( + users_password_verify_function_configured, + ) + + return users_password_verify_function_configured().execute() + + +def _user(name="APPUSER", **overrides): + defaults = { + "name": name, + "account_status": "OPEN", + "profile": "DEFAULT", + "default_tablespace": "USERS", + "oracle_maintained": False, + "password_life_time": "180", + "failed_login_attempts": "10", + "inactive_account_time": "30", + "password_verify_function": "ORA12C_VERIFY_FUNCTION", + } + defaults.update(overrides) + return User(**defaults) + + +def _build_client(users=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.users = users if users is not None else [] + return client + + +class Test_users_password_verify_function_configured: + def test_no_users(self): + assert _run_check(_build_client()) == [] + + def test_pass(self): + findings = _run_check(_build_client([_user()])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == "APPUSER" + assert ( + "uses the password verification function ORA12C_VERIFY_FUNCTION" + in findings[0].status_extended + ) + + def test_fail(self): + findings = _run_check(_build_client([_user(password_verify_function="NULL")])) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_name == "APPUSER" + assert "no password verification function" in findings[0].status_extended + + def test_oracle_maintained_users_are_skipped(self): + findings = _run_check( + _build_client( + [ + _user( + name="SYS", + oracle_maintained=True, + password_verify_function="NULL", + ) + ] + ) + ) + assert findings == [] diff --git a/tests/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed_test.py b/tests/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed_test.py new file mode 100644 index 0000000000..37a8df3bb8 --- /dev/null +++ b/tests/providers/oracledb/services/users/users_sample_schemas_removed/users_sample_schemas_removed_test.py @@ -0,0 +1,74 @@ +from unittest import mock +from unittest.mock import MagicMock + +from prowler.providers.oracledb.services.users.users_service import User +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DATABASE_NAME, + set_mocked_oracledb_provider, +) + +CHECK_CLIENT = ( + "prowler.providers.oracledb.services.users." + "users_sample_schemas_removed." + "users_sample_schemas_removed.users_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oracledb_provider(), + ), + mock.patch(CHECK_CLIENT, new=client), + ): + from prowler.providers.oracledb.services.users.users_sample_schemas_removed.users_sample_schemas_removed import ( + users_sample_schemas_removed, + ) + + return users_sample_schemas_removed().execute() + + +def _user(name="APPUSER", **overrides): + defaults = { + "name": name, + "account_status": "OPEN", + "profile": "DEFAULT", + "default_tablespace": "USERS", + "oracle_maintained": False, + "password_life_time": "180", + "failed_login_attempts": "10", + "inactive_account_time": "30", + "password_verify_function": "ORA12C_VERIFY_FUNCTION", + } + defaults.update(overrides) + return User(**defaults) + + +def _build_client(users=None): + client = MagicMock() + client.database_name = ORACLEDB_DATABASE_NAME + client.users = users if users is not None else [] + return client + + +class Test_users_sample_schemas_removed: + def test_pass_when_no_sample_schemas(self): + findings = _run_check(_build_client([_user()])) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == ORACLEDB_DATABASE_NAME + assert "no sample" in findings[0].status_extended + + def test_fail_when_sample_schemas_present(self): + findings = _run_check( + _build_client([_user(name="SCOTT"), _user(name="HR"), _user()]) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "HR, SCOTT" in findings[0].status_extended + + def test_single_report_even_without_users(self): + findings = _run_check(_build_client()) + assert len(findings) == 1 + assert findings[0].status == "PASS" diff --git a/tests/providers/oracledb/services/users/users_service_test.py b/tests/providers/oracledb/services/users/users_service_test.py new file mode 100644 index 0000000000..2bfaae3b14 --- /dev/null +++ b/tests/providers/oracledb/services/users/users_service_test.py @@ -0,0 +1,74 @@ +from prowler.providers.oracledb.models import OracledbSession +from prowler.providers.oracledb.services.users.users_service import Users +from tests.providers.oracledb.oracledb_fixtures import ( + ORACLEDB_DSN, + ORACLEDB_USER, + set_mocked_connection, + set_mocked_oracledb_provider, +) + +PROFILE_ROWS = [ + ("DEFAULT", "PASSWORD_LIFE_TIME", "180"), + ("DEFAULT", "FAILED_LOGIN_ATTEMPTS", "10"), + ("DEFAULT", "INACTIVE_ACCOUNT_TIME", "UNLIMITED"), + ("DEFAULT", "PASSWORD_VERIFY_FUNCTION", "NULL"), + ("APP_PROFILE", "PASSWORD_LIFE_TIME", "DEFAULT"), + ("APP_PROFILE", "FAILED_LOGIN_ATTEMPTS", "UNLIMITED"), + ("APP_PROFILE", "INACTIVE_ACCOUNT_TIME", "30"), + ("APP_PROFILE", "PASSWORD_VERIFY_FUNCTION", "ORA12C_VERIFY_FUNCTION"), +] + +USER_ROWS = [ + ("SYS", "OPEN", "DEFAULT", "SYSTEM", "Y"), + ("APPUSER", "OPEN", "APP_PROFILE", "USERS", "N"), + ("OLDUSER", "LOCKED", "DEFAULT", "USERS", "N"), +] + + +def _build_users_service(fetchall_results): + provider = set_mocked_oracledb_provider( + session=OracledbSession( + user=ORACLEDB_USER, + dsn=ORACLEDB_DSN, + connection=set_mocked_connection(fetchall_results=fetchall_results), + ) + ) + return Users(provider) + + +class TestUsersService: + def test_lists_users_with_effective_limits(self): + service = _build_users_service([PROFILE_ROWS, USER_ROWS]) + + assert len(service.users) == 3 + + appuser = next(user for user in service.users if user.name == "APPUSER") + assert appuser.profile == "APP_PROFILE" + assert appuser.oracle_maintained is False + assert appuser.is_open is True + # DEFAULT indirection resolves to the DEFAULT profile value. + assert appuser.password_life_time == "180" + assert appuser.failed_login_attempts == "UNLIMITED" + assert appuser.inactive_account_time == "30" + assert appuser.password_verify_function == "ORA12C_VERIFY_FUNCTION" + + sys_user = next(user for user in service.users if user.name == "SYS") + assert sys_user.oracle_maintained is True + assert sys_user.default_tablespace == "SYSTEM" + + old_user = next(user for user in service.users if user.name == "OLDUSER") + assert old_user.is_open is False + + def test_profiles_indexed_by_name(self): + service = _build_users_service([PROFILE_ROWS, USER_ROWS]) + + assert service.profiles["DEFAULT"]["PASSWORD_LIFE_TIME"] == "180" + assert service.profiles["APP_PROFILE"]["PASSWORD_LIFE_TIME"] == "DEFAULT" + + def test_query_error_returns_empty(self): + service = _build_users_service( + [Exception("ORA-00942: table or view does not exist")] * 2 + ) + + assert service.profiles == {} + assert service.users == [] diff --git a/uv.lock b/uv.lock index 0c73b97181..3cf22ad480 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10, <3.14" resolution-markers = [ "python_full_version >= '3.13'", @@ -3269,6 +3269,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] +[[package]] +name = "oracledb" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/01/203599755aaff4f79683c2d61d2e6dc9d30f6228485d9e0280cc358cede3/oracledb-4.0.1.tar.gz", hash = "sha256:34bbea44423ed8b24093aa859ca7ee9b6e76ea490f9acdc5f6ff01aa1083e343", size = 879077, upload-time = "2026-05-19T18:14:34.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/4e/d1a16f398963eb41a6a1024030bf6c5ead34df2f9e558fbf24759effa703/oracledb-4.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dbe8b44fea57385617838f2acfce8cc19f6c95cd9e65e7235e86b5932af1acd9", size = 4374567, upload-time = "2026-05-19T18:14:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/db/da/849e06ddd29f5d8807ba9fb93ebdf210958273eeb12ca08f72d9f07f87cb/oracledb-4.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3d54b624748cfe42248c4bc62c3f788632a2077058485a9acb3150312b1c396", size = 2499287, upload-time = "2026-05-19T18:14:43.321Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b6/d6df13f297ccf64396867da84bed8529e2c1755839207746e5ac2cc6abc2/oracledb-4.0.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29ae0ff517a3241060eeee15a321b710c3f83a688cf2da7d5729adbe212e2b00", size = 2689995, upload-time = "2026-05-19T18:14:44.832Z" }, + { url = "https://files.pythonhosted.org/packages/01/98/5dec7011c2c26c8c3c2f9ea76783e2bc6cd6ad5f0489782f9cf4637e733f/oracledb-4.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73ba32597fe1da72e0824aaa4b1900ec08a3b77268cb4eb45c733ae7e7043e70", size = 2540194, upload-time = "2026-05-19T18:14:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/09/ef/21049e705a5dbac0011e647a4dcaf1746a501e8e19da98bd1674b21206d7/oracledb-4.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ce3f25552fe58df5c266874f8b13f0a8ab7fcd09ab4b476bc15520a67527ca4b", size = 2714751, upload-time = "2026-05-19T18:14:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c1/0e02cc9f84b92a2fc8cbcb48be22b43076c511392b6ec36795e8a9ebc424/oracledb-4.0.1-cp310-cp310-win32.whl", hash = "sha256:7db5a43c29a23ed23923a29816c65c7a81fe00f2abfe6bf36d83ad952abd9b89", size = 1546877, upload-time = "2026-05-19T18:14:50.081Z" }, + { url = "https://files.pythonhosted.org/packages/b9/4a/d14ac7ed4e2f87f77807eaed88d8a2e787219407f170e80546cbebc466dd/oracledb-4.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:03afeda85bec3eca983ebf3ad9910d0f217d99300258366d287e015a041d6c13", size = 1894743, upload-time = "2026-05-19T18:14:52.038Z" }, + { url = "https://files.pythonhosted.org/packages/88/46/f38813e076a43cfa632db2b80761c0b1aa1c85dca7b89720d4984b3b2e17/oracledb-4.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:86a06d0afb3bb3a24bace0e72fb9abca2093efe0fa3457c65c13ba4eb5000b0b", size = 4351657, upload-time = "2026-05-19T18:14:53.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e6/8ad2592ae4d5657882b900bad1b1286f1b29befa0f418df8550d8f7defd6/oracledb-4.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:416b324cd7715073cf5f3d577330387ffd59741463995c25bdc2d82b3e80b88e", size = 2492515, upload-time = "2026-05-19T18:14:55.834Z" }, + { url = "https://files.pythonhosted.org/packages/08/4e/eaab1183a2ba63e7f8673e3305f5382488c773d8f51d9a705e90d8b8b3e1/oracledb-4.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce6319ee01dcbb4d74f0e2a5794c6a566f339958ecac9830c67c7070521620e2", size = 2680701, upload-time = "2026-05-19T18:14:57.418Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/824b419dc3758c86ddcb1d8f69d1e7957882e58e99a3907a45a063ed0878/oracledb-4.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:873fcca53306e2b3b445a7d657cddc19e415a7aa7e392c473dfd1a3ae3970989", size = 2542151, upload-time = "2026-05-19T18:14:59.173Z" }, + { url = "https://files.pythonhosted.org/packages/be/5c/35b60bb1c85be5607913a24467052d6496eed9493793989de2a6b9b71147/oracledb-4.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b09eec35681d72c9476e6d715b89bb775724a31e7363df6beba7470494ea8040", size = 2709740, upload-time = "2026-05-19T18:15:01.03Z" }, + { url = "https://files.pythonhosted.org/packages/79/22/d4639df519c7cca02f45b3404c9e7934970f50a0296f704be7b1805b1ce7/oracledb-4.0.1-cp311-cp311-win32.whl", hash = "sha256:08e84a6af1b6e5921dba088dd9fc0738927206eafe5ce9763c34195f87556849", size = 1543018, upload-time = "2026-05-19T18:15:02.516Z" }, + { url = "https://files.pythonhosted.org/packages/20/88/7304ff7d512f45d6804501e404ceb7e567eb15661cd7d06c9ffaa6025cb5/oracledb-4.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:3b5ef1676a27b7e0a7ec55be27fd8f6d28d1601f5e8dfdae78705909f25b7c0a", size = 1901166, upload-time = "2026-05-19T18:15:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b5/079fe8aa90cb769c68a54dde7591b374c64260d3d448c086acf4585cb8fc/oracledb-4.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:90586b3c7729b9cf3d40df902e81257f01e15e3408d8b6b9dbf91e939b64f72c", size = 1586727, upload-time = "2026-05-19T18:15:05.523Z" }, + { url = "https://files.pythonhosted.org/packages/53/1a/237ddaa33fe9576e1bb1ee9008eba2269fe49dee974a581465589eac658e/oracledb-4.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c05a01d6ad610a88c2aa1a43b1dc0a8485f5fbd4374d2b36908859d4205de192", size = 4345951, upload-time = "2026-05-19T18:15:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bd/2ee10e0bd4d7a44db68a3f10d8941a5ea762bdb9dcff9b37739b2554e011/oracledb-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf61e42b9ef723dbdd0b23032b695e872009ed7341003df59d9a97cd960df977", size = 2290431, upload-time = "2026-05-19T18:15:09.041Z" }, + { url = "https://files.pythonhosted.org/packages/80/54/98fac98c9c1e2461512dcad81d2bd491fb7fe779d5044614867b2e827a78/oracledb-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2d394453f669858bec942ff0da18b6ebade296ece823d582ad2b464ed5c6c90", size = 2493620, upload-time = "2026-05-19T18:15:10.635Z" }, + { url = "https://files.pythonhosted.org/packages/58/71/ebeade4753fd20b6d9cc7568c3853bec44425f24982def220835544008ca/oracledb-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d7cd278d59780e22e0a7451d208460756d779dc62b55bdbd95652f9640fbf8c3", size = 2329161, upload-time = "2026-05-19T18:15:12.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/4ec3ef256633126b669b99bf14e1c2af264ecb25c28f80f5365ea279c39a/oracledb-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b73820521eccd290506af94e1ffb9a8a5941b4018e3861df9b040652a7cef123", size = 2513518, upload-time = "2026-05-19T18:15:14.863Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7a/4506477b52bb2db61a114202beab19bf24859f9fdc0979b8031042582477/oracledb-4.0.1-cp312-cp312-win32.whl", hash = "sha256:8fcad6d9628923281bf21e48a391ac2f87ec6950dc63381d8fea470e3128aef0", size = 1498632, upload-time = "2026-05-19T18:15:16.361Z" }, + { url = "https://files.pythonhosted.org/packages/dd/66/3d2246e5473b85c61d4b206ea9fcf50619db6b60a1425e34a3a1c1f64c44/oracledb-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:523b3356cde9d588ba250cefafdfc34869233d65c179f805ea6e4d3d6b209a7f", size = 1846730, upload-time = "2026-05-19T18:15:18.532Z" }, + { url = "https://files.pythonhosted.org/packages/dd/95/70420ebc1f051cff48cffb48f720540446d3fc497f7d9ba6f2e63337cb3f/oracledb-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:10204432f0eea8707a79c75bdccb84071e43fd19c658cb3b34d1746b12c6e7fe", size = 1518529, upload-time = "2026-05-19T18:15:19.916Z" }, + { url = "https://files.pythonhosted.org/packages/e3/52/c95031186ddec3788ee72708df406a6b7bafa308bd04722b7d5857942366/oracledb-4.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:443b2f03461e873ccd73dff3d8541fcf974c05e13e296a6687ffbb0c4a72c0a1", size = 4331008, upload-time = "2026-05-19T18:15:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c3/7aaf12fe0f8b8323bce4647762722268da6b3b04ab1ff2349e4b43d30515/oracledb-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae894ca2705929eb0ac228329336fd03388ad6e3b54002be6f5d4400a8feaf52", size = 2284457, upload-time = "2026-05-19T18:15:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/db/1e/48af5b2c559ee2fbee2ad13372858f34f2260cd9727b8ccad6cbe26e09b8/oracledb-4.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b42725337f80d433a3bd2928c08667e5b89da9ce05cf9ae3a4189c4fc4805ea", size = 2506136, upload-time = "2026-05-19T18:15:26.057Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/50cb8f7a3bc7ef9edc61db5fbf0ee2af9a4f487f4967eb54cc9f330ea908/oracledb-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e13ff1e6f28fdb863180d23fa94cb42c619c29d2981e24992431e51b97caa54", size = 2332705, upload-time = "2026-05-19T18:15:27.632Z" }, + { url = "https://files.pythonhosted.org/packages/48/7d/6eea9e9ffece7d270a7d3caf364b5033fa8c73a0f377c10750eca8760a6e/oracledb-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e36581bb10e719d928dad12018c2d42606db2c34f49d6665b06f701f049255f0", size = 2528849, upload-time = "2026-05-19T18:15:29.437Z" }, + { url = "https://files.pythonhosted.org/packages/97/5c/553afdf90a144e25c5ee9825fbbc0c88d52bcf2b1dd7c5b509cad1022a60/oracledb-4.0.1-cp313-cp313-win32.whl", hash = "sha256:86ac65cbc8d29626b1d9d203f9151566c26a78e55bdfc030c06169ae8017f458", size = 1503094, upload-time = "2026-05-19T18:15:30.838Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/9305fa2755b234352e29dd5ed6063d017a661ccb08c06da5e14db90b143d/oracledb-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:a029dcee759bca56a8c95e952040c3d3f57e5ec05965355293b21930a66967fb", size = 1846068, upload-time = "2026-05-19T18:15:32.475Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d8/6995a755dbdeee1eab1a6cb77283e3ab6634631512c47e2c3a64efcb515f/oracledb-4.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:20a10f903c8da59e9689a98bd68012f78fa19bed950ad9f19cd8f5b8b97e73a0", size = 1518345, upload-time = "2026-05-19T18:15:34.156Z" }, +] + [[package]] name = "os-service-types" version = "1.8.2" @@ -3620,6 +3663,7 @@ dependencies = [ { name = "oci" }, { name = "okta" }, { name = "openstacksdk" }, + { name = "oracledb" }, { name = "pandas" }, { name = "py-iam-expand" }, { name = "py-ocsf-models" }, @@ -3729,6 +3773,7 @@ requires-dist = [ { name = "oci", specifier = "==2.169.0" }, { name = "okta", specifier = "==3.4.2" }, { name = "openstacksdk", specifier = "==4.2.0" }, + { name = "oracledb", specifier = "==4.0.1" }, { name = "pandas", specifier = "==2.2.3" }, { name = "py-iam-expand", specifier = "==0.3.0" }, { name = "py-ocsf-models", specifier = "==0.8.1" },