Compare commits

..

106 Commits

Author SHA1 Message Date
StylusFrost 0ffb0f6f45 Merge remote-tracking branch 'origin/PROWLER-1774-dynamic-provider-kwargs-connection' into PROWLER-1775-dynamic-credential-validation 2026-06-07 14:50:02 +02:00
StylusFrost 2b1c9c3381 Merge remote-tracking branch 'origin/PROWLER-1773-dynamic-provider-resolution' into PROWLER-1774-dynamic-provider-kwargs-connection 2026-06-07 14:49:59 +02:00
StylusFrost 983c2141ff Merge remote-tracking branch 'origin/PROWLER-1772-provider-type-storage-varchar' into PROWLER-1773-dynamic-provider-resolution 2026-06-07 14:49:56 +02:00
StylusFrost cdac1ce915 Merge remote-tracking branch 'origin/PROWLER-1771-public-dynamic-provider-class-resolver' into PROWLER-1772-provider-type-storage-varchar 2026-06-07 14:49:53 +02:00
StylusFrost 789dbfb620 Merge remote-tracking branch 'origin/PROWLER-1444-multi-provider-compliance-entry-points' into PROWLER-1771-public-dynamic-provider-class-resolver 2026-06-07 14:49:31 +02:00
StylusFrost d7346a6e63 docs(changelog): add entry for external universal compliance via entry points 2026-06-07 14:46:05 +02:00
StylusFrost 8efff5ccf8 Merge remote-tracking branch 'origin/PROWLER-1774-dynamic-provider-kwargs-connection' into PROWLER-1775-dynamic-credential-validation 2026-06-07 14:27:34 +02:00
StylusFrost 900a668ddc Merge remote-tracking branch 'origin/PROWLER-1773-dynamic-provider-resolution' into PROWLER-1774-dynamic-provider-kwargs-connection 2026-06-07 14:27:13 +02:00
StylusFrost f34daf1e69 Merge remote-tracking branch 'origin/PROWLER-1772-provider-type-storage-varchar' into PROWLER-1773-dynamic-provider-resolution 2026-06-07 14:25:41 +02:00
StylusFrost 3c72e9d25e Merge remote-tracking branch 'origin/PROWLER-1771-public-dynamic-provider-class-resolver' into PROWLER-1772-provider-type-storage-varchar 2026-06-07 14:22:41 +02:00
StylusFrost 5392a87a30 Merge remote-tracking branch 'origin/PROWLER-1444-multi-provider-compliance-entry-points' into PROWLER-1771-public-dynamic-provider-class-resolver
# Conflicts:
#	prowler/CHANGELOG.md
2026-06-07 14:04:36 +02:00
StylusFrost 40da359804 feat(compliance): discover external universal frameworks via entry points
External plug-ins ship multi-provider (universal-schema) frameworks through a
dedicated prowler.compliance.universal entry point group, separate from the
per-provider prowler.compliance group. Both get_bulk_compliance_frameworks_universal
(loading) and get_available_compliance_frameworks (listing / --compliance
choices) scan the new group. Built-ins load first and win on a name collision;
multiple packages under the same provider are merged. load_compliance_framework
gains fatal=False so the legacy external path skips a non-legacy JSON with a
warning instead of aborting the run.
2026-06-07 13:56:59 +02:00
StylusFrost 8a0d56786d fix(changelog): resolve leftover merge conflict marker
Remove a stray '=======' conflict marker left in prowler/CHANGELOG.md after
the master merge, and move the elbv2_alb_drop_invalid_header_fields_enabled
entry from Fixed to Added where it belongs.
2026-06-05 15:01:11 +02:00
StylusFrost f729c5a9f0 Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-06-05 14:44:50 +02:00
StylusFrost f9682c1354 fix(compliance): make GenericCompliance tolerant of provider-specific schemas
GenericCompliance is the documented last-resort renderer, but it read the
universal attribute fields (Section, SubSection, SubGroup, Service, Type,
Comment) directly and raised AttributeError on frameworks whose schema does
not declare them (CIS, ENS, ISO27001), dropping the whole compliance CSV.
Read all six fields with getattr defaulting to None, and dedupe the finding
and manual rows into a single helper.
2026-06-05 14:38:42 +02:00
potato-20 6f172a5c19 feat(elbv2): add elbv2_alb_drop_invalid_header_fields_enabled check (FSBP ELB.4) (#11471)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-05 14:26:07 +02:00
StylusFrost 29825f9a2f fix(provider): move get_output_options default to call site
Resolve the provider<->models import cycle CodeQL flagged (py/cyclic-import
#7267, #7268). provider.py no longer imports models: get_output_options
stays override-only and __main__ falls back to a new default_output_options
helper in models.py when a provider does not implement it. models.py keeps
its original module-level Provider import (one-way, no cycle).
2026-06-05 14:21:49 +02:00
StylusFrost 356e6e2bb4 fix(provider): default get_summary_entity instead of raising
External providers that do not override get_summary_entity no longer cause
the summary table to be silently dropped. The base contract returns
(self.type, account_id), mirroring the get_output_options default.
2026-06-05 14:05:56 +02:00
StylusFrost 8bc8b16a77 fix(provider): avoid import cycle in get_output_options default
Move the models.py Provider import (used only in the shodan path) to a
local import so models no longer depends on provider at module level. This
breaks the provider <-> models import cycle CodeQL flagged after the
generic OutputOptions default was added, and lets provider.py import
ProviderOutputOptions without a cycle. The output_file_timestamp import is
consolidated into the existing top-level config import.
2026-06-05 13:57:36 +02:00
StylusFrost efa3283a25 fix(provider): return generic OutputOptions default instead of raising
External providers that do not override get_output_options no longer abort
the run with NotImplementedError. The base contract returns a generic
ProviderOutputOptions, honoring arguments.output_filename and otherwise
falling back to a provider-typed filename. Built-ins are unaffected.
2026-06-05 13:42:29 +02:00
Pedro Martín a7d180ea5b feat(dashboard): add AWS AI Security Framework compliance view (#11475) 2026-06-05 13:28:31 +02:00
Pedro Martín d4bbc8b5ad fix(jira): avoid 400 INVALID_INPUT on findings with empty field (#11474) 2026-06-05 13:26:28 +02:00
Aline Almeida a5bc226f11 fix(gcp): pass iam_service_account_unused for disabled service accounts (#11467) 2026-06-05 12:07:30 +02:00
Pablo Fernandez Guerra (PFE) 3a3d9d6146 chore(ui): type process.env via ambient NodeJS.ProcessEnv (#11328)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-06-05 08:31:16 +02:00
Oleksandr_Sanin bcd282d3d0 fix(gcp): honour org-level aggregated sinks in logging_sink_created check (#11355)
Signed-off-by: Oleksandr Sanin <alexaaander.sanin@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-04 12:07:01 +02:00
Pedro Martín eb7949c884 fix(ui): show delete user action only for the current user (#11447)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-03 17:03:12 +02:00
Alejandro Bailo e60a4462e5 fix(ui): refine add-provider wizard flow between scans and providers (#11424) 2026-06-03 16:08:06 +02:00
StylusFrost 4775f11dbf Merge remote-tracking branch 'origin/PROWLER-1774-dynamic-provider-kwargs-connection' into PROWLER-1775-dynamic-credential-validation 2026-06-03 13:12:28 +02:00
StylusFrost a471c82a7e Merge remote-tracking branch 'origin/PROWLER-1773-dynamic-provider-resolution' into PROWLER-1774-dynamic-provider-kwargs-connection 2026-06-03 13:12:13 +02:00
StylusFrost 849c399c93 Merge remote-tracking branch 'origin/PROWLER-1772-provider-type-storage-varchar' into PROWLER-1773-dynamic-provider-resolution 2026-06-03 13:12:02 +02:00
StylusFrost e25758ba8e Merge remote-tracking branch 'origin/PROWLER-1771-public-dynamic-provider-class-resolver' into PROWLER-1772-provider-type-storage-varchar
# Conflicts:
#	api/CHANGELOG.md
2026-06-03 13:11:38 +02:00
StylusFrost c4effd7a60 Merge remote-tracking branch 'origin/PROWLER-1391-provider-contract-dynamic-discovery' into PROWLER-1771-public-dynamic-provider-class-resolver 2026-06-03 13:07:55 +02:00
StylusFrost 38788b7922 Merge remote-tracking branch 'origin/master' into PROWLER-1391-provider-contract-dynamic-discovery
# Conflicts:
#	prowler/CHANGELOG.md
2026-06-03 12:15:24 +02:00
StylusFrost 5daa39c4cb feat: bind external provider secret validation to its secret type
- get_credentials_schema returns schemas keyed by secret type
- validate an external provider's secret against the schema for its
  secret_type instead of accepting any declared schema
- reject a secret_type the provider does not declare
2026-06-01 22:09:21 +02:00
StylusFrost 116fb7083d fix(api): reject non-object external provider secret
- Validate the secret is a JSON object before the no-schema path accepts it,
  so a list/string/null cannot be persisted and fail later at {**secret}
- Add parametrized coverage for list/string/null/int payloads
2026-06-01 21:35:34 +02:00
StylusFrost 77b2ffeb54 Merge remote-tracking branch 'origin/PROWLER-1774-dynamic-provider-kwargs-connection' into PROWLER-1775-dynamic-credential-validation 2026-06-01 21:35:10 +02:00
StylusFrost 21e63ebc7e Merge remote-tracking branch 'origin/PROWLER-1773-dynamic-provider-resolution' into PROWLER-1774-dynamic-provider-kwargs-connection
# Conflicts:
#	prowler/providers/common/provider.py
2026-06-01 21:30:39 +02:00
StylusFrost bcc697f42a Merge remote-tracking branch 'origin/PROWLER-1772-provider-type-storage-varchar' into PROWLER-1773-dynamic-provider-resolution 2026-06-01 21:29:02 +02:00
StylusFrost c94456c131 perf(api): share cached provider-type choices across filters and serializer
- Move get_provider_type_choices into a leaf module so the provider
  serializer field reuses the filters' cached list instead of recomputing
  SDK provider discovery on every request
2026-06-01 21:18:39 +02:00
StylusFrost 28433362c5 fix(api): make provider enum-to-varchar migration deploy-safe
- Backfill provider_str synchronously in 0095 (single UPDATE) so the column
  is populated before 0096 sets it NOT NULL, removing the Celery race
- Recreate the unique index inside 0096's transaction with a lock_timeout,
  closing the duplicate-provider window left by the concurrent rebuild
- Drop migration 0097 and the now-unused backfill_provider_str task
2026-06-01 21:18:31 +02:00
StylusFrost 9c7b33157f Merge remote-tracking branch 'origin/PROWLER-1771-public-dynamic-provider-class-resolver' into PROWLER-1772-provider-type-storage-varchar 2026-06-01 20:45:01 +02:00
StylusFrost a111ae763c chore(sdk): tighten get_class docstring and unknown-provider test
- Reword get_class docstring: it may populate the _ep_providers cache,
  rather than claiming "no global state"
- Assert ImportError specifically in the unknown-provider test to enforce
  the public API contract
- Drop the unused provider_class_name left over after get_class delegation
2026-06-01 20:28:19 +02:00
StylusFrost ece6af5dd3 Merge remote-tracking branch 'origin/PROWLER-1391-provider-contract-dynamic-discovery' into PROWLER-1771-public-dynamic-provider-class-resolver
# Conflicts:
#	prowler/providers/common/provider.py
2026-06-01 20:20:50 +02:00
StylusFrost b7b5565aeb Merge remote-tracking branch 'origin/master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-06-01 20:09:48 +02:00
StylusFrost 9c7afd64c5 fix(sdk): match compliance provider segment exactly in get_bulk
- Compare the module's last dotted segment instead of substring
- Prevent a provider name from capturing overlapping built-ins
- Add parametrized regression test (cloud, git, work, open cases)
- Update get_bulk test mock to the real dotted module name
2026-06-01 19:51:27 +02:00
StylusFrost 64e82682bd fix(sdk): detect shadowed provider plug-ins without loading them
- Match shadowing entry point by name instead of calling ep.load()
- Prevent plug-in code from executing during a built-in run
- Update regression test to assert ep.load is never called
2026-06-01 19:44:17 +02:00
StylusFrost 5070ce39c2 fix(sdk): guard built-in providers in is_tool_wrapper_provider
- Short-circuit on is_builtin_provider before loading entry points
- Prevent same-name plug-ins from flipping a built-in onto the tool-wrapper path
- Avoid executing plug-in code via ep.load() for built-in names
- Add regression test asserting ep.load is never called
2026-06-01 19:43:36 +02:00
StylusFrost b8d3312577 feat: validate external provider secrets via SDK credential schema
- Add get_credentials_schema to the provider contract
- Validate non-built-in secrets against the declared schema; built-ins unchanged
- Accept secrets as-is when no schema is declared (validated at connection)
2026-06-01 01:08:41 +02:00
StylusFrost 51581c35ec feat: add SDK contract for dynamic provider construction args
- Add get_scan_arguments/get_connection_arguments to the provider contract
- Route non-built-in providers through the contract; built-ins unchanged
- Cover the external dynamic path in tests
2026-06-01 00:19:17 +02:00
StylusFrost cd15ed07eb feat(api): resolve provider class dynamically via the SDK resolver
- Replace the hardcoded provider match with Provider.get_class
- Drop the closed provider-type union and TYPE_CHECKING imports
- Cover built-in and external entry-point resolution in tests
2026-05-31 23:41:10 +02:00
StylusFrost 30f8244ec1 docs(api): add changelog entry for provider varchar migration 2026-05-31 23:33:23 +02:00
StylusFrost 37323e691a feat(api): drive provider-type filters from SDK-available providers
- Replace the static provider enum in filters with the SDK provider list
- Cache the provider-type choices for hot list endpoints
- Drop the dead provider enum filter override
2026-05-31 23:33:23 +02:00
StylusFrost f14778438e feat(api): validate provider against SDK-available providers
- Drive provider validity from the SDK instead of a static enum
- Tolerate providers without a uid validator at model clean()
- Accept any SDK-exposed provider in the provider serializer field
2026-05-31 23:33:23 +02:00
StylusFrost 64fdea2954 feat(api): store provider as varchar and drop the enum type
- Promote the synced shadow column into provider, dropping the enum
- Rebuild the partial unique index concurrently after the swap
- Keep provider input validation at the serializer layer
2026-05-31 23:33:23 +02:00
StylusFrost 7dc0895581 feat(api): backfill provider_str shadow column per-tenant
- Add batched per-tenant backfill job for the provider_str column
- Register backfill task on the backfill queue
- Dispatch the backfill from a data-only migration
2026-05-31 23:33:23 +02:00
StylusFrost 383e9c6bd8 feat(api): add provider_str shadow column synced by trigger
- Add nullable provider_str CharField mirroring the provider enum column
- DB trigger keeps provider_str in sync on INSERT and UPDATE
- First step of the zero-downtime migration of the provider enum to varchar
2026-05-31 23:33:23 +02:00
StylusFrost 459f986abe Merge branch 'PROWLER-1391-provider-contract-dynamic-discovery' into PROWLER-1771-public-dynamic-provider-class-resolver
# Conflicts:
#	prowler/CHANGELOG.md
2026-05-31 20:18:56 +02:00
StylusFrost 468234577c docs(sdk): move #10700 changelog entries to 5.30.0 unreleased 2026-05-31 20:17:50 +02:00
StylusFrost fe821a41ea Merge branch 'PROWLER-1391-provider-contract-dynamic-discovery' into PROWLER-1771-public-dynamic-provider-class-resolver
# Conflicts:
#	prowler/CHANGELOG.md
2026-05-31 20:05:35 +02:00
StylusFrost c1e131766d fix(sdk): sync CLI parser provider list with available built-ins
- Add okta, scaleway, stackit to known_providers so they no longer
  appear as dynamically-discovered "extra" providers
- Fix missing comma in usage string (stackitvercel -> stackit,vercel)
2026-05-31 19:53:32 +02:00
StylusFrost e1ade761b5 Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-05-31 19:30:23 +02:00
StylusFrost 64907898f7 Merge branch 'PROWLER-1391-provider-contract-dynamic-discovery' into PROWLER-1771-public-dynamic-provider-class-resolver
# Conflicts:
#	prowler/CHANGELOG.md
2026-05-31 19:10:18 +02:00
StylusFrost a6ae4903b8 docs(sdk): move #10700 changelog entries to 5.29.0 unreleased
- Move "external/custom providers" (Added) and namespaced-config unwrap
  (Fixed) out of the released 5.26.0/5.25.0 blocks
- Remove the duplicated Fixed section left in 5.25.0
2026-05-31 19:09:19 +02:00
StylusFrost dde265731c docs(sdk): add changelog entry for Provider.get_class 2026-05-31 18:57:19 +02:00
StylusFrost 073dbb74f6 feat(sdk): add Provider.get_class dynamic provider resolver
- Add public get_class() resolving built-in and entry-point providers
- Refactor init_global_provider to use it; collision warning stays there
- Refactor get_providers_help_text to use it
2026-05-31 18:52:03 +02:00
StylusFrost 03cacb83d1 fix(sdk): gate external mutelist delegate to non-builtin providers
- Azure/Cloudflare set per-finding mutelist args inside the loop;
  the external else-branch was overwriting them
- Use is_builtin_provider to scope get_mutelist_finding_args to
  truly external providers
2026-05-27 17:59:24 +02:00
StylusFrost b25a8e5b6e ci(sdk): switch external provider tests from poetry to uv
- Replace poetry.lock filter with uv.lock
- Run pytest via uv to match the migrated workflow
2026-05-27 17:33:46 +02:00
StylusFrost b3c0f78801 style(sdk): remove trailing whitespace on blank lines
- prowler/lib/check/check.py
- prowler/providers/common/provider.py
2026-05-27 17:20:27 +02:00
StylusFrost ca72922dca Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-05-27 17:12:54 +02:00
StylusFrost b13baa9076 refactor(sdk): scope ImageBaseException catch to image provider in __main__
After this PR, the tool-wrapper else branch in __main__.py covers iac,
image, and any external tool-wrapper provider registered via entry
points (anything Provider.is_tool_wrapper_provider returns True for and
that is not llm). The existing `except ImageBaseException` was specific
to the Image provider but lived in a branch that was no longer
Image-only — its name no longer matched its scope, misleading plug-in
authors reading the file.

Move the catch inside an explicit `if provider == "image":` branch so
the name matches what it catches. iac and external tool-wrapper
providers fall through to the outer `except Exception` backstop for
unexpected failures, which was already the effective behavior in
practice (they do not raise ImageBaseException).

No behavior change — pure refactor for legibility, flagged by reviewer
on the PR #10700 review of 2026-05-06.
2026-05-07 10:11:37 +02:00
StylusFrost 4fb14bbb21 perf(sdk): cache misses in Provider._load_ep_provider
Repeated lookups for unknown provider names (built-ins, typos, names
with no registered entry point) re-iterated entry_points() on every
call because only hits were cached. importlib.metadata.entry_points()
walks the metadata of every installed Python package, so the cost is
proportional to the size of the venv, not just to Prowler.

Caches None on miss so subsequent lookups hit the existing
`if name in Provider._ep_providers` short-circuit and return
immediately. Aligns Provider._ep_providers with the symmetric cache
in tool_wrapper._ep_class_cache, which already had this behavior.

Includes a regression test that mocks importlib.metadata.entry_points,
calls _load_ep_provider("nonexistent") twice, and asserts entry_points
is invoked exactly once.

Also underscore-prefix the remaining unused parameters on the abstract
Provider stubs (get_output_options, get_stdout_detail,
generate_compliance_output, display_compliance_table) so vulture stops
flagging them now that the file is in the diff. Same pattern applied
in bbe3a7dbf for the previous batch.
2026-05-07 10:00:07 +02:00
StylusFrost e5b9fee942 fix(sdk): dedupe entry-point compliance frameworks against built-ins
The entry-point loop in get_available_compliance_frameworks appended
framework names without checking whether the same name was already
provided by a built-in. The loader (Compliance.get_bulk) already
respects "built-in wins" via an explicit guard, but the listing
function did not, causing --list-compliance and
--list-compliance-requirements to print the same name twice on
collision with a built-in.

Adds the same dedup guard already used by the universal frameworks
loop a few lines above, restoring symmetry across the three
compliance discovery layers.

Includes a regression test that registers a fake entry point with
a name colliding with a known built-in (cis_2.0_aws) and asserts
the framework appears exactly once in the resulting list.
2026-05-07 09:32:48 +02:00
StylusFrost 020388824e fix(sdk): silence CodeQL py/not-named-self on CheckMetadata validators
Stack @classmethod under @validator for the five validators that take
cls as the first parameter. Pydantic v1 was already treating them as
classmethods via signature introspection, so runtime behavior is
unchanged. The explicit decorator resolves CodeQL alerts #6240-#6244.

Adds the existing # noqa: F841 marker (already used for cls in the
older validators of this file) so vulture does not flag cls as unused.

Affected validators in prowler/lib/check/models.py:
- validate_check_title
- validate_related_url
- validate_recommendation_url
- validate_description
- validate_risk
2026-05-06 18:26:22 +02:00
StylusFrost cf99e02ceb Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-05-05 08:57:11 +02:00
StylusFrost 9681901174 style(sdk): satisfy black and vulture in test_dynamic_provider_loading
Two unrelated lint blockers that CI surfaced once the file was in scope
of `black --check .` and `vulture` on the changed-file path:

- Reformat one if/and conditional that black wanted on a single line.
- Underscore-prefix unused parameter names on FakeProvider stub methods
  (from_cli_args, get_output_options, display_compliance_table,
  generate_compliance_output, FakePureContractProvider.from_cli_args)
  and the throwaway **kw in a MagicMock side_effect lambda. These are
  test fixtures whose method bodies don't reference those args; the
  signatures must match the abstract contract on Provider, so we keep
  positions/types and just rename to indicate intentional non-use.

No logic change.
2026-05-03 22:55:24 +02:00
StylusFrost bbe3a7dbf8 refactor(sdk): extract is_builtin_provider to leaf module to break import cycle
CodeQL flagged a cyclic import after `prowler/lib/check/utils.py` and
`prowler/lib/check/check.py` started importing `Provider` from
`prowler.providers.common.provider`. That module transitively imports
`prowler.config.config`, which imports back into `prowler.lib.check.*`
(`compliance_models`, `external_tool_providers`) — closing the cycle.

Apply the same pattern already used for `is_tool_wrapper_provider`:
extract the predicate to a leaf module, `prowler.providers.common.builtin`,
that depends only on `importlib.util`. `Provider.is_builtin` delegates to
the leaf, and call sites in `prowler.lib.check.*` now import directly
from the leaf — no more cycle.

Also underscore-prefix unused parameters on the abstract stubs in
`Provider` (get_finding_output_data, generate_compliance_output,
display_compliance_table) so vulture stops flagging them now that the
file is in the diff.
2026-05-03 22:46:00 +02:00
StylusFrost 0672c80563 fix(sdk): guard find_spec with is_builtin for external provider discovery
Calling importlib.util.find_spec on prowler.providers.{provider}.services
for an external provider propagates ModuleNotFoundError when the parent
package prowler.providers.{provider} does not exist, instead of returning
None. This caused recover_checks_from_provider, _resolve_check_module and
Scan.scan to fail with "No module named 'prowler.providers.{external}'"
even though the plug-in registered its checks via entry points correctly.

Gate the built-in branch on Provider.is_builtin (which already wraps the
find_spec in try/except) and reuse _resolve_check_module from Scan.scan
so external providers fall through to the entry-point lookup.
2026-05-03 22:31:31 +02:00
StylusFrost 92d7ea2170 Merge remote-tracking branch 'origin/master' into PROWLER-1391-provider-contract-dynamic-discovery
# Conflicts:
#	prowler/config/config.py
2026-05-03 19:41:25 +02:00
StylusFrost c7aa536896 fix(sdk): built-in wins on plug-in collision for providers and checks 2026-04-30 19:50:19 +02:00
StylusFrost e7f23bb13f fix(sdk): propagate provider argument from report to stdout_report 2026-04-30 14:06:17 +02:00
StylusFrost 82132a9341 fix(sdk): use find_spec to distinguish missing vs broken built-ins 2026-04-30 13:53:06 +02:00
StylusFrost 5e876579f8 fix(sdk): use is_tool_wrapper_provider for compliance framework gate 2026-04-30 10:12:24 +02:00
StylusFrost be49fd8c4e Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-04-28 14:48:21 +02:00
StylusFrost 15d8f1642e test(sdk): unit tests for tool_wrapper leaf module 2026-04-28 14:45:07 +02:00
StylusFrost 79f12f3617 refactor(sdk): extract is_tool_wrapper_provider to leaf module to break import cycle 2026-04-28 13:57:27 +02:00
StylusFrost 6715361246 fix(sdk): restore dynamic external providers help in CLI epilog 2026-04-28 12:47:50 +02:00
StylusFrost 45e946cd87 Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-04-28 12:42:18 +02:00
StylusFrost 7836905b82 fix(sdk): consult Provider.is_tool_wrapper_provider in check discovery 2026-04-28 12:20:42 +02:00
StylusFrost 52f6653ccf fix(sdk): use equality not substring in provider dispatch chain 2026-04-28 11:54:23 +02:00
StylusFrost a5de6608ae fix(sdk): restore llm in parser usage line to match epilog 2026-04-28 10:50:43 +02:00
StylusFrost 1cdce02397 fix(sdk): use startswith("-") to detect CLI flags so external provider names with hyphens are not misparsed 2026-04-24 20:59:07 +02:00
StylusFrost a31fe9b618 Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery
Conflict in prowler/config/config.py resolved by combining both branches:
- HEAD: external compliance discovery via entry points (PROWLER-1391)
- master: multi-provider framework JSONs scanned at top-level compliance/ (#10300)

Order: built-in per-provider -> built-in multi-provider -> external entry points.
Built-ins first so they win on name collisions against external registrations.

Supporting external plug-ins to register multi-provider frameworks is tracked
in PROWLER-1444.
2026-04-24 20:54:22 +02:00
StylusFrost 907166d88a fix(sdk): discriminate builtin vs external providers via find_spec for clearer import errors 2026-04-24 20:33:38 +02:00
StylusFrost 0883baad78 fix(sdk): external providers with --service and external checks for new services 2026-04-24 20:18:20 +02:00
StylusFrost cf70d1f9f8 fix(sdk): honor from_cli_args return value in init_global_provider fallback 2026-04-24 18:51:57 +02:00
StylusFrost 60e7657081 feat(sdk): wire is_external_tool_provider property to execution and metadata validators 2026-04-24 18:23:42 +02:00
StylusFrost e8487d0686 fix(sdk): unwrap namespaced config for all built-in and external providers
load_and_validate_config_file only detected the namespaced format for 5
hardcoded providers (aws, gcp, azure, kubernetes, m365). For every other
built-in (github, nhn, vercel, cloudflare, iac, llm, image, mongodbatlas,
oraclecloud, openstack, alibabacloud, googleworkspace) and for any
external plug-in, the full YAML was returned wrapped instead of the
provider's own block.

Replace the hardcoded list with a dynamic check: if the file has a
top-level key matching the provider and its value is a dict, unwrap it.
Keep the legacy flat format for AWS only (historical, pre-multicloud)
and identify it by the absence of nested-dict top-level values, which
prevents cross-provider config leakage when a namespaced file has no
section for the requested provider.
2026-04-24 18:01:47 +02:00
StylusFrost 9c056beed1 Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-04-22 09:39:10 +02:00
StylusFrost f60f7c61c7 feat(provider): add display_compliance_table method for provider-specific compliance rendering 2026-04-21 19:40:38 +02:00
StylusFrost 3deb1359a5 Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-04-21 18:52:13 +02:00
StylusFrost e2295bd086 feat(provider): implement get_mutelist_finding_args for external providers and add tests 2026-04-21 18:50:18 +02:00
StylusFrost e27317437d feat(external-provider): add dynamic loading tests and coverage for external provider 2026-04-21 14:37:50 +02:00
StylusFrost 6f6016d822 chore: update CHANGELOG for Prowler v5.25.0 with new features 2026-04-21 14:22:24 +02:00
StylusFrost 5f10e1c1b6 Merge branch 'master' into PROWLER-1391-provider-contract-dynamic-discovery 2026-04-21 14:19:52 +02:00
StylusFrost 484211b465 fix(sdk): align exception handlers to SDK convention and improve test coverage 2026-04-21 14:14:17 +02:00
StylusFrost f8333baf24 feat: Enhance dynamic provider loading and compliance framework discovery
- Implemented dynamic loading of external providers via entry points, allowing for greater flexibility in provider integration.
  - Added functionality to discover compliance directories from entry points, enabling external compliance frameworks to be loaded seamlessly.
  - Refactored check module resolution to prioritize built-in checks while falling back to entry points if necessary.
  - Improved compliance framework loading to include both built-in and external sources, ensuring comprehensive compliance coverage.
  - Enhanced CLI argument parsing to support external providers, improving user experience and configurability.
  - Introduced extensive unit tests to validate dynamic loading, compliance discovery, and overall integration of external providers.
2026-04-15 13:22:57 +02:00
92 changed files with 6757 additions and 1018 deletions
+6 -6
View File
@@ -71,7 +71,7 @@ jobs:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
with:
destination: /opt/gh-aw/actions
- name: Check workflow file timestamps
@@ -140,7 +140,7 @@ jobs:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
with:
destination: /opt/gh-aw/actions
- name: Checkout repository
@@ -875,7 +875,7 @@ jobs:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
with:
destination: /opt/gh-aw/actions
- name: Download agent output artifact
@@ -987,7 +987,7 @@ jobs:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
with:
destination: /opt/gh-aw/actions
- name: Download agent artifacts
@@ -1096,7 +1096,7 @@ jobs:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
with:
destination: /opt/gh-aw/actions
- name: Add eyes reaction for immediate feedback
@@ -1169,7 +1169,7 @@ jobs:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@58d1bedbb7200f59c2d224151339e38fd8687d05 # v0.76.1
uses: github/gh-aw/actions/setup@4d44d0e89851a877f4ddc0cb6c0197e42b1016c5 # v0.73.0
with:
destination: /opt/gh-aw/actions
- name: Download agent output artifact
+28 -1
View File
@@ -540,7 +540,7 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
# Scaleway Provider
- name: Check if Scaleway files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -588,7 +588,34 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-stackit
files: ./stackit_coverage.xml
# External Provider (dynamic loading)
- name: Check if External Provider files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-external
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with:
files: |
./prowler/providers/common/**
./prowler/config/**
./prowler/lib/**
./tests/providers/external/**
./uv.lock
- name: Run External Provider tests
if: steps.changed-external.outputs.any_changed == 'true'
run: uv run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external
- name: Upload External Provider coverage to Codecov
if: steps.changed-external.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 }}-external
files: ./external_coverage.xml
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
+1
View File
@@ -14,6 +14,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- A recovered scan rewrites its findings, summaries, attack surface, and compliance data instead of appending to the previous run, so recovery never leaves stale or duplicate materialized rows [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- Provider type is validated against the SDK's available providers instead of a static enum, so the API accepts any installed provider (built-in or external); `Provider.provider` is stored as `varchar` and the native PostgreSQL enum is removed [(#11399)](https://github.com/prowler-cloud/prowler/pull/11399)
### 🐞 Fixed
+23 -28
View File
@@ -19,7 +19,6 @@ from api.constants import SEVERITY_ORDER
from api.db_utils import (
FindingDeltaEnumField,
InvitationStateEnumField,
ProviderEnumField,
SeverityEnumField,
StatusEnumField,
)
@@ -67,6 +66,7 @@ from api.uuid_utils import (
uuid7_range,
uuid7_start,
)
from api.provider_types import get_provider_type_choices
from api.v1.serializers import TaskBase
@@ -109,11 +109,11 @@ class BaseProviderFilter(FilterSet):
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
field_name="provider__provider", choices=get_provider_type_choices()
)
provider_type__in = ChoiceInFilter(
field_name="provider__provider",
choices=Provider.ProviderChoices.choices,
choices=get_provider_type_choices(),
lookup_expr="in",
)
@@ -133,11 +133,11 @@ class BaseScanProviderFilter(FilterSet):
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
field_name="scan__provider__provider", choices=get_provider_type_choices()
)
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider",
choices=Provider.ProviderChoices.choices,
choices=get_provider_type_choices(),
lookup_expr="in",
)
@@ -155,10 +155,10 @@ class CommonFindingFilters(FilterSet):
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider"
choices=get_provider_type_choices(), field_name="scan__provider__provider"
)
provider_type__in = ChoiceInFilter(
choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider"
choices=get_provider_type_choices(), field_name="scan__provider__provider"
)
provider_uid = CharFilter(field_name="scan__provider__uid", lookup_expr="exact")
provider_uid__in = CharInFilter(field_name="scan__provider__uid", lookup_expr="in")
@@ -356,18 +356,18 @@ class ProviderFilter(FilterSet):
included. Providers with no connection attempt (status is null) are
excluded from this filter."""
)
provider = ChoiceFilter(choices=Provider.ProviderChoices.choices)
provider = ChoiceFilter(choices=get_provider_type_choices())
provider__in = ChoiceInFilter(
field_name="provider",
choices=Provider.ProviderChoices.choices,
choices=get_provider_type_choices(),
lookup_expr="in",
)
provider_type = ChoiceFilter(
choices=Provider.ProviderChoices.choices, field_name="provider"
choices=get_provider_type_choices(), field_name="provider"
)
provider_type__in = ChoiceInFilter(
field_name="provider",
choices=Provider.ProviderChoices.choices,
choices=get_provider_type_choices(),
lookup_expr="in",
)
@@ -381,19 +381,14 @@ class ProviderFilter(FilterSet):
"inserted_at": ["gte", "lte"],
"updated_at": ["gte", "lte"],
}
filter_overrides = {
ProviderEnumField: {
"filter_class": CharFilter,
},
}
class ProviderRelationshipFilterSet(FilterSet):
provider_type = ChoiceFilter(
choices=Provider.ProviderChoices.choices, field_name="provider__provider"
choices=get_provider_type_choices(), field_name="provider__provider"
)
provider_type__in = ChoiceInFilter(
choices=Provider.ProviderChoices.choices, field_name="provider__provider"
choices=get_provider_type_choices(), field_name="provider__provider"
)
provider_uid = CharFilter(field_name="provider__uid", lookup_expr="exact")
provider_uid__in = CharInFilter(field_name="provider__uid", lookup_expr="in")
@@ -998,7 +993,7 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
field_name="provider__provider", choices=get_provider_type_choices()
)
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
@@ -1098,7 +1093,7 @@ class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
field_name="provider__provider", choices=get_provider_type_choices()
)
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
@@ -1301,10 +1296,10 @@ class ScanSummaryFilter(FilterSet):
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
field_name="scan__provider__provider", choices=get_provider_type_choices()
)
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
field_name="scan__provider__provider", choices=get_provider_type_choices()
)
region = CharFilter(field_name="region")
@@ -1324,10 +1319,10 @@ class DailySeveritySummaryFilter(FilterSet):
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
field_name="provider__provider", choices=get_provider_type_choices()
)
provider_type__in = ChoiceInFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
field_name="provider__provider", choices=get_provider_type_choices()
)
date_from = DateFilter(method="filter_noop")
date_to = DateFilter(method="filter_noop")
@@ -1578,11 +1573,11 @@ class ThreatScoreSnapshotFilter(FilterSet):
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
field_name="provider__provider", choices=get_provider_type_choices()
)
provider_type__in = ChoiceInFilter(
field_name="provider__provider",
choices=Provider.ProviderChoices.choices,
choices=get_provider_type_choices(),
lookup_expr="in",
)
compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact")
@@ -1621,11 +1616,11 @@ class ResourceGroupOverviewFilter(FilterSet):
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="scan__provider__id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
field_name="scan__provider__provider", choices=get_provider_type_choices()
)
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider",
choices=Provider.ProviderChoices.choices,
choices=get_provider_type_choices(),
lookup_expr="in",
)
resource_group = CharFilter(field_name="resource_group", lookup_expr="exact")
@@ -0,0 +1,43 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Expand step of the zero-downtime migration of Provider.provider from a
native PostgreSQL enum to varchar.
Adds a transitional varchar shadow column `provider_str` and a trigger that
keeps it in sync with the `provider` enum column on every INSERT/UPDATE.
Adding a nullable column is metadata-only (no table rewrite, no long lock).
The trigger covers writes from now on; existing rows are populated by a
later backfill. A subsequent migration drops the enum column and renames
`provider_str` to take its place.
"""
dependencies = [
("api", "0096_jiraissuedispatch"),
]
operations = [
migrations.AddField(
model_name="provider",
name="provider_str",
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.RunSQL(
sql=(
"CREATE OR REPLACE FUNCTION sync_provider_str() RETURNS trigger AS $$\n"
"BEGIN\n"
" NEW.provider_str := NEW.provider::text;\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"CREATE TRIGGER providers_sync_provider_str\n"
" BEFORE INSERT OR UPDATE ON providers\n"
" FOR EACH ROW EXECUTE FUNCTION sync_provider_str();"
),
reverse_sql=(
"DROP TRIGGER IF EXISTS providers_sync_provider_str ON providers;\n"
"DROP FUNCTION IF EXISTS sync_provider_str();"
),
),
]
@@ -0,0 +1,25 @@
from django.db import migrations
class Migration(migrations.Migration):
"""Synchronous backfill of the `provider_str` shadow column.
A single UPDATE fills rows that predate the 0097 trigger. The providers
table is small, so this is safe inline and guarantees the column is fully
populated before 0099 sets it NOT NULL (no race with an async backfill).
Runs on the migration connection, which is exempt from RLS.
"""
dependencies = [
("api", "0097_provider_str_shadow_column"),
]
operations = [
migrations.RunSQL(
sql=(
"UPDATE providers SET provider_str = provider::text "
"WHERE provider_str IS NULL;"
),
reverse_sql=migrations.RunSQL.noop,
),
]
@@ -0,0 +1,51 @@
from django.db import migrations, models
class Migration(migrations.Migration):
"""Contract step: promote `provider_str` into `provider`.
Drops the trigger and enum column, renames the shadow column, sets it NOT
NULL, and drops the enum type. The unique index is dropped and recreated in
the same transaction, so there is no window for duplicate active providers;
recreated non-concurrently since the table is small, with a short
lock_timeout so the migration fails fast instead of queueing behind a
long-running transaction.
"""
dependencies = [
("api", "0098_backfill_provider_str"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name="provider",
name="provider_str",
),
migrations.AlterField(
model_name="provider",
name="provider",
field=models.CharField(default="aws", max_length=50),
),
],
database_operations=[
migrations.RunSQL(
sql=(
"SET LOCAL lock_timeout = '10s';\n"
"DROP TRIGGER IF EXISTS providers_sync_provider_str ON providers;\n"
"DROP FUNCTION IF EXISTS sync_provider_str();\n"
"DROP INDEX IF EXISTS unique_provider_uids;\n"
"ALTER TABLE providers DROP COLUMN provider;\n"
"ALTER TABLE providers RENAME COLUMN provider_str TO provider;\n"
"ALTER TABLE providers ALTER COLUMN provider SET DEFAULT 'aws';\n"
"ALTER TABLE providers ALTER COLUMN provider SET NOT NULL;\n"
"DROP TYPE provider;\n"
"CREATE UNIQUE INDEX unique_provider_uids ON providers "
"(tenant_id, provider, uid) WHERE NOT is_deleted;"
),
reverse_sql=migrations.RunSQL.noop,
),
],
),
]
+17 -5
View File
@@ -40,7 +40,6 @@ from api.db_utils import (
InvitationStateEnumField,
MemberRoleEnumField,
ProcessorTypeEnumField,
ProviderEnumField,
ProviderSecretTypeEnumField,
ScanTriggerEnumField,
SeverityEnumField,
@@ -59,6 +58,7 @@ from api.rls import (
Tenant,
)
from prowler.lib.check.models import Severity
from prowler.providers.common.provider import Provider as SDKProvider
fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode())
@@ -482,9 +482,10 @@ class Provider(RowLevelSecurityProtectedModel):
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
is_deleted = models.BooleanField(default=False)
provider = ProviderEnumField(
choices=ProviderChoices.choices, default=ProviderChoices.AWS
)
# Stored as a plain varchar: the SDK is the source of truth for which
# providers are valid, so the column accepts any provider name without a
# database-level enum to keep in sync.
provider = models.CharField(max_length=50, default=ProviderChoices.AWS)
uid = models.CharField(
"Unique identifier for the provider, set by the provider",
max_length=250,
@@ -501,13 +502,24 @@ class Provider(RowLevelSecurityProtectedModel):
def clean(self):
super().clean()
if self.provider not in SDKProvider.get_available_providers():
raise ModelValidationError(
detail=f"{self.provider} is not a supported provider.",
code="invalid",
pointer="/data/attributes/provider",
)
if self.provider == self.ProviderChoices.OKTA and self.uid:
# Mirror the SDK, which lowercases the org domain before connecting.
# Without this the API would reject Acme.okta.com even though the
# SDK would accept it, and stored uids could disagree with the
# authenticated org domain.
self.uid = self.uid.strip().lower()
getattr(self, f"validate_{self.provider}_uid")(self.uid)
# Providers the SDK exposes but the API has no specific uid rule for
# (e.g. external providers) fall back to the field-level min-length
# check only, instead of failing on a missing validator.
uid_validator = getattr(self, f"validate_{self.provider}_uid", None)
if uid_validator is not None:
uid_validator(self.uid)
def save(self, *args, **kwargs):
self.full_clean()
+15
View File
@@ -0,0 +1,15 @@
from functools import lru_cache
from prowler.providers.common.provider import Provider as SDKProvider
@lru_cache(maxsize=1)
def get_provider_type_choices():
"""Provider-type choices from the SDK's available providers, so they cover
external providers and not just a static enum.
Cached for the process lifetime; hot-installing a provider needs
coordinated cache invalidation (tracked separately) to show up here without
a restart. Shared by the filters and the provider serializer.
"""
return [(name, name) for name in SDKProvider.get_available_providers()]
+23
View File
@@ -0,0 +1,23 @@
from api.filters import get_provider_type_choices
from prowler.providers.common.provider import Provider as SDKProvider
class TestProviderTypeChoices:
"""Provider-type filter choices are driven by the SDK's available providers
so filtering covers external providers, not just a static enum."""
def test_choices_track_sdk_available_providers(self):
available = set(SDKProvider.get_available_providers())
choices = get_provider_type_choices()
assert {value for value, _ in choices} == available
def test_choices_include_provider_absent_from_legacy_enum(self):
from api.models import Provider
legacy = {value for value, _ in Provider.ProviderChoices.choices}
choice_values = {value for value, _ in get_provider_type_choices()}
# `llm` is exposed by the SDK but is not part of the legacy static enum.
assert "llm" in choice_values
assert "llm" not in legacy
+28
View File
@@ -6,7 +6,9 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError
from api.db_router import MainRouter
from api.exceptions import ModelValidationError
from api.models import (
Provider,
ProviderComplianceScore,
Resource,
ResourceTag,
@@ -525,3 +527,29 @@ class TestTenantComplianceSummaryModel:
assert summary1.id != summary2.id
assert summary1.requirements_passed != summary2.requirements_passed
@pytest.mark.django_db
class TestProviderDynamicValidation:
"""Provider validity is driven by the SDK's available providers, not a
static enum. Providers the SDK exposes are accepted; for those without a
`validate_<provider>_uid` method only the uid min-length floor applies."""
def test_accepts_provider_without_uid_validator(self, tenants_fixture):
tenant = tenants_fixture[0]
provider = Provider.objects.create(
tenant_id=tenant.id, provider="llm", uid="my-llm-account"
)
assert provider.provider == "llm"
def test_rejects_provider_not_available_in_sdk(self, tenants_fixture):
tenant = tenants_fixture[0]
with pytest.raises(ModelValidationError):
Provider.objects.create(
tenant_id=tenant.id, provider="does-not-exist", uid="whatever"
)
def test_uid_floor_still_enforced_for_external_provider(self, tenants_fixture):
tenant = tenants_fixture[0]
with pytest.raises(ValidationError):
Provider.objects.create(tenant_id=tenant.id, provider="llm", uid="ab")
+96 -1
View File
@@ -1,8 +1,103 @@
from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel
from rest_framework.exceptions import ValidationError
from api.v1.serializer_utils.integrations import S3ConfigSerializer
from api.v1.serializers import ImageProviderSecret
from api.v1.serializers import (
BaseWriteProviderSecretSerializer,
ImageProviderSecret,
ProviderEnumSerializerField,
)
class TestExternalProviderSecretValidation:
"""A non-built-in provider's secret is validated against the schema it
declares for the chosen secret type through the SDK contract, or accepted as
an object when it declares none (then validated by test_connection)."""
class _StaticCredentials(BaseModel):
api_url: str
api_key: str
class _RoleCredentials(BaseModel):
role_arn: str
def _patch(self, schemas):
provider_class = MagicMock()
provider_class.get_credentials_schema.return_value = schemas
return patch(
"api.v1.serializers.SDKProvider.get_class", return_value=provider_class
)
def test_secret_validated_against_its_type_schema(self):
with self._patch({"static": self._StaticCredentials}):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "static", {"api_url": "u", "api_key": "k"}
)
def test_secret_rejected_when_schema_violated(self):
with self._patch({"static": self._StaticCredentials}):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "static", {"api_url": "u"}
)
def test_secret_must_match_its_type_not_another(self):
"""A secret is validated against the schema for its declared secret_type,
not "any declared schema": a role-shaped secret under secret_type=static
is rejected. See PR #11402 review (josema-xyz / Alan-TheGentleman)."""
schemas = {"static": self._StaticCredentials, "role": self._RoleCredentials}
with self._patch(schemas):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "static", {"role_arn": "arn:aws:iam::x"}
)
def test_rejects_secret_type_not_declared_by_provider(self):
with self._patch({"static": self._StaticCredentials}):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "role", {"role_arn": "arn"}
)
def test_secret_accepted_when_no_schema_declared(self):
with self._patch({}):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "static", {"anything": "goes"}
)
@pytest.mark.parametrize("bad_secret", [["a", "b"], "a-string", None, 42])
def test_secret_rejected_when_not_a_json_object(self, bad_secret):
"""Even with no declared schema, a non-object secret must be rejected so
a list/string/null cannot be persisted and blow up later at
``{**secret}``. See PR #11402 review (Alan-TheGentleman)."""
with self._patch({}):
with pytest.raises(ValidationError):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
"external-template", "static", bad_secret
)
class TestProviderEnumSerializerField:
"""The provider field accepts whatever the SDK exposes (built-in or
external) and rejects anything else with `invalid_choice`."""
def test_accepts_sdk_available_provider(self):
field = ProviderEnumSerializerField()
assert field.run_validation("aws") == "aws"
def test_accepts_external_provider_absent_from_static_enum(self):
field = ProviderEnumSerializerField()
# `llm` is exposed by the SDK but is not part of the legacy static enum.
assert field.run_validation("llm") == "llm"
def test_rejects_unknown_provider(self):
field = ProviderEnumSerializerField()
with pytest.raises(ValidationError) as exc:
field.run_validation("does-not-exist")
assert exc.value.detail[0].code == "invalid_choice"
class TestS3ConfigSerializer:
+63 -3
View File
@@ -146,11 +146,25 @@ class TestReturnProwlerProvider:
with pytest.raises(ValueError):
return return_prowler_provider(provider)
def test_return_prowler_provider_external_resolves_via_get_class(self):
"""An external provider name resolves through the SDK resolver, so any
entry-point provider works without a hardcoded branch in the API."""
external_class = type("ExternalProvider", (), {})
provider = MagicMock()
provider.provider = "external-template"
with patch(
"api.utils.SDKProvider.get_class", return_value=external_class
) as mock_get_class:
resolved = return_prowler_provider(provider)
mock_get_class.assert_called_once_with("external-template")
assert resolved is external_class
class TestInitializeProwlerProvider:
@patch("api.utils.return_prowler_provider")
def test_initialize_prowler_provider(self, mock_return_prowler_provider):
provider = MagicMock()
provider.provider = "aws"
provider.secret.secret = {"key": "value"}
mock_return_prowler_provider.return_value = MagicMock()
@@ -162,6 +176,7 @@ class TestInitializeProwlerProvider:
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = "aws"
provider.secret.secret = {"key": "value"}
mutelist_processor = MagicMock()
mutelist_processor.configuration = {"Mutelist": {"key": "value"}}
@@ -177,6 +192,7 @@ class TestProwlerProviderConnectionTest:
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test(self, mock_return_prowler_provider):
provider = MagicMock()
provider.provider = "aws"
provider.uid = "1234567890"
provider.secret.secret = {"key": "value"}
mock_return_prowler_provider.return_value = MagicMock()
@@ -186,6 +202,29 @@ class TestProwlerProviderConnectionTest:
key="value", provider_id="1234567890", raise_on_exception=False
)
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_external_uses_contract(
self, mock_return_prowler_provider
):
"""External providers build connection kwargs through the SDK contract
(get_connection_arguments), with no provider_id forced by the API."""
provider = MagicMock()
provider.provider = "external-template"
provider.uid = "acme-1"
provider.secret.secret = {"api_key": "k"}
mock_return_prowler_provider.return_value.get_connection_arguments.return_value = {
"api_key": "k"
}
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.get_connection_arguments.assert_called_once_with(
"acme-1", {"api_key": "k"}
)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
api_key="k", raise_on_exception=False
)
@pytest.mark.django_db
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_without_secret(
@@ -560,7 +599,8 @@ class TestGetProwlerProviderKwargs:
assert result == expected_result
def test_get_prowler_provider_kwargs_unsupported_provider(self):
# Setup
# A non-built-in provider is resolved dynamically; one that is neither a
# built-in nor an installed entry-point provider cannot be resolved.
provider_uid = "provider_uid"
secret_dict = {"key": "value"}
secret_mock = MagicMock()
@@ -571,10 +611,30 @@ class TestGetProwlerProviderKwargs:
provider.secret = secret_mock
provider.uid = provider_uid
with pytest.raises(ValueError):
get_prowler_provider_kwargs(provider)
@patch("api.utils.return_prowler_provider")
def test_get_prowler_provider_kwargs_external_uses_contract(
self, mock_return_prowler_provider
):
"""External providers build constructor kwargs through the SDK contract
(get_scan_arguments), not a hardcoded branch in the API."""
provider = MagicMock()
provider.provider = "external-template"
provider.uid = "acme-1"
provider.secret.secret = {"api_key": "k"}
mock_return_prowler_provider.return_value.get_scan_arguments.return_value = {
"api_key": "k",
"custom": "acme-1",
}
result = get_prowler_provider_kwargs(provider)
expected_result = secret_dict.copy()
assert result == expected_result
mock_return_prowler_provider.return_value.get_scan_arguments.assert_called_once_with(
"acme-1", {"api_key": "k"}, None
)
assert result == {"api_key": "k", "custom": "acme-1"}
def test_get_prowler_provider_kwargs_no_secret(self):
# Setup
+39 -149
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from django.contrib.postgres.aggregates import ArrayAgg
@@ -17,30 +16,7 @@ from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError
from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
from prowler.providers.common.models import Connection
if TYPE_CHECKING:
from prowler.providers.alibabacloud.alibabacloud_provider import (
AlibabacloudProvider,
)
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.github.github_provider import GithubProvider
from prowler.providers.googleworkspace.googleworkspace_provider import (
GoogleworkspaceProvider,
)
from prowler.providers.iac.iac_provider import IacProvider
from prowler.providers.image.image_provider import ImageProvider
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
MongodbatlasProvider,
)
from prowler.providers.okta.okta_provider import OktaProvider
from prowler.providers.openstack.openstack_provider import OpenstackProvider
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
from prowler.providers.vercel.vercel_provider import VercelProvider
from prowler.providers.common.provider import Provider as SDKProvider
class CustomOAuth2Client(OAuth2Client):
@@ -79,117 +55,26 @@ def merge_dicts(default_dict: dict, replacement_dict: dict) -> dict:
return result
def return_prowler_provider(
provider: Provider,
) -> (
AlibabacloudProvider
| AwsProvider
| AzureProvider
| CloudflareProvider
| GcpProvider
| GithubProvider
| GoogleworkspaceProvider
| IacProvider
| ImageProvider
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
| OktaProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
):
"""Return the Prowler provider class based on the given provider type.
def return_prowler_provider(provider: Provider) -> type:
"""Resolve the Prowler provider class for the given provider.
The class is resolved dynamically from the SDK, so any provider the SDK
discovers built-in or external entry-point works without a per-provider
branch in the API.
Args:
provider (Provider): The provider object containing the provider type and associated secrets.
provider (Provider): The provider whose `provider` type to resolve.
Returns:
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class.
type: The Prowler provider class.
Raises:
ValueError: If the provider type specified in `provider.provider` is not supported.
ValueError: If the provider type is not available in the SDK.
"""
match provider.provider:
case Provider.ProviderChoices.AWS.value:
from prowler.providers.aws.aws_provider import AwsProvider
prowler_provider = AwsProvider
case Provider.ProviderChoices.GCP.value:
from prowler.providers.gcp.gcp_provider import GcpProvider
prowler_provider = GcpProvider
case Provider.ProviderChoices.GOOGLEWORKSPACE.value:
from prowler.providers.googleworkspace.googleworkspace_provider import (
GoogleworkspaceProvider,
)
prowler_provider = GoogleworkspaceProvider
case Provider.ProviderChoices.AZURE.value:
from prowler.providers.azure.azure_provider import AzureProvider
prowler_provider = AzureProvider
case Provider.ProviderChoices.KUBERNETES.value:
from prowler.providers.kubernetes.kubernetes_provider import (
KubernetesProvider,
)
prowler_provider = KubernetesProvider
case Provider.ProviderChoices.M365.value:
from prowler.providers.m365.m365_provider import M365Provider
prowler_provider = M365Provider
case Provider.ProviderChoices.GITHUB.value:
from prowler.providers.github.github_provider import GithubProvider
prowler_provider = GithubProvider
case Provider.ProviderChoices.MONGODBATLAS.value:
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
MongodbatlasProvider,
)
prowler_provider = MongodbatlasProvider
case Provider.ProviderChoices.IAC.value:
from prowler.providers.iac.iac_provider import IacProvider
prowler_provider = IacProvider
case Provider.ProviderChoices.ORACLECLOUD.value:
from prowler.providers.oraclecloud.oraclecloud_provider import (
OraclecloudProvider,
)
prowler_provider = OraclecloudProvider
case Provider.ProviderChoices.ALIBABACLOUD.value:
from prowler.providers.alibabacloud.alibabacloud_provider import (
AlibabacloudProvider,
)
prowler_provider = AlibabacloudProvider
case Provider.ProviderChoices.CLOUDFLARE.value:
from prowler.providers.cloudflare.cloudflare_provider import (
CloudflareProvider,
)
prowler_provider = CloudflareProvider
case Provider.ProviderChoices.OPENSTACK.value:
from prowler.providers.openstack.openstack_provider import OpenstackProvider
prowler_provider = OpenstackProvider
case Provider.ProviderChoices.IMAGE.value:
from prowler.providers.image.image_provider import ImageProvider
prowler_provider = ImageProvider
case Provider.ProviderChoices.VERCEL.value:
from prowler.providers.vercel.vercel_provider import VercelProvider
prowler_provider = VercelProvider
case Provider.ProviderChoices.OKTA.value:
from prowler.providers.okta.okta_provider import OktaProvider
prowler_provider = OktaProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
return prowler_provider
try:
return SDKProvider.get_class(provider.provider)
except ImportError as error:
raise ValueError(f"Provider type {provider.provider} not supported") from error
def get_prowler_provider_kwargs(
@@ -204,6 +89,18 @@ def get_prowler_provider_kwargs(
Returns:
dict: The provider kwargs for the corresponding provider class.
"""
# External providers declare their own uid/secret -> kwargs mapping through
# the SDK contract; built-in providers keep the explicit mapping below.
if not SDKProvider.is_builtin(provider.provider):
mutelist_content = (
mutelist_processor.configuration.get("Mutelist", {})
if mutelist_processor
else None
)
return return_prowler_provider(provider).get_scan_arguments(
provider.uid, provider.secret.secret, mutelist_content
)
prowler_provider_kwargs = provider.secret.secret
if provider.provider == Provider.ProviderChoices.AZURE.value:
prowler_provider_kwargs = {
@@ -288,24 +185,7 @@ def get_prowler_provider_kwargs(
def initialize_prowler_provider(
provider: Provider,
mutelist_processor: Processor | None = None,
) -> (
AlibabacloudProvider
| AwsProvider
| AzureProvider
| CloudflareProvider
| GcpProvider
| GithubProvider
| GoogleworkspaceProvider
| IacProvider
| ImageProvider
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
| OktaProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
):
) -> SDKProvider:
"""Initialize a Prowler provider instance based on the given provider type.
Args:
@@ -313,8 +193,8 @@ def initialize_prowler_provider(
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
Returns:
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class
initialized with the provider's secrets.
SDKProvider: An instance of the corresponding provider class initialized
with the provider's secrets.
"""
prowler_provider = return_prowler_provider(provider)
prowler_provider_kwargs = get_prowler_provider_kwargs(provider, mutelist_processor)
@@ -337,6 +217,16 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
except Provider.secret.RelatedObjectDoesNotExist as secret_error:
return Connection(is_connected=False, error=secret_error)
# External providers declare their own connection kwargs through the SDK
# contract; built-in providers keep the explicit mapping below.
if not SDKProvider.is_builtin(provider.provider):
return prowler_provider.test_connection(
**prowler_provider.get_connection_arguments(
provider.uid, prowler_provider_kwargs
),
raise_on_exception=False,
)
# For IaC provider, construct the kwargs properly for test_connection
if provider.provider == Provider.ProviderChoices.IAC.value:
# Don't pass repository_url from secret, use scan_repository_url with the UID
+61 -1
View File
@@ -10,6 +10,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError
from drf_spectacular.utils import extend_schema_field
from jwt.exceptions import InvalidKeyError
from pydantic import ValidationError as PydanticValidationError
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from rest_framework_json_api import serializers
@@ -71,8 +72,10 @@ from api.v1.serializer_utils.lighthouse import (
OpenAICredentialsSerializer,
)
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.provider_types import get_provider_type_choices
from api.v1.serializer_utils.providers import ProviderSecretField
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.providers.common.provider import Provider as SDKProvider
# Base
@@ -854,7 +857,9 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
# Providers
class ProviderEnumSerializerField(serializers.ChoiceField):
def __init__(self, **kwargs):
kwargs["choices"] = Provider.ProviderChoices.choices
# Accepted values track the SDK's installed providers (built-in or
# external), shared with the filters via one cached source.
kwargs["choices"] = get_provider_type_choices()
super().__init__(**kwargs)
@@ -940,6 +945,12 @@ class ProviderIncludeSerializer(RLSSerializer):
class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer):
# Declared explicitly so provider validation stays at the serializer layer:
# the model column is now a plain varchar with no choices, so without this
# an unknown provider would slip through to Provider.clean() instead of
# being rejected here with `invalid_choice`.
provider = ProviderEnumSerializerField()
class Meta:
model = Provider
fields = [
@@ -1532,10 +1543,59 @@ class FindingMetadataSerializer(BaseSerializerV1):
# Provider secrets
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@staticmethod
def _validate_external_provider_secret(
provider_type: str, secret_type: str, secret: dict
):
"""Validate a non-built-in provider's secret against the schema it
declares for the given secret type through the SDK contract.
The provider maps each secret type to one model, so the chosen
secret_type stays bound to the shape it claims. Providers that declare
no schema have their secret accepted as an object and validated by the
provider's ``test_connection``.
"""
if not isinstance(secret, dict):
raise serializers.ValidationError({"secret": ["Must be a JSON object."]})
schemas = SDKProvider.get_class(provider_type).get_credentials_schema()
if not schemas:
return
schema = schemas.get(secret_type)
if schema is None:
raise serializers.ValidationError(
{
"secret_type": [
f"'{secret_type}' is not supported by provider "
f"'{provider_type}'. Supported types: "
f"{', '.join(sorted(schemas))}."
]
}
)
try:
schema.model_validate(secret)
except PydanticValidationError as error:
raise serializers.ValidationError(
{
"secret": [
f"{'/'.join(str(loc) for loc in item['loc']) or 'secret'}: "
f"{item['msg']}"
for item in error.errors()
]
}
)
@staticmethod
def validate_secret_based_on_provider(
provider_type: str, secret_type: ProviderSecret.TypeChoices, secret: dict
):
# External providers validate against the schemas they declare via the
# SDK contract; built-in providers keep their explicit serializers below.
if not SDKProvider.is_builtin(provider_type):
BaseWriteProviderSecretSerializer._validate_external_provider_secret(
provider_type, secret_type, secret
)
return
if secret_type == ProviderSecret.TypeChoices.STATIC:
if provider_type == Provider.ProviderChoices.AWS.value:
serializer = AwsProviderSecret(data=secret)
@@ -0,0 +1,27 @@
import warnings
from dashboard.common_methods import get_section_containers_3_levels
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"NAME",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
]
return get_section_containers_3_levels(
aux,
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"NAME",
)
@@ -47,7 +47,11 @@ Follow these steps to remove a user of your account:
1. Navigate to **Users** from the side menu.
2. Click the delete button of your current user.
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
+12
View File
@@ -8,6 +8,18 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
### 🐞 Fixed
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470)
---
+57 -12
View File
@@ -10,7 +10,6 @@ from colorama import Fore, Style
from colorama import init as colorama_init
from prowler.config.config import (
EXTERNAL_TOOL_PROVIDERS,
cloud_api_base_url,
csv_file_suffix,
get_available_compliance_frameworks,
@@ -205,9 +204,10 @@ def prowler():
# We treat the compliance framework as another output format
if compliance_framework:
args.output_formats.extend(compliance_framework)
# If no input compliance framework, set all, unless a specific service or check is input
# Skip for IAC and LLM providers that don't use compliance frameworks
elif default_execution and provider not in ["iac", "llm"]:
# If no input compliance framework, set all, unless a specific service or check is input.
# Skip for tool-wrapper providers (iac, llm, image, and any external plug-in
# declaring `is_external_tool_provider = True`) — they don't use compliance frameworks.
elif default_execution and not Provider.is_tool_wrapper_provider(provider):
args.output_formats.extend(get_available_compliance_frameworks(provider))
# Set Logger configuration
@@ -245,7 +245,7 @@ def prowler():
universal_frameworks = {}
# Skip compliance frameworks for external-tool providers
if provider not in EXTERNAL_TOOL_PROVIDERS:
if not Provider.is_tool_wrapper_provider(provider):
bulk_compliance_frameworks = Compliance.get_bulk(provider)
# Complete checks metadata with the compliance framework specification
bulk_checks_metadata = update_checks_metadata_with_compliance(
@@ -313,7 +313,7 @@ def prowler():
sys.exit()
# Skip service and check loading for external-tool providers
if provider not in EXTERNAL_TOOL_PROVIDERS:
if not Provider.is_tool_wrapper_provider(provider):
# Import custom checks from folder
if checks_folder:
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
@@ -436,6 +436,20 @@ def prowler():
output_options = ScalewayOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
else:
# Dynamic fallback: any external/custom provider
try:
output_options = global_provider.get_output_options(
args, bulk_checks_metadata
)
except NotImplementedError:
# No provider-specific OutputOptions: use the generic default so the
# run still produces output instead of aborting.
from prowler.providers.common.models import default_output_options
output_options = default_output_options(
global_provider, args, bulk_checks_metadata
)
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
@@ -445,7 +459,7 @@ def prowler():
# Execute checks
findings = []
if provider in EXTERNAL_TOOL_PROVIDERS:
if Provider.is_tool_wrapper_provider(provider):
# For external-tool providers, run the scan directly
if provider == "llm":
@@ -455,12 +469,19 @@ def prowler():
findings = global_provider.run_scan(streaming_callback=streaming_callback)
else:
# Original behavior for IAC and Image
try:
if provider == "image":
try:
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
else:
# IAC and external tool-wrapper providers registered via entry
# points. Unexpected failures propagate to the outer except
# Exception backstop further down in this file — keeping the
# branch free of an Image-specific catch that would otherwise
# mislead plug-in authors reading this code.
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
# Progress tracking would just be 0% → 100%.
@@ -1293,6 +1314,30 @@ def prowler():
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
else:
# Dynamic fallback: any external/custom provider
try:
global_provider.generate_compliance_output(
finding_outputs,
bulk_compliance_frameworks,
input_compliance_frameworks,
output_options,
generated_outputs,
)
except NotImplementedError:
# Last resort: generic compliance
for compliance_name in input_compliance_frameworks:
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
generic_compliance = GenericCompliance(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
# AWS Security Hub Integration
if provider == "aws":
@@ -1863,7 +1863,9 @@
"Id": "ELB.4",
"Name": "Application load balancers should be configured to drop HTTP headers",
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
"Checks": [],
"Checks": [
"elbv2_alb_drop_invalid_header_fields_enabled"
],
"Attributes": [
{
"ItemId": "ELB.4",
+86 -14
View File
@@ -1,3 +1,4 @@
import importlib.metadata
import os
import pathlib
from datetime import datetime, timezone
@@ -85,13 +86,38 @@ class Provider(str, Enum):
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
def _get_ep_compliance_dirs() -> dict:
"""Discover compliance directories from entry points. Returns {provider: path}."""
dirs = {}
for ep in importlib.metadata.entry_points(group="prowler.compliance"):
try:
module = ep.load()
if hasattr(module, "__path__"):
dirs[ep.name] = module.__path__[0]
elif hasattr(module, "__file__"):
dirs[ep.name] = os.path.dirname(module.__file__)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return dirs
def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks = []
providers = [p.value for p in Provider]
# Built-in compliance
compliance_base = f"{actual_directory}/../compliance"
if provider:
providers = [provider]
for current_provider in providers:
compliance_dir = f"{actual_directory}/../compliance/{current_provider}"
else:
# Scan compliance directory for all provider subdirectories
providers = []
if os.path.isdir(compliance_base):
for entry in os.scandir(compliance_base):
if entry.is_dir():
providers.append(entry.name)
for prov in providers:
compliance_dir = f"{compliance_base}/{prov}"
if not os.path.isdir(compliance_dir):
continue
with os.scandir(compliance_dir) as files:
@@ -100,7 +126,8 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(
file.name.removesuffix(".json")
)
# Also scan top-level compliance/ for multi-provider (universal) JSONs.
# Built-in multi-provider frameworks at top-level compliance/ directory.
# Placed before external entry points so built-ins win on name collisions.
# When a specific provider was requested, only include the framework if it
# declares support for that provider; otherwise include all universal frameworks.
compliance_root = f"{actual_directory}/../compliance"
@@ -117,6 +144,43 @@ def get_available_compliance_frameworks(provider=None):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
# External per-provider compliance via entry points.
ep_dirs = _get_ep_compliance_dirs()
for prov, path in ep_dirs.items():
if provider and prov != provider:
continue
if os.path.isdir(path):
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
name = file.name.removesuffix(".json")
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
# External multi-provider frameworks via the dedicated universal group;
# filtered by supports_provider when a provider is given.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
path = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
if not os.path.isdir(path):
continue
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
name = file.name.removesuffix(".json")
if provider:
framework = load_compliance_framework_universal(file.path)
if framework is None or not framework.supports_provider(provider):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
return available_compliance_frameworks
@@ -228,18 +292,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict:
with open(config_file_path, "r", encoding=encoding_format_utf_8) as f:
config_file = yaml.safe_load(f)
# Not to introduce a breaking change, allow the old format config file without any provider keys
# and a new format with a key for each provider to include their configuration values within.
if any(
key in config_file
for key in ["aws", "gcp", "azure", "kubernetes", "m365"]
# Namespaced format: each provider has its own top-level key.
# Works for every built-in and every external plugin without a hardcoded list.
# Flat legacy format is AWS-only (historical, pre-multicloud). We identify it
# by the absence of nested-dict top-level values (namespaced files always
# have dict values; the legacy AWS format only has primitives/lists).
if (
isinstance(config_file, dict)
and provider in config_file
and isinstance(config_file[provider], dict)
):
config = config_file.get(provider, {})
config = config_file.get(provider, {}) or {}
elif (
isinstance(config_file, dict)
and config_file
and provider == "aws"
and not any(isinstance(v, dict) for v in config_file.values())
):
config = config_file
else:
config = config_file if config_file else {}
# Not to break Azure, K8s and GCP does not support or use the old config format
if provider in ["azure", "gcp", "kubernetes", "m365"]:
config = {}
config = {}
return config
+54 -6
View File
@@ -1,4 +1,6 @@
import importlib
import importlib.metadata
import importlib.util
import json
import os
import re
@@ -19,6 +21,7 @@ from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.lib.outputs.outputs import report
from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes
from prowler.providers.common.builtin import is_builtin_provider
from prowler.providers.common.models import Audit_Metadata
@@ -385,6 +388,45 @@ def import_check(check_path: str) -> ModuleType:
return lib
def _resolve_check_module(
provider_type: str, service: str, check_name: str
) -> ModuleType:
"""Resolve and import a check module.
Built-in wins on CheckID collision. Plug-ins are first-class extenders
(they can add new checks under new CheckIDs) but cannot override
existing built-ins a security tool prefers fail-loud predictability
over silent overrides. CheckMetadata.get_bulk() applies the same
precedence on the metadata side (first-write-wins) and emits a warning
when a plug-in tries to override, so the user knows their plug-in
duplicate is being ignored and can rename it.
Gates the built-in branch on `is_builtin_provider(provider_type)`
calling `find_spec` on `prowler.providers.{provider_type}.services...`
directly would propagate `ModuleNotFoundError` for external providers
(their parent package `prowler.providers.{provider_type}` does not
exist) instead of returning None. The leaf helper encapsulates the
safe lookup, so external providers go straight to entry points. For
built-ins we still use `find_spec` to distinguish "check doesn't
exist" from "check exists but failed to import" (broken transitive
dep, etc.).
"""
# Built-in first — built-in wins on CheckID collision
if is_builtin_provider(provider_type):
builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}"
if importlib.util.find_spec(builtin_path) is not None:
return import_check(builtin_path)
# Entry point lookup — only consulted when the built-in truly doesn't exist
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"):
if ep.name == check_name:
return importlib.import_module(ep.value)
raise ModuleNotFoundError(
f"Check '{check_name}' not found for provider '{provider_type}'"
)
def run_fixer(check_findings: list) -> int:
"""
Run the fixer for the check if it exists and there are any FAIL findings
@@ -525,9 +567,10 @@ def execute_checks(
service = check_name.split("_")[0]
try:
try:
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -605,9 +648,10 @@ def execute_checks(
)
try:
try:
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -753,6 +797,10 @@ def execute(
is_finding_muted_args["org_domain"] = (
global_provider.identity.org_domain
)
elif not is_builtin_provider(global_provider.type):
# External/custom provider — delegate identity args
is_finding_muted_args = global_provider.get_mutelist_finding_args()
for finding in check_findings:
if global_provider.type == "cloudflare":
is_finding_muted_args["account_id"] = finding.account_id
+8 -3
View File
@@ -2,10 +2,10 @@ import sys
from colorama import Fore, Style
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.check import parse_checks_from_file
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata, Severity
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.logger import logger
@@ -26,8 +26,13 @@ def load_checks_to_execute(
) -> set:
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
try:
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
# Bypass check loading for tool-wrapper providers — they delegate
# scanning to an external tool and have no checks to recover.
# Single source of truth across __main__, the CheckMetadata validators,
# check discovery and this loader, covering both built-in tool wrappers
# (iac/llm/image) and external plug-ins that declare
# `is_external_tool_provider = True` via the contract.
if is_tool_wrapper_provider(provider):
return set()
# Local subsets
+80 -15
View File
@@ -1,3 +1,4 @@
import importlib.metadata
import json
import os
import sys
@@ -434,26 +435,63 @@ class Compliance(BaseModel):
"""Bulk load all compliance frameworks specification into a dict"""
try:
bulk_compliance_frameworks = {}
# Built-in compliance from prowler/compliance/{provider}/
available_compliance_framework_modules = list_compliance_modules()
for compliance_framework in available_compliance_framework_modules:
if provider in compliance_framework.name:
# Match the provider segment exactly, not as a substring, so
# e.g. `cloud` does not capture `cloudflare`.
if compliance_framework.name.split(".")[-1] == provider:
compliance_specification_dir_path = (
f"{compliance_framework.module_finder.path}/{provider}"
)
# for compliance_framework in available_compliance_framework_modules:
for filename in os.listdir(compliance_specification_dir_path):
file_path = os.path.join(
compliance_specification_dir_path, filename
)
# Check if it is a file and ti size is greater than 0
if os.path.isfile(file_path) and os.stat(file_path).st_size > 0:
# Open Compliance file in JSON
# cis_v1.4_aws.json --> cis_v1.4_aws
compliance_framework_name = filename.split(".json")[0]
# Store the compliance info
bulk_compliance_frameworks[compliance_framework_name] = (
load_compliance_framework(file_path)
)
# External compliance via entry points
for ep in importlib.metadata.entry_points(group="prowler.compliance"):
if ep.name == provider:
try:
module = ep.load()
compliance_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
for filename in os.listdir(compliance_dir):
if filename.endswith(".json"):
file_path = os.path.join(compliance_dir, filename)
if (
os.path.isfile(file_path)
and os.stat(file_path).st_size > 0
):
compliance_framework_name = filename.split(".json")[
0
]
if (
compliance_framework_name
not in bulk_compliance_frameworks
):
# External JSON: tolerate non-legacy
# schemas (skip + warn) instead of aborting.
framework = load_compliance_framework(
file_path, fatal=False
)
if framework is not None:
bulk_compliance_frameworks[
compliance_framework_name
] = framework
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
@@ -462,18 +500,26 @@ class Compliance(BaseModel):
# Testing Pending
def load_compliance_framework(
compliance_specification_file: str,
) -> Compliance:
"""load_compliance_framework loads and parse a Compliance Framework Specification"""
compliance_specification_file: str, fatal: bool = True
) -> Optional[Compliance]:
"""load_compliance_framework loads and parse a Compliance Framework Specification.
With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with
``fatal=False`` (external JSONs) it is skipped with a warning and ``None``
is returned.
"""
try:
compliance_framework = Compliance.parse_file(compliance_specification_file)
return Compliance.parse_file(compliance_specification_file)
except ValidationError as error:
logger.critical(
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
if fatal:
logger.critical(
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
)
sys.exit(1)
logger.warning(
f"Skipping invalid compliance framework {compliance_specification_file}: {error}"
)
sys.exit(1)
else:
return compliance_framework
return None
# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
@@ -950,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
if compliance_root and os.path.isdir(compliance_root):
_load_jsons_from_dir(compliance_root, provider, bulk)
# External multi-provider frameworks via the dedicated universal entry
# point group, kept separate from the per-provider `prowler.compliance`
# group so the legacy loader never parses a universal JSON. Built-ins
# (already in bulk) win on a name collision.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
ep_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
if os.path.isdir(ep_dir):
_load_jsons_from_dir(ep_dir, provider, bulk)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
return bulk
+37 -12
View File
@@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set
from pydantic.v1 import BaseModel, Field, ValidationError, validator
from pydantic.v1.error_wrappers import ErrorWrapper
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.providers.common.provider import Provider as ProviderABC
# Valid ResourceGroup values as defined in the RFC
VALID_RESOURCE_GROUPS = frozenset(
@@ -259,7 +259,7 @@ class CheckMetadata(BaseModel):
)
if (
value_lower not in VALID_CATEGORIES
and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS
and not ProviderABC.is_tool_wrapper_provider(values.get("Provider"))
):
raise ValueError(
f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}."
@@ -288,7 +288,9 @@ class CheckMetadata(BaseModel):
raise ValueError("ServiceName must be a non-empty string")
check_id = values.get("CheckID")
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if check_id and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
service_from_check_id = check_id.split("_")[0]
if service_name != service_from_check_id:
raise ValueError(
@@ -304,7 +306,9 @@ class CheckMetadata(BaseModel):
if not check_id:
raise ValueError("CheckID must be a non-empty string")
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if check_id and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
if "-" in check_id:
raise ValueError(
f"CheckID {check_id} contains a hyphen, which is not allowed"
@@ -313,8 +317,9 @@ class CheckMetadata(BaseModel):
return check_id
@validator("CheckTitle", pre=True, always=True)
@classmethod
def validate_check_title(cls, check_title, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(check_title) > 150:
raise ValueError(
f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters"
@@ -326,14 +331,18 @@ class CheckMetadata(BaseModel):
return check_title
@validator("RelatedUrl", pre=True, always=True)
@classmethod
def validate_related_url(cls, related_url, values): # noqa: F841
if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if related_url and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
return related_url
@validator("Remediation")
@classmethod
def validate_recommendation_url(cls, remediation, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
url = remediation.Recommendation.Url
if url and not url.startswith("https://hub.prowler.com/"):
raise ValueError(
@@ -346,7 +355,7 @@ class CheckMetadata(BaseModel):
provider = values.get("Provider", "").lower()
# Non-AWS providers must have an empty CheckType list
if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS:
if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider):
if check_type:
raise ValueError(
f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'."
@@ -371,8 +380,9 @@ class CheckMetadata(BaseModel):
return check_type
@validator("Description", pre=True, always=True)
@classmethod
def validate_description(cls, description, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(description) > 400:
raise ValueError(
f"Description must not exceed 400 characters, got {len(description)} characters"
@@ -380,8 +390,9 @@ class CheckMetadata(BaseModel):
return description
@validator("Risk", pre=True, always=True)
@classmethod
def validate_risk(cls, risk, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(risk) > 400:
raise ValueError(
f"Risk must not exceed 400 characters, got {len(risk)} characters"
@@ -433,6 +444,20 @@ class CheckMetadata(BaseModel):
metadata_file = f"{check_path}/{check_name}.metadata.json"
# Load metadata
check_metadata = load_check_metadata(metadata_file)
# Built-in wins on CheckID collision. Plug-in entry points are
# appended after built-ins by `recover_checks_from_provider`, so
# a duplicate CheckID here means an entry-point check is trying
# to override a built-in. Ignore the override (the built-in
# metadata stays) and surface it via a warning — matching the
# precedence enforced by `_resolve_check_module`.
if check_metadata.CheckID in bulk_check_metadata:
logger.warning(
f"Plug-in check metadata '{check_metadata.CheckID}' "
f"(loaded from '{metadata_file}') is being IGNORED — "
f"a built-in with the same CheckID exists. To use your "
f"plug-in, register it under a different CheckID."
)
continue
bulk_check_metadata[check_metadata.CheckID] = check_metadata
return bulk_check_metadata
@@ -470,7 +495,7 @@ class CheckMetadata(BaseModel):
# If the bulk checks metadata is not provided, get it
if not bulk_checks_metadata:
bulk_checks_metadata = {}
available_providers = [p.value for p in Provider]
available_providers = ProviderABC.get_available_providers()
for provider_name in available_providers:
bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name))
if provider:
@@ -495,7 +520,7 @@ class CheckMetadata(BaseModel):
# Loaded here, as it is not always needed
if not bulk_compliance_frameworks:
bulk_compliance_frameworks = {}
available_providers = [p.value for p in Provider]
available_providers = ProviderABC.get_available_providers()
for provider in available_providers:
bulk_compliance_frameworks = Compliance.get_bulk(provider=provider)
checks_from_compliance_framework = (
+62
View File
@@ -0,0 +1,62 @@
"""Standalone helper for tool-wrapper provider detection.
A provider is a "tool wrapper" if it delegates scanning to an external tool
(Trivy, promptfoo, etc.) instead of running checks/services through the
standard Prowler engine. This module is the single source of truth for that
classification across the codebase.
Kept as a leaf module with no Prowler imports beyond the leaf
`external_tool_providers` so it can be referenced from `prowler.lib.check.*`
and `prowler.providers.common.provider` without forming an import cycle.
"""
import importlib.metadata
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.providers.common.builtin import is_builtin_provider
# Module-level cache for entry-point classes consulted by this helper.
# Independent of `Provider._ep_providers` to keep this module leaf — the cost
# of a duplicate cache entry is negligible (one class object per external
# provider, loaded lazily on first lookup).
_ep_class_cache: dict = {}
def _load_ep_class(provider: str):
"""Return the entry-point provider class for `provider`, or None.
Caches the result in `_ep_class_cache`. Errors during entry-point loading
are swallowed (returning None) so a broken plug-in never crashes the
is-tool-wrapper check; it just falls through to "not a tool wrapper".
"""
if provider in _ep_class_cache:
return _ep_class_cache[provider]
for ep in importlib.metadata.entry_points(group="prowler.providers"):
if ep.name == provider:
try:
cls = ep.load()
except Exception:
cls = None
_ep_class_cache[provider] = cls
return cls
_ep_class_cache[provider] = None
return None
def is_tool_wrapper_provider(provider: str) -> bool:
"""Return True if the provider delegates scanning to an external tool.
Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for
iac/llm/image) with the `is_external_tool_provider` class attribute of
external plug-ins registered via entry points. This is the single source
of truth consulted by `__main__`, the `CheckMetadata` validators, the
check-loading utilities, and the checks loader.
"""
if provider in EXTERNAL_TOOL_PROVIDERS:
return True
# Built-in wins: short-circuit before ep.load() so a same-name plug-in
# cannot flip a built-in onto the tool-wrapper path or run its code.
if is_builtin_provider(provider):
return False
cls = _load_ep_class(provider)
return bool(cls and getattr(cls, "is_external_tool_provider", False))
+84 -23
View File
@@ -1,9 +1,43 @@
import importlib
import importlib.metadata
import importlib.util
import os
import sys
from pkgutil import walk_packages
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.logger import logger
from prowler.providers.common.builtin import is_builtin_provider
def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]:
"""Discover external checks registered via entry points for a provider.
External plugins follow the same layout as built-ins:
`{plugin_root}.services.{service}.{check}.{check}`
When `service` is provided, only entry points whose dotted path contains
`.services.{service}.` are included mirroring how built-in discovery
filters by the `prowler.providers.{provider}.services.{service}` package.
Uses find_spec to locate the check module without importing it,
avoiding service client initialization at discovery time.
"""
checks = []
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"):
try:
if service and f".services.{service}." not in ep.value:
continue
spec = importlib.util.find_spec(ep.value)
if spec and spec.origin:
check_path = os.path.dirname(spec.origin)
checks.append((ep.name, check_path))
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return checks
def recover_checks_from_provider(
@@ -15,29 +49,55 @@ def recover_checks_from_provider(
Returns a list of tuples with the following format (check_name, check_path)
"""
try:
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
# Bypass check loading for tool-wrapper providers — they delegate
# scanning to an external tool and have no checks to recover.
# Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS
# frozenset (built-ins) with the per-provider `is_external_tool_provider`
# class attribute (so external plug-ins opt in via the contract).
if is_tool_wrapper_provider(provider):
return []
checks = []
modules = list_modules(provider, service)
for module_name in modules:
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
check_module_name = module_name.name
# We need to exclude common shared libraries in services
if (
check_module_name.count(".") == 6
and ".lib." not in check_module_name
and (not check_module_name.endswith("_fixer") or include_fixers)
):
check_path = module_name.module_finder.path
# Check name is the last part of the check_module_name
check_name = check_module_name.split(".")[-1]
check_info = (check_name, check_path)
checks.append(check_info)
except ModuleNotFoundError:
logger.critical(f"Service {service} was not found for the {provider} provider.")
sys.exit(1)
# Built-in checks from prowler.providers.{provider}.services. Gate
# the built-in branch on `is_builtin_provider(provider)` — calling
# `find_spec` directly on `prowler.providers.{provider}.services`
# would propagate `ModuleNotFoundError` when the parent package
# `prowler.providers.{provider}` does not exist (i.e. the provider
# is external), instead of returning None. The leaf helper
# encapsulates the safe lookup, so we only run the built-in
# discovery when the provider actually ships with the SDK; for
# external providers we go straight to entry points.
if is_builtin_provider(provider):
modules = list_modules(provider, service)
for module_name in modules:
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
check_module_name = module_name.name
# We need to exclude common shared libraries in services
if (
check_module_name.count(".") == 6
and ".lib." not in check_module_name
and (not check_module_name.endswith("_fixer") or include_fixers)
):
check_path = module_name.module_finder.path
check_name = check_module_name.split(".")[-1]
check_info = (check_name, check_path)
checks.append(check_info)
# External checks registered via entry points — always consulted, with
# optional service filter. Previously gated by `if not service:`, which
# prevented external providers from being usable with --service.
checks.extend(_recover_ep_checks(provider, service))
# A service was requested but nothing matched in either built-ins or
# entry points — surface this as a clear error instead of silently
# returning an empty list.
if service and not checks:
logger.critical(
f"Service '{service}' was not found for the '{provider}' provider "
f"(neither as a built-in nor via external entry points)."
)
sys.exit(1)
except Exception as e:
logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}")
sys.exit(1)
@@ -64,8 +124,9 @@ def recover_checks_from_service(service_list: list, provider: str) -> set:
Returns a set of checks from the given services
"""
try:
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
# Bypass check loading for tool-wrapper providers — symmetric with
# `recover_checks_from_provider` above, using the same source of truth.
if is_tool_wrapper_provider(provider):
return set()
checks = set()
+52 -7
View File
@@ -20,19 +20,61 @@ from prowler.providers.common.arguments import (
validate_provider_arguments,
validate_sarif_usage,
)
from prowler.providers.common.provider import Provider
class ProwlerArgumentParser:
# Set the default parser
def __init__(self):
# Discover any providers not in the hardcoded list below
# TODO - First step to support current providers and the new external provider implementation
known_providers = {
"aws",
"azure",
"gcp",
"kubernetes",
"m365",
"github",
"googleworkspace",
"cloudflare",
"oraclecloud",
"openstack",
"alibabacloud",
"iac",
"llm",
"image",
"nhn",
"mongodbatlas",
"vercel",
"okta",
"scaleway",
"stackit",
}
all_providers = set(Provider.get_available_providers())
new_providers = sorted(all_providers - known_providers)
# Build extra strings for dynamically discovered providers
extra_providers_csv = ""
extra_providers_text = ""
if new_providers:
providers_help = Provider.get_providers_help_text()
extra_providers_csv = "," + ",".join(new_providers)
extra_lines = []
for name in new_providers:
help_text = providers_help.get(name, "")
if help_text:
extra_lines.append(f" {name:<20}{help_text}")
if extra_lines:
extra_providers_text = "\n" + "\n".join(extra_lines)
# CLI Arguments
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...",
epilog="""
usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,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}
{{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -52,13 +94,14 @@ Available Cloud Providers:
nhn NHN Provider (Unofficial)
mongodbatlas MongoDB Atlas Provider
scaleway Scaleway Provider
vercel Vercel Provider
vercel Vercel Provider{extra_providers_text}
Available components:
dashboard Local dashboard
To see the different available options on a specific component, run:
prowler {provider|dashboard} -h|--help
prowler {{provider|dashboard}} -h|--help
Detailed documentation at https://docs.prowler.com
""",
@@ -117,8 +160,10 @@ Detailed documentation at https://docs.prowler.com
and (sys.argv[1] not in ("-v", "--version"))
):
# Since the provider is always the second argument, we are checking if
# a flag, starting by "-", is supplied
if "-" in sys.argv[1]:
# a flag is supplied. Use startswith("-") instead of "in" to avoid
# matching external provider names that contain hyphens
# (e.g. "local-acme-snowflake").
if sys.argv[1].startswith("-"):
sys.argv = self.__set_default_provider__(sys.argv)
# Provider aliases mapping
+26 -8
View File
@@ -253,14 +253,32 @@ def display_compliance_table(
compliance_overview,
)
else:
get_generic_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
# Try provider-specific table first, fall back to generic
from prowler.providers.common.provider import Provider
provider = Provider.get_global_provider()
handled = False
if provider is not None:
try:
handled = provider.display_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
except NotImplementedError:
handled = False
if not handled:
get_generic_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
@@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput):
Returns:
- None
"""
def compliance_row(requirement, attribute, finding=None):
# Read attribute fields defensively: GenericCompliance is the
# last-resort renderer for any framework, and provider-specific
# schemas (e.g. CIS, ENS, ISO27001) do not declare the universal
# Section/SubSection/SubGroup/Service/Type/Comment fields.
return GenericComplianceModel(
Provider=(finding.provider if finding else compliance.Provider.lower()),
Description=compliance.Description,
AccountId=finding.account_uid if finding else "",
Region=finding.region if finding else "",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=getattr(attribute, "Section", None),
Requirements_Attributes_SubSection=getattr(
attribute, "SubSection", None
),
Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None),
Requirements_Attributes_Service=getattr(attribute, "Service", None),
Requirements_Attributes_Type=getattr(attribute, "Type", None),
Requirements_Attributes_Comment=getattr(attribute, "Comment", None),
Status=finding.status if finding else "MANUAL",
StatusExtended=(finding.status_extended if finding else "Manual check"),
ResourceId=finding.resource_uid if finding else "manual_check",
ResourceName=finding.resource_name if finding else "Manual check",
CheckId=finding.check_id if finding else "manual",
Muted=finding.muted if finding else False,
Framework=compliance.Framework,
Name=compliance.Name,
)
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = GenericComplianceModel(
Provider=finding.provider,
Description=compliance.Description,
AccountId=finding.account_uid,
Region=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_SubGroup=attribute.SubGroup,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Requirements_Attributes_Comment=attribute.Comment,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
self._data.append(
compliance_row(requirement, attribute, finding)
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = GenericComplianceModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
AccountId="",
Region="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_SubGroup=attribute.SubGroup,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Requirements_Attributes_Comment=attribute.Comment,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
self._data.append(compliance_row(requirement, attribute))
+5
View File
@@ -517,6 +517,11 @@ class Finding(BaseModel):
check_output, "fixed_version", ""
)
else:
# Dynamic fallback: any external/custom provider
provider_data = provider.get_finding_output_data(check_output)
output_data.update(provider_data)
# check_output Unique ID
# TODO: move this to a function
# TODO: in Azure, GCP and K8s there are findings without resource_name
+7 -5
View File
@@ -1608,11 +1608,13 @@ class HTML(Output):
# Azure_provider --> azure
# Kubernetes_provider --> kubernetes
# Dynamically get the Provider quick inventory handler
provider_html_assessment_summary_function = (
f"get_{provider.type}_assessment_summary"
)
return getattr(HTML, provider_html_assessment_summary_function)(provider)
# Try static method first, fall back to provider method
method_name = f"get_{provider.type}_assessment_summary"
if hasattr(HTML, method_name):
return getattr(HTML, method_name)(provider)
else:
# Dynamic fallback: any external/custom provider
return provider.get_html_assessment_summary()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+15 -1
View File
@@ -229,7 +229,9 @@ class MarkdownToADFConverter:
return node
def _paragraph_with_text(self, text: str) -> Dict:
return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
# ADF forbids empty text nodes; emit an empty paragraph instead.
content = [self._create_text_node(text, None)] if text else []
return {"type": "paragraph", "content": content}
@staticmethod
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
@@ -1118,6 +1120,18 @@ class Jira:
tenant_info: str = "",
) -> dict:
# ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT.
def _safe(value: str) -> str:
return value if (value and value.strip()) else "-"
check_id = _safe(check_id)
check_title = _safe(check_title)
status_extended = _safe(status_extended)
provider = _safe(provider)
region = _safe(region)
resource_uid = _safe(resource_uid)
resource_name = _safe(resource_name)
table_rows = [
{
"type": "tableRow",
+36 -22
View File
@@ -7,45 +7,52 @@ from prowler.lib.outputs.common import Status
from prowler.lib.outputs.finding import Finding
def stdout_report(finding, color, verbose, status, fix):
def stdout_report(finding, color, verbose, status, fix, provider=None):
if finding.check_metadata.Provider == "aws":
details = finding.region
if finding.check_metadata.Provider == "azure":
elif finding.check_metadata.Provider == "azure":
details = finding.location
if finding.check_metadata.Provider == "gcp":
elif finding.check_metadata.Provider == "gcp":
details = finding.location.lower()
if finding.check_metadata.Provider == "kubernetes":
elif finding.check_metadata.Provider == "kubernetes":
details = finding.namespace.lower()
if finding.check_metadata.Provider == "github":
elif finding.check_metadata.Provider == "github":
details = finding.owner
if finding.check_metadata.Provider == "m365":
elif finding.check_metadata.Provider == "m365":
details = finding.location
if finding.check_metadata.Provider == "mongodbatlas":
elif finding.check_metadata.Provider == "mongodbatlas":
details = finding.location
if finding.check_metadata.Provider == "nhn":
elif finding.check_metadata.Provider == "nhn":
details = finding.location
if finding.check_metadata.Provider == "stackit":
elif finding.check_metadata.Provider == "stackit":
details = finding.location
if finding.check_metadata.Provider == "llm":
elif finding.check_metadata.Provider == "llm":
details = finding.check_metadata.CheckID
if finding.check_metadata.Provider == "iac":
elif finding.check_metadata.Provider == "iac":
details = finding.check_metadata.CheckID
if finding.check_metadata.Provider == "oraclecloud":
elif finding.check_metadata.Provider == "oraclecloud":
details = finding.region
if finding.check_metadata.Provider == "alibabacloud":
elif finding.check_metadata.Provider == "alibabacloud":
details = finding.region
if finding.check_metadata.Provider == "openstack":
elif finding.check_metadata.Provider == "openstack":
details = finding.region
if finding.check_metadata.Provider == "cloudflare":
elif finding.check_metadata.Provider == "cloudflare":
details = finding.zone_name
if finding.check_metadata.Provider == "googleworkspace":
elif finding.check_metadata.Provider == "googleworkspace":
details = finding.location
if finding.check_metadata.Provider == "vercel":
elif finding.check_metadata.Provider == "vercel":
details = finding.region
if finding.check_metadata.Provider == "okta":
elif finding.check_metadata.Provider == "okta":
details = finding.region
if finding.check_metadata.Provider == "scaleway":
elif finding.check_metadata.Provider == "scaleway":
details = finding.region
else:
# Dynamic fallback: any external/custom provider
if provider is None:
from prowler.providers.common.provider import Provider
provider = Provider.get_global_provider()
details = provider.get_stdout_detail(finding)
if (verbose or fix) and (not status or finding.status in status):
if finding.muted:
@@ -65,12 +72,15 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "verbose"):
verbose = output_options.verbose
if check_findings:
# TO-DO Generic Function
if provider.type == "aws":
check_findings.sort(key=lambda x: x.region)
if provider.type == "azure":
elif provider.type == "azure":
check_findings.sort(key=lambda x: x.subscription)
else:
# Dynamic fallback: any external/custom provider
sort_key = provider.get_finding_sort_key()
if sort_key and isinstance(sort_key, str):
check_findings.sort(key=lambda x: getattr(x, sort_key, ""))
for finding in check_findings:
# Print findings by stdout
@@ -81,12 +91,16 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "fixer"):
fixer = output_options.fixer
color = set_report_color(finding.status, finding.muted)
# Pass the local `provider` through so the dynamic else inside
# `stdout_report` does not have to consult the global singleton
# — defeating the whole purpose of the new parameter.
stdout_report(
finding,
color,
verbose,
status,
fixer,
provider=provider,
)
else: # No service resources in the whole account
+3
View File
@@ -121,6 +121,9 @@ def display_summary_table(
elif provider.type == "scaleway":
entity_type = "Organization"
audited_entities = provider.identity.organization_id
else:
# Dynamic fallback: any external/custom provider
entity_type, audited_entities = provider.get_summary_entity()
# Check if there are findings and that they are not all MANUAL
if findings and not all(finding.status == "MANUAL" for finding in findings):
+9 -4
View File
@@ -4,8 +4,8 @@ from types import SimpleNamespace
from typing import Generator
from prowler.lib.check.check import (
_resolve_check_module,
execute,
import_check,
list_services,
update_audit_metadata,
)
@@ -426,9 +426,14 @@ class Scan:
# Recover service from check name
service = get_service_name_from_check_name(check_name)
try:
# Import check module
check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point) —
# delegates to `_resolve_check_module` so external
# providers registered via entry points are resolved
# correctly (their checks do not live under
# `prowler.providers.{type}.services...`).
lib = _resolve_check_module(
self._provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -0,0 +1,40 @@
{
"Provider": "aws",
"CheckID": "elbv2_alb_drop_invalid_header_fields_enabled",
"CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Initial Access",
"Effects/Data Exposure"
],
"ServiceName": "elbv2",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsElbv2LoadBalancer",
"ResourceGroup": "network",
"Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.",
"Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields",
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4"
],
"Remediation": {
"Code": {
"CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn <ALB_ARN> --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true",
"NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - <example_subnet_id1>\n - <example_subnet_id2>\n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```",
"Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.",
"Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n load_balancer_type = \"application\"\n subnets = [\"<example_subnet_id1>\", \"<example_subnet_id2>\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```"
},
"Recommendation": {
"Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.",
"Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,27 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client
class elbv2_alb_drop_invalid_header_fields_enabled(Check):
def execute(self):
findings = []
for lb in elbv2_client.loadbalancersv2.values():
if lb.type == "application":
report = Check_Report_AWS(
metadata=self.metadata(),
resource=lb,
)
report.status = "PASS"
report.status_extended = (
f"ELBv2 ALB {lb.name} is configured to drop invalid "
"header fields."
)
if lb.drop_invalid_header_fields != "true":
report.status = "FAIL"
report.status_extended = (
f"ELBv2 ALB {lb.name} is not configured to drop "
"invalid header fields."
)
findings.append(report)
return findings
+35 -12
View File
@@ -16,18 +16,41 @@ def init_providers_parser(self):
# We need to call the arguments parser for each provider
providers = Provider.get_available_providers()
for provider in providers:
try:
getattr(
import_module(
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
),
init_provider_arguments_function,
)(self)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
# Discriminate built-in vs external upfront via find_spec, so an
# ImportError from a transitive dependency missing inside a built-in
# arguments module surfaces clearly instead of being silently
# re-routed to the entry-point path (which only has external providers).
if Provider.is_builtin(provider):
try:
getattr(
import_module(
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
),
init_provider_arguments_function,
)(self)
except ImportError as e:
logger.critical(
f"Failed to load arguments for built-in provider '{provider}'. "
f"Missing dependency: {e}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
sys.exit(1)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
else:
# External provider — init_parser classmethod via entry point
cls = Provider._load_ep_provider(provider)
if cls and hasattr(cls, "init_parser"):
try:
cls.init_parser(self)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]:
+29
View File
@@ -0,0 +1,29 @@
"""Leaf helper for built-in provider detection.
Lives in its own module with no imports back into `prowler.lib.check` so
that callers in `prowler.lib.check.*` can ask "is this provider built-in?"
without creating an import cycle through `prowler.providers.common.provider`
(which transitively imports `prowler.config.config` and from there
`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`).
Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate
to a leaf module is the canonical way to break the cycle in this codebase.
"""
import importlib.util
def is_builtin_provider(provider: str) -> bool:
"""Return True if the provider's own package ships with the SDK.
Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)`
because `find_spec` propagates `ModuleNotFoundError` when a parent package
in the dotted path does not exist (instead of returning `None`). The
try/except is what makes the call safe for external providers, whose
package does not live under `prowler.providers.{provider}`.
"""
try:
spec = importlib.util.find_spec(f"prowler.providers.{provider}")
return spec is not None
except (ImportError, ValueError):
return False
+13
View File
@@ -4,6 +4,7 @@ from os.path import isdir
from pydantic.v1 import BaseModel
from prowler.config.config import output_file_timestamp
from prowler.providers.common.provider import Provider
@@ -69,3 +70,15 @@ class Connection:
is_connected: bool = False
error: Exception = None
def default_output_options(provider, arguments, bulk_checks_metadata):
"""Generic OutputOptions fallback for external providers that do not
implement get_output_options, so the run still produces output instead of
aborting. Honors arguments.output_filename and otherwise derives a name
from the provider type."""
output_options = ProviderOutputOptions(arguments, bulk_checks_metadata)
output_options.output_filename = getattr(arguments, "output_filename", None) or (
f"prowler-output-{provider.type}-{output_file_timestamp}"
)
return output_options
+345 -32
View File
@@ -1,4 +1,6 @@
import importlib
import importlib.metadata
import importlib.util
import os
import pkgutil
import sys
@@ -136,6 +138,155 @@ class Provider(ABC):
"""
return set()
# --- Dynamic provider contract methods (not @abstractmethod for incremental migration) ---
_cli_help_text: str = ""
@classmethod
def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider":
"""Instantiate the provider from CLI arguments and return the instance.
The caller wires the returned instance into the global provider slot
via Provider.set_global_provider(). Implementations that already call
set_global_provider(self) from __init__ are also supported the call
site tolerates a None return in that case.
"""
raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()")
def get_output_options(self, arguments, _bulk_checks_metadata):
"""Create the provider-specific OutputOptions."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_output_options()"
)
def get_stdout_detail(self, _finding) -> str:
"""Return the detail string for stdout reporting (region, location, etc.)."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_stdout_detail()"
)
def get_finding_sort_key(self) -> Optional[str]:
"""Return the attribute name to sort findings by, or None for no sorting."""
return None
def get_summary_entity(self) -> tuple:
"""Return (entity_type, audited_entities) for the summary table."""
return (self.type, getattr(self.identity, "account_id", ""))
def get_finding_output_data(self, _check_output) -> dict:
"""Return provider-specific fields for Finding.generate_output()."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_finding_output_data()"
)
def get_html_assessment_summary(self) -> str:
"""Return the HTML assessment summary card for this provider."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_html_assessment_summary()"
)
def generate_compliance_output(
self,
_findings,
_bulk_compliance_frameworks,
_input_compliance_frameworks,
_output_options,
_generated_outputs,
) -> None:
"""Generate compliance CSV output for this provider's frameworks."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented generate_compliance_output()"
)
def get_mutelist_finding_args(self) -> dict:
"""Return extra kwargs for mutelist.is_finding_muted() besides 'finding'.
External providers must return a dict with the identity key their
Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``.
The ``finding`` kwarg is added automatically by the caller.
"""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()"
)
@classmethod
def get_scan_arguments(
cls,
provider_uid: str,
secret: dict,
mutelist_content: Optional[dict] = None,
) -> dict:
"""Build the provider constructor kwargs from a stored uid and secret.
This is the programmatic construction interface used by callers that
persist a provider as a single ``uid`` plus a ``secret`` dict (e.g. the
API), as opposed to the CLI which passes explicit per-provider flags.
The base implementation passes the secret through and adds the mutelist;
providers whose constructor needs the uid (e.g. as a subscription id) or
that rename/filter secret keys override this.
"""
kwargs = {**secret}
if mutelist_content:
kwargs["mutelist_content"] = mutelist_content
return kwargs
@classmethod
def get_connection_arguments(cls, provider_uid: str, secret: dict) -> dict:
"""Build the ``test_connection`` kwargs from a stored uid and secret.
Companion to :meth:`get_scan_arguments` for the connection check, which
often needs a different shape than the constructor. The base passes the
secret through; providers add their identity kwarg (and ``provider_id``
where their ``test_connection`` expects it).
"""
return {**secret}
@classmethod
def get_credentials_schema(cls) -> dict:
"""Return the provider's credential schemas keyed by secret type.
Maps each secret type the provider accepts (``"static"``, ``"role"`` or
``"service_account"``) to the pydantic model that validates a secret of
that type. The provider declares which type each schema belongs to, so
the API validates a secret against the model for the secret type it is
created with and the chosen type stays bound to the shape it claims.
Each model documents each field via ``Field(description=...)`` and
whether it is required (no default) or optional. An empty dict means no
schema is declared: the secret is accepted as an object and validated by
:meth:`test_connection`.
"""
return {}
def display_compliance_table(
self,
_findings: list,
_bulk_checks_metadata: dict,
_compliance_framework: str,
_output_filename: str,
_output_directory: str,
_compliance_overview: bool,
) -> bool:
"""Render a custom compliance table in the terminal.
External providers can override this to display a detailed
compliance table (e.g., per-section breakdown). Return True
if the table was rendered, False to fall back to the generic table.
"""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented display_compliance_table()"
)
# Class-level flag: True for providers that delegate scanning to an external
# tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and
# metadata validation. Subclasses override as `is_external_tool_provider = True`.
# Kept as a class attribute (not a property) so it can be read from the class
# without instantiation — the metadata validators in lib.check.models need to
# decide whether to relax validation before any provider instance exists.
is_external_tool_provider: bool = False
# --- End dynamic provider contract methods ---
@staticmethod
def get_excluded_regions_from_env() -> set:
"""Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable.
@@ -159,20 +310,57 @@ class Provider(ABC):
@staticmethod
def init_global_provider(arguments: Namespace) -> None:
try:
provider_class_path = (
f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
)
provider_class_name = f"{arguments.provider.capitalize()}Provider"
provider_class = getattr(
import_module(provider_class_path), provider_class_name
)
# Delegate class resolution to the public, side-effect-free
# resolver. init_global_provider owns the CLI-specific error
# handling: a missing transitive dep in a built-in becomes a
# logger.critical + sys.exit(1); a completely unknown provider
# re-raises so the outer try/except can sys.exit too.
try:
provider_class = Provider.get_class(arguments.provider)
except ImportError as e:
if Provider.is_builtin(arguments.provider):
# Built-in's transitive dependency is missing — loud CLI error.
logger.critical(
f"Failed to load built-in provider '{arguments.provider}'. "
f"Missing dependency: {e}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
sys.exit(1)
# Unknown or missing external provider — propagate so the
# outer try/except can handle it (sys.exit(1) via generic
# exception handler).
raise
# Built-in wins on name collision — warn that a same-named
# plug-in is ignored. This lives here (not in get_class) so
# that `prowler --help` and API callers that resolve a class
# without initialising a global provider do not see spurious
# warnings. Match by name only — never ep.load() a shadowing
# plug-in, or its module code would run during a built-in run.
if Provider.is_builtin(arguments.provider) and any(
ep.name == arguments.provider
for ep in importlib.metadata.entry_points(group="prowler.providers")
):
logger.warning(
f"Plug-in provider '{arguments.provider}' registered "
f"via entry points is being IGNORED — a built-in with "
f"the same name exists. To use your plug-in, register "
f"it under a different name."
)
fixer_config = load_and_validate_config_file(
arguments.provider, arguments.fixer_config
)
# Dispatch by exact provider name (equality, not substring) so
# external plug-ins whose names contain a built-in substring
# (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed
# to the wrong built-in branch. Anything that doesn't match a
# built-in falls through to the dynamic else and uses the
# contract's `from_cli_args`.
if not isinstance(Provider._global, provider_class):
if "aws" in provider_class_name.lower():
if arguments.provider == "aws":
excluded_regions = (
set(arguments.excluded_region)
if getattr(arguments, "excluded_region", None)
@@ -196,7 +384,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "azure" in provider_class_name.lower():
elif arguments.provider == "azure":
provider_class(
az_cli_auth=arguments.az_cli_auth,
sp_env_auth=arguments.sp_env_auth,
@@ -209,7 +397,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "gcp" in provider_class_name.lower():
elif arguments.provider == "gcp":
provider_class(
retries_max_attempts=arguments.gcp_retries_max_attempts,
organization_id=arguments.organization_id,
@@ -223,7 +411,7 @@ class Provider(ABC):
fixer_config=fixer_config,
skip_api_check=arguments.skip_api_check,
)
elif "kubernetes" in provider_class_name.lower():
elif arguments.provider == "kubernetes":
provider_class(
kubeconfig_file=arguments.kubeconfig_file,
context=arguments.context,
@@ -233,7 +421,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "m365" in provider_class_name.lower():
elif arguments.provider == "m365":
provider_class(
region=arguments.region,
config_path=arguments.config_file,
@@ -247,7 +435,7 @@ class Provider(ABC):
init_modules=arguments.init_modules,
fixer_config=fixer_config,
)
elif "nhn" in provider_class_name.lower():
elif arguments.provider == "nhn":
provider_class(
username=arguments.nhn_username,
password=arguments.nhn_password,
@@ -256,7 +444,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "stackit" in provider_class_name.lower():
elif arguments.provider == "stackit":
provider_class(
project_id=arguments.stackit_project_id,
service_account_key_path=getattr(
@@ -275,7 +463,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "github" in provider_class_name.lower():
elif arguments.provider == "github":
orgs = []
repos = []
@@ -307,13 +495,13 @@ class Provider(ABC):
exclude_workflows=getattr(arguments, "exclude_workflows", []),
fixer_config=fixer_config,
)
elif "googleworkspace" in provider_class_name.lower():
elif arguments.provider == "googleworkspace":
provider_class(
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "cloudflare" in provider_class_name.lower():
elif arguments.provider == "cloudflare":
provider_class(
filter_zones=arguments.region,
filter_accounts=arguments.account_id,
@@ -321,7 +509,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "iac" in provider_class_name.lower():
elif arguments.provider == "iac":
provider_class(
scan_path=arguments.scan_path,
scan_repository_url=arguments.scan_repository_url,
@@ -334,13 +522,13 @@ class Provider(ABC):
oauth_app_token=arguments.oauth_app_token,
provider_uid=arguments.provider_uid,
)
elif "llm" in provider_class_name.lower():
elif arguments.provider == "llm":
provider_class(
max_concurrency=arguments.max_concurrency,
config_path=arguments.config_file,
fixer_config=fixer_config,
)
elif "image" in provider_class_name.lower():
elif arguments.provider == "image":
provider_class(
images=arguments.images,
image_list_file=arguments.image_list_file,
@@ -358,7 +546,7 @@ class Provider(ABC):
registry_insecure=arguments.registry_insecure,
registry_list_images=arguments.registry_list_images,
)
elif "mongodbatlas" in provider_class_name.lower():
elif arguments.provider == "mongodbatlas":
provider_class(
atlas_public_key=arguments.atlas_public_key,
atlas_private_key=arguments.atlas_private_key,
@@ -367,7 +555,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "oraclecloud" in provider_class_name.lower():
elif arguments.provider == "oraclecloud":
provider_class(
oci_config_file=arguments.oci_config_file,
profile=arguments.profile,
@@ -378,7 +566,7 @@ class Provider(ABC):
fixer_config=fixer_config,
use_instance_principal=arguments.use_instance_principal,
)
elif "openstack" in provider_class_name.lower():
elif arguments.provider == "openstack":
provider_class(
clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None),
clouds_yaml_content=getattr(
@@ -403,7 +591,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "alibabacloud" in provider_class_name.lower():
elif arguments.provider == "alibabacloud":
provider_class(
role_arn=arguments.role_arn,
role_session_name=arguments.role_session_name,
@@ -415,14 +603,14 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "vercel" in provider_class_name.lower():
elif arguments.provider == "vercel":
provider_class(
projects=getattr(arguments, "project", None),
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "okta" in provider_class_name.lower():
elif arguments.provider == "okta":
provider_class(
okta_org_domain=getattr(arguments, "okta_org_domain", ""),
okta_client_id=getattr(arguments, "okta_client_id", ""),
@@ -435,7 +623,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "scaleway" in provider_class_name.lower():
elif arguments.provider == "scaleway":
# Credentials are read from the SCW_ACCESS_KEY /
# SCW_SECRET_KEY env vars by the provider itself; there
# are no credential CLI flags to avoid leaking secrets.
@@ -447,6 +635,18 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
else:
# Dynamic fallback: any external/custom provider.
# Honor the from_cli_args type hint (-> Provider): if the
# implementation returns an instance, wire it as the global
# provider here. Implementations that call
# set_global_provider(self) from __init__ return None and
# remain supported (the condition below is a no-op for them).
provider_instance = provider_class.from_cli_args(
arguments, fixer_config
)
if provider_instance is not None:
Provider.set_global_provider(provider_instance)
except TypeError as error:
logger.critical(
@@ -459,17 +659,130 @@ class Provider(ABC):
)
sys.exit(1)
# Cache for entry-point provider classes {name: class}
_ep_providers: dict = {}
@staticmethod
def get_available_providers() -> list[str]:
"""get_available_providers returns a list of the available providers"""
providers = []
# Dynamically import the package based on its string path
providers = set()
# Built-in providers from local package
prowler_providers = importlib.import_module(providers_path)
# Iterate over all modules found in the prowler_providers package
for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__):
if provider != "common" and ispkg:
providers.append(provider)
return providers
providers.add(provider)
# External providers registered via entry points
for ep in importlib.metadata.entry_points(group="prowler.providers"):
providers.add(ep.name)
return sorted(providers)
@staticmethod
def is_tool_wrapper_provider(provider: str) -> bool:
"""Return True if the provider delegates scanning to an external tool.
Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`,
the leaf module that holds the actual logic. Kept on `Provider` as a
convenience entry point for callers that already import `Provider`.
"""
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl
return _impl(provider)
@staticmethod
def is_builtin(provider: str) -> bool:
"""Return True if the provider's own package is importable as a built-in.
Delegates to `prowler.providers.common.builtin.is_builtin_provider`,
the leaf module that holds the actual check. Kept on `Provider` as a
convenience entry point for callers that already import `Provider`.
Call sites in `prowler.lib.check.*` should import from the leaf
directly to avoid the import cycle through this module.
"""
from prowler.providers.common.builtin import is_builtin_provider as _impl
return _impl(provider)
@staticmethod
def _load_ep_provider(name: str):
"""Load an external provider class from entry points, with cache.
Caches both hits and misses so repeated lookups for unknown names do
not re-iterate entry_points(). Symmetric with
tool_wrapper._ep_class_cache.
"""
if name in Provider._ep_providers:
return Provider._ep_providers[name]
for ep in importlib.metadata.entry_points(group="prowler.providers"):
if ep.name == name:
try:
cls = ep.load()
Provider._ep_providers[name] = cls
return cls
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
Provider._ep_providers[name] = None
return None
@staticmethod
def get_class(provider: str) -> type:
"""Resolve the provider class for a name (built-in or entry-point).
Does not call ``sys.exit`` and does not initialize the global
provider (it may populate the ``_ep_providers`` memoization cache).
Collision warnings are emitted by ``init_global_provider``, not here.
The caller handles errors (CLI exits; the API can return HTTP 400).
Args:
provider: Provider name, e.g. ``"aws"`` or an external plug-in.
Returns:
The provider class (a subclass of :class:`Provider`).
Raises:
ImportError: If not found as built-in or entry point, or a
built-in's transitive dependency is missing.
"""
if Provider.is_builtin(provider):
provider_class_path = f"{providers_path}.{provider}.{provider}_provider"
provider_class_name = f"{provider.capitalize()}Provider"
# Let ImportError propagate — the caller decides whether to
# sys.exit (CLI) or return HTTP 400 (API).
module = import_module(provider_class_path)
try:
return getattr(module, provider_class_name)
except AttributeError:
# Module exists but doesn't define the expected class —
# fall through to entry points.
cls = Provider._load_ep_provider(provider)
if cls is not None:
return cls
raise ImportError(
f"Provider '{provider}' not found as built-in or entry point"
)
cls = Provider._load_ep_provider(provider)
if cls is None:
raise ImportError(
f"Provider '{provider}' not found as built-in or entry point"
)
return cls
@staticmethod
def get_providers_help_text() -> dict:
"""Returns a dict of {provider_name: cli_help_text} for all available providers."""
help_text = {}
for name in Provider.get_available_providers():
try:
cls = Provider.get_class(name)
help_text[name] = getattr(cls, "_cli_help_text", "")
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
help_text[name] = ""
return help_text
@staticmethod
def update_provider_config(audit_config: dict, variable: str, value: str):
@@ -37,6 +37,7 @@ class IAM(GCPService):
display_name=account.get("displayName", ""),
project_id=project_id,
uniqueId=account.get("uniqueId", ""),
disabled=account.get("disabled", False),
)
)
@@ -102,6 +103,7 @@ class ServiceAccount(BaseModel):
keys: list[Key] = []
project_id: str
uniqueId: str
disabled: bool = False
class AccessApproval(GCPService):
@@ -19,7 +19,12 @@ class iam_service_account_unused(Check):
resource_id=account.email,
location=iam_client.region,
)
if account.uniqueId in sa_ids_used:
if account.disabled:
report.status = "PASS"
report.status_extended = (
f"Service Account {account.email} is disabled and cannot be used."
)
elif account.uniqueId in sa_ids_used:
report.status = "PASS"
report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days."
else:
@@ -12,6 +12,7 @@ class Logging(GCPService):
self.sinks = []
self.metrics = []
self._get_sinks()
self._get_org_sinks()
self._get_metrics()
def _get_sinks(self):
@@ -39,6 +40,38 @@ class Logging(GCPService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_org_sinks(self):
"""Fetch org-level sinks with includeChildren so child projects are not falsely failed."""
org_ids = set()
for project in self.projects.values():
if project.organization:
org_ids.add(project.organization.id)
for org_id in org_ids:
try:
request = self.client.sinks().list(parent=f"organizations/{org_id}")
while request is not None:
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
for sink in response.get("sinks", []):
self.sinks.append(
Sink(
name=sink["name"],
destination=sink["destination"],
filter=sink.get("filter", "all"),
project_id=f"organizations/{org_id}",
include_children=sink.get("includeChildren", False),
)
)
request = self.client.sinks().list_next(
previous_request=request, previous_response=response
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_metrics(self):
for project_id in self.project_ids:
try:
@@ -76,6 +109,7 @@ class Sink(BaseModel):
destination: str
filter: str
project_id: str
include_children: bool = False
class Metric(BaseModel):
@@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client
class logging_sink_created(Check):
def execute(self) -> Check_Report_GCP:
findings = []
# Map project_id -> sink for direct project-level sinks
projects_with_logging_sink = {}
for sink in logging_client.sinks:
if sink.filter == "all":
if sink.filter == "all" and not sink.include_children:
projects_with_logging_sink[sink.project_id] = sink
# Collect org resource names that have a covering sink (includeChildren=True)
covering_org_sinks = {}
for sink in logging_client.sinks:
if sink.filter == "all" and sink.include_children:
covering_org_sinks[sink.project_id] = sink
for project in logging_client.project_ids:
if project not in projects_with_logging_sink.keys():
project_obj = logging_client.projects.get(project)
report = Check_Report_GCP(
metadata=self.metadata(),
resource=project_obj,
resource_id=project,
project_id=project,
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
report.status = "FAIL"
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
findings.append(report)
else:
project_obj = logging_client.projects.get(project)
# Determine whether this project is covered by an org-level sink
org = getattr(project_obj, "organization", None) if project_obj else None
org_resource = f"organizations/{org.id}" if org else None
covering_sink = (
covering_org_sinks.get(org_resource) if org_resource else None
)
if project in projects_with_logging_sink:
sink = projects_with_logging_sink[project]
sink_name = getattr(sink, "name", None) or "unknown"
report = Check_Report_GCP(
@@ -40,4 +44,31 @@ class logging_sink_created(Check):
report.status = "PASS"
report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}."
findings.append(report)
elif covering_sink:
sink_name = getattr(covering_sink, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
resource=covering_sink,
resource_id=sink_name,
project_id=project,
location=logging_client.region,
resource_name=(
sink_name if sink_name != "unknown" else "Logging Sink"
),
)
report.status = "PASS"
report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}."
findings.append(report)
else:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=project_obj,
resource_id=project,
project_id=project,
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
report.status = "FAIL"
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
findings.append(report)
return findings
+52
View File
@@ -471,6 +471,32 @@ class Test_Config:
all_frameworks = get_available_compliance_frameworks()
assert "csa_ccm_4.0" in all_frameworks
@mock.patch("prowler.config.config._get_ep_compliance_dirs")
def test_get_available_compliance_frameworks_dedupes_ep_collisions_with_builtins(
self, mock_dirs
):
"""Entry-point compliance frameworks that collide with a built-in
name must appear only once in the available frameworks list.
Built-in wins silently same policy as the universal frameworks
loop and as Compliance.get_bulk."""
import json
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
# cis_2.0_aws ships as a built-in under prowler/compliance/aws/
json_path = os.path.join(tmpdir, "cis_2.0_aws.json")
with open(json_path, "w") as f:
json.dump({"Framework": "CIS", "Provider": "aws"}, f)
mock_dirs.return_value = {"aws": tmpdir}
frameworks = get_available_compliance_frameworks("aws")
assert frameworks.count("cis_2.0_aws") == 1, (
f"Expected cis_2.0_aws to appear exactly once, got "
f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}"
)
def test_load_and_validate_config_file_aws(self):
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
config_test_file = f"{path}/fixtures/config.yaml"
@@ -508,6 +534,32 @@ class Test_Config:
assert load_and_validate_config_file("azure", config_test_file) == {}
assert load_and_validate_config_file("kubernetes", config_test_file) == {}
def test_load_and_validate_config_file_namespaced_non_listed_provider(self):
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
# github is a built-in not in the legacy hardcoded list; namespaced format must unwrap it.
assert load_and_validate_config_file("github", config_test_file) == {
"token": "abc",
"org": "prowler-cloud",
}
def test_load_and_validate_config_file_namespaced_external_provider(self):
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
# External plug-in provider: namespaced format must unwrap its block.
assert load_and_validate_config_file("custom_plugin", config_test_file) == {
"setting": "value",
"nested": {"key": 42},
}
def test_load_and_validate_config_file_namespaced_missing_provider(self):
path = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
config_test_file = f"{path}/fixtures/config_namespaced_external.yaml"
# Provider with no section in a namespaced file must return empty config,
# not the full file (prevents cross-provider config leakage).
assert load_and_validate_config_file("aws", config_test_file) == {}
assert load_and_validate_config_file("gcp", config_test_file) == {}
def test_load_and_validate_config_file_invalid_config_file_path(self, caplog):
provider = "aws"
config_file_path = "invalid/path/to/fixer_config.yaml"
@@ -0,0 +1,8 @@
# Namespaced config covering a non-listed built-in (github) and an external plugin.
github:
token: abc
org: prowler-cloud
custom_plugin:
setting: value
nested:
key: 42
+3 -1
View File
@@ -540,7 +540,9 @@ class TestCompliance:
):
object = mock.Mock()
object.path = "/path/to/compliance"
object.name = "framework1_aws"
# list_compliance_modules yields dotted module names; get_bulk matches
# the last segment exactly against the provider.
object.name = "prowler.compliance.aws"
mock_list_modules.return_value = [object]
mock_listdir.return_value = ["framework1_aws.json"]
+32
View File
@@ -95,6 +95,38 @@ class TestCheckMetada:
"/path/to/accessanalyzer_enabled/accessanalyzer_enabled.metadata.json"
)
@mock.patch("prowler.lib.check.models.logger")
@mock.patch("prowler.lib.check.models.load_check_metadata")
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
def test_get_bulk_builtin_wins_on_check_id_collision(
self, mock_recover_checks, mock_load_metadata, mock_logger
):
"""Regression guard: when an entry-point plug-in re-registers a
built-in CheckID, the BUILT-IN metadata wins (first-write-wins) and
the plug-in is IGNORED. The override is surfaced via a warning so
the user knows their plug-in duplicate is being skipped and can
rename it. Matches the precedence in `_resolve_check_module`. See
PR #10700 review (HugoPBrito)."""
# Built-in first, plug-in last (matches recover_checks_from_provider order)
mock_recover_checks.return_value = [
("accessanalyzer_enabled", "/builtin/accessanalyzer_enabled"),
("accessanalyzer_enabled", "/plugin/accessanalyzer_enabled"),
]
builtin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled")
plugin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled")
mock_load_metadata.side_effect = [builtin_metadata, plugin_metadata]
result = CheckMetadata.get_bulk(provider="aws")
# Built-in wins (first-write-wins on CheckID), plug-in is ignored
assert result["accessanalyzer_enabled"] is builtin_metadata
# Override is surfaced via warning naming the plug-in metadata file
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args.args[0]
assert "accessanalyzer_enabled" in warning_msg
assert "/plugin/accessanalyzer_enabled" in warning_msg
@mock.patch("prowler.lib.check.models.load_check_metadata")
@mock.patch("prowler.lib.check.models.recover_checks_from_provider")
def test_list(self, mock_recover_checks, mock_load_metadata):
+124
View File
@@ -0,0 +1,124 @@
"""Unit tests for prowler.lib.check.tool_wrapper.
Covers the leaf helper directly (Provider.is_tool_wrapper_provider delegates
to it). Tests the frozenset fast path, the entry-point fallback for external
plug-ins, the broken-plug-in path, the no-match path, and the module-level
cache.
"""
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _clear_ep_class_cache():
"""Reset the leaf module's cache between tests so they stay independent."""
from prowler.lib.check import tool_wrapper
tool_wrapper._ep_class_cache.clear()
yield
tool_wrapper._ep_class_cache.clear()
def _make_entry_point(name, cls):
"""Create a mock entry point whose `load()` returns `cls`."""
ep = MagicMock()
ep.name = name
ep.load.return_value = cls
return ep
class TestIsToolWrapperProvider:
"""is_tool_wrapper_provider: frozenset + entry-point fallback."""
@pytest.mark.parametrize("name", ["iac", "llm", "image"])
def test_returns_true_for_builtin_tool_wrappers(self, name):
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
assert is_tool_wrapper_provider(name) is True
@pytest.mark.parametrize("name", ["aws", "azure", "gcp", "github", "kubernetes"])
def test_returns_false_for_regular_builtins(self, name):
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
assert is_tool_wrapper_provider(name) is False
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_returns_true_for_external_plugin_with_flag(self, mock_eps):
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
cls = MagicMock(is_external_tool_provider=True)
mock_eps.return_value = [_make_entry_point("custom_wrapper", cls)]
assert is_tool_wrapper_provider("custom_wrapper") is True
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_returns_false_for_external_plugin_without_flag(self, mock_eps):
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
cls = MagicMock(is_external_tool_provider=False)
mock_eps.return_value = [_make_entry_point("vanilla_external", cls)]
assert is_tool_wrapper_provider("vanilla_external") is False
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_returns_false_for_unknown_provider(self, mock_eps):
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
mock_eps.return_value = []
assert is_tool_wrapper_provider("does-not-exist") is False
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_builtin_name_shortcircuits_before_loading_same_name_plugin(self, mock_eps):
"""A plug-in registered under a built-in's name cannot flip the
built-in onto the tool-wrapper path, and its module is never loaded."""
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
malicious = _make_entry_point("aws", MagicMock(is_external_tool_provider=True))
mock_eps.return_value = [malicious]
# `aws` is a built-in, so classification short-circuits to False...
assert is_tool_wrapper_provider("aws") is False
# ...and the shadowing plug-in's code is never executed via ep.load().
malicious.load.assert_not_called()
class TestLoadEpClass:
"""_load_ep_class: cache, broken plug-ins, no-match."""
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_caches_result_across_calls(self, mock_eps):
from prowler.lib.check.tool_wrapper import _load_ep_class
cls = MagicMock(is_external_tool_provider=True)
mock_eps.return_value = [_make_entry_point("cached_one", cls)]
first = _load_ep_class("cached_one")
second = _load_ep_class("cached_one")
assert first is cls
assert second is cls
# entry_points consulted only on the first call
assert mock_eps.call_count == 1
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_returns_none_for_broken_plugin(self, mock_eps):
from prowler.lib.check.tool_wrapper import _load_ep_class
broken_ep = MagicMock()
broken_ep.name = "broken"
broken_ep.load.side_effect = ImportError("plug-in is broken")
mock_eps.return_value = [broken_ep]
assert _load_ep_class("broken") is None
@patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points")
def test_returns_none_when_no_entry_point_matches(self, mock_eps):
from prowler.lib.check.tool_wrapper import _load_ep_class
cls = MagicMock()
mock_eps.return_value = [_make_entry_point("other_provider", cls)]
assert _load_ep_class("missing_provider") is None
@@ -1,5 +1,7 @@
import json
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from pydantic.v1 import ValidationError
@@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import (
TableLabels,
UniversalComplianceRequirement,
adapt_legacy_to_universal,
get_bulk_compliance_frameworks_universal,
load_compliance_framework_universal,
)
from tests.lib.outputs.compliance.fixtures import (
@@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation:
],
attributes_metadata=self._metadata(enum=["high", "low"]),
)
class TestGetBulkUniversalEntryPoints:
"""Entry-point discovery for universal (multi-provider) compliance frameworks."""
@staticmethod
def _write_universal_json(directory, filename, framework, display_name):
data = {
"framework": framework,
"name": display_name,
"version": "1.0",
"description": "External multi-provider framework",
"requirements": [
{
"id": "1",
"name": "Requirement 1",
"description": "desc",
"checks": {"fakeexternal": ["check_a"]},
}
],
}
with open(os.path.join(directory, filename), "w") as f:
json.dump(data, f)
@staticmethod
def _entry_point(path):
module = MagicMock()
module.__path__ = [path]
ep = MagicMock()
ep.name = "fakeexternal"
ep.group = "prowler.compliance.universal"
ep.load.return_value = module
return ep
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_includes_external_universal_framework(self, mock_list_modules, mock_ep):
mock_list_modules.return_value = []
with tempfile.TemporaryDirectory() as ep_dir:
self._write_universal_json(
ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom"
)
mock_ep.return_value = [self._entry_point(ep_dir)]
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
mock_ep.assert_called_with(group="prowler.compliance.universal")
assert "customuniversal_1.0" in bulk
assert bulk["customuniversal_1.0"].framework == "CustomUniversal"
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_builtin_wins_over_external_on_name_collision(
self, mock_list_modules, mock_ep
):
with (
tempfile.TemporaryDirectory() as root,
tempfile.TemporaryDirectory() as ep_dir,
):
builtin_sub = os.path.join(root, "builtinprov")
os.makedirs(builtin_sub)
self._write_universal_json(
builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in"
)
builtin_module = MagicMock()
builtin_module.module_finder.path = root
builtin_module.name = "prowler.compliance.builtinprov"
mock_list_modules.return_value = [builtin_module]
self._write_universal_json(
ep_dir, "shared_1.0.json", "SharedFramework", "External"
)
mock_ep.return_value = [self._entry_point(ep_dir)]
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
assert "shared_1.0" in bulk
assert bulk["shared_1.0"].name == "Built-in"
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_loads_all_frameworks_in_a_single_entry_point_path(
self, mock_list_modules, mock_ep
):
"""All JSONs in one entry-point directory are added, not collapsed to one."""
mock_list_modules.return_value = []
with tempfile.TemporaryDirectory() as ep_dir:
self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A")
self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B")
mock_ep.return_value = [self._entry_point(ep_dir)]
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
assert "fw_a_1.0" in bulk
assert "fw_b_1.0" in bulk
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
@patch("prowler.lib.check.compliance_models.list_compliance_modules")
def test_merges_frameworks_from_multiple_packages_same_provider(
self, mock_list_modules, mock_ep
):
"""Two packages under the same provider name are both discovered."""
mock_list_modules.return_value = []
with (
tempfile.TemporaryDirectory() as dir_a,
tempfile.TemporaryDirectory() as dir_b,
):
self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A")
self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B")
mock_ep.return_value = [
self._entry_point(dir_a),
self._entry_point(dir_b),
]
bulk = get_bulk_compliance_frameworks_universal("fakeexternal")
assert "pkg_a_1.0" in bulk
assert "pkg_b_1.0" in bulk
@@ -9,6 +9,7 @@ from prowler.lib.check.compliance_models import (
Compliance,
Compliance_Requirement,
Generic_Compliance_Requirement_Attribute,
ISO27001_2013_Requirement_Attribute,
)
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel
@@ -198,3 +199,47 @@ class TestAWSGenericCompliance:
), f"Expected 1 row driven by framework JSON, got {len(rows)}"
assert rows[0].Requirements_Id == "req_in_framework"
assert rows[0].CheckId == "service_check_in_framework"
def test_transform_tolerates_framework_specific_attribute_schema(self):
"""GenericCompliance is the documented last-resort renderer, so it must not
crash on a framework whose attribute schema lacks the universal fields
(Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares
none of them; missing fields must render as None instead of raising
AttributeError and dropping the whole CSV."""
framework_name = "ISO27001-2013-External"
compliance = Compliance(
Framework=framework_name,
Name=framework_name,
Provider="external",
Version="",
Description="Framework shipping a provider-specific attribute schema",
Requirements=[
Compliance_Requirement(
Id="A.5.1.1",
Description="Policies for information security",
Attributes=[
ISO27001_2013_Requirement_Attribute(
Category="Information security policies",
Objetive_ID="A.5.1",
Objetive_Name="Management direction",
Check_Summary="Policy is defined",
)
],
Checks=["service_test_check_id"],
)
],
)
findings = [generate_finding_output(check_id="service_test_check_id")]
output = GenericCompliance(findings, compliance)
rows = [row for row in output.data if row.Status != "MANUAL"]
assert len(rows) == 1
assert rows[0].Requirements_Id == "A.5.1.1"
assert rows[0].Requirements_Attributes_Section is None
assert rows[0].Requirements_Attributes_SubSection is None
assert rows[0].Requirements_Attributes_SubGroup is None
assert rows[0].Requirements_Attributes_Service is None
assert rows[0].Requirements_Attributes_Type is None
assert rows[0].Requirements_Attributes_Comment is None
+83
View File
@@ -1004,6 +1004,89 @@ class TestJiraIntegration:
for mark in node.get("marks", [])
)
@staticmethod
def _find_empty_text_nodes(node) -> List[str]:
# ADF forbids empty text nodes; collect any to assert the document is valid.
empties: List[str] = []
def walk(current) -> None:
if isinstance(current, dict):
if current.get("type") == "text" and current.get("text", "") == "":
empties.append(current.get("text", ""))
for value in current.values():
walk(value)
elif isinstance(current, list):
for item in current:
walk(item)
walk(node)
return empties
def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self):
# A resource without a name (e.g. an AWS-managed IAM policy) used to emit an
# empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT.
adf_description = self.jira_integration.get_adf_description(
check_id="CHECK-1",
check_title="Sample check",
severity="CRITICAL",
severity_color="#FF0000",
status="FAIL",
status_color="#FF0000",
status_extended="Some status",
provider="aws",
region="eu-west-1",
resource_uid="arn:aws:iam::aws:policy/AdministratorAccess",
resource_name="",
recommendation_text="",
)
assert self._find_empty_text_nodes(adf_description) == []
table = adf_description["content"][1]
resource_name_row = self._find_table_row(table["content"], "Resource Name")
value_cell = resource_name_row["content"][1]
assert self._collect_text_from_cell(value_cell) == "-"
@pytest.mark.parametrize(
"field, header",
[
("check_id", "Check Id"),
("check_title", "Check Title"),
("status_extended", "Status Extended"),
("provider", "Provider"),
("region", "Region"),
("resource_uid", "Resource UID"),
("resource_name", "Resource Name"),
],
)
def test_get_adf_description_empty_plain_text_fields_render_placeholder(
self, field, header
):
base_kwargs = dict(
check_id="CHECK-1",
check_title="Sample check",
severity="HIGH",
severity_color="#FF0000",
status="FAIL",
status_color="#00FF00",
status_extended="Some status",
provider="aws",
region="us-east-1",
resource_uid="resource-1",
resource_name="resource-name",
recommendation_text="",
)
base_kwargs[field] = ""
adf_description = self.jira_integration.get_adf_description(**base_kwargs)
assert self._find_empty_text_nodes(adf_description) == []
table = adf_description["content"][1]
row = self._find_table_row(table["content"], header)
value_cell = row["content"][1]
assert self._collect_text_from_cell(value_cell) == "-"
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
+4 -4
View File
@@ -51,7 +51,7 @@ def mock_provider():
def mock_execute():
with mock.patch("prowler.lib.scan.scan.execute", autospec=True) as mock_exec:
findings = [finding]
mock_exec.side_effect = lambda *args, **kwargs: findings
mock_exec.side_effect = lambda *_args, **_kwargs: findings
yield mock_exec
@@ -264,10 +264,10 @@ class TestScan:
@patch("prowler.lib.scan.scan.update_checks_metadata_with_compliance")
@patch("prowler.lib.scan.scan.Compliance.get_bulk")
@patch("prowler.lib.scan.scan.CheckMetadata.get_bulk")
@patch("prowler.lib.scan.scan.import_check")
@patch("prowler.lib.scan.scan._resolve_check_module")
def test_scan(
self,
mock_import_check,
mock_resolve_check_module,
mock_get_bulk,
mock_compliance_get_bulk,
mock_update_checks_metadata,
@@ -285,7 +285,7 @@ class TestScan:
mock_check_instance.CheckTitle = "Check if IAM Access Analyzer is enabled"
mock_check_instance.Categories = []
mock_import_check.return_value = MagicMock(
mock_resolve_check_module.return_value = MagicMock(
accessanalyzer_enabled=mock_check_class
)
@@ -0,0 +1,254 @@
from importlib import import_module
from unittest import mock
from boto3 import client, resource
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_REGION_EU_WEST_1,
AWS_REGION_EU_WEST_1_AZA,
AWS_REGION_EU_WEST_1_AZB,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
CHECK_MODULE = (
"prowler.providers.aws.services.elbv2."
"elbv2_alb_drop_invalid_header_fields_enabled."
"elbv2_alb_drop_invalid_header_fields_enabled"
)
ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client"
GLOBAL_PROVIDER_PATCH = ".".join(
[
"prowler.providers.common.provider.Provider",
"get_global_provider",
]
)
PASS_STATUS_EXTENDED = " ".join(
[
"ELBv2 ALB my-lb is configured to drop invalid",
"header fields.",
]
)
FAIL_STATUS_EXTENDED = (
"ELBv2 ALB my-lb is not configured to drop invalid header fields."
)
def get_check_class():
return getattr(
import_module(CHECK_MODULE),
"elbv2_alb_drop_invalid_header_fields_enabled",
)
class Test_elbv2_alb_drop_invalid_header_fields_enabled:
@mock_aws
def test_elb_no_balancers(self):
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
with (
mock.patch(
GLOBAL_PROVIDER_PATCH,
return_value=set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
),
),
mock.patch(
ELBV2_CLIENT_PATCH,
new=ELBv2(
set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
create_default_organization=False,
)
),
),
):
check = get_check_class()()
result = check.execute()
assert len(result) == 0
@mock_aws
def test_elbv2_dropping_invalid_header_fields(self):
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
security_group = ec2.create_security_group(
GroupName="a-security-group", Description="First One"
)
vpc = ec2.create_vpc(
CidrBlock="172.28.7.0/24",
InstanceTenancy="default",
)
subnet1 = ec2.create_subnet(
VpcId=vpc.id,
CidrBlock="172.28.7.192/26",
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
)
subnet2 = ec2.create_subnet(
VpcId=vpc.id,
CidrBlock="172.28.7.0/26",
AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
)
lb = conn.create_load_balancer(
Name="my-lb",
Subnets=[subnet1.id, subnet2.id],
SecurityGroups=[security_group.id],
Scheme="internal",
Type="application",
)["LoadBalancers"][0]
conn.modify_load_balancer_attributes(
LoadBalancerArn=lb["LoadBalancerArn"],
Attributes=[
{
"Key": "routing.http.drop_invalid_header_fields.enabled",
"Value": "true",
},
],
)
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
with (
mock.patch(
GLOBAL_PROVIDER_PATCH,
return_value=set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
),
),
mock.patch(
ELBV2_CLIENT_PATCH,
new=ELBv2(
set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
create_default_organization=False,
)
),
),
):
check = get_check_class()()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == PASS_STATUS_EXTENDED
assert result[0].resource_id == "my-lb"
assert result[0].resource_arn == lb["LoadBalancerArn"]
@mock_aws
def test_elbv2_not_dropping_invalid_header_fields(self):
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
security_group = ec2.create_security_group(
GroupName="a-security-group", Description="First One"
)
vpc = ec2.create_vpc(
CidrBlock="172.28.7.0/24",
InstanceTenancy="default",
)
subnet1 = ec2.create_subnet(
VpcId=vpc.id,
CidrBlock="172.28.7.192/26",
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
)
subnet2 = ec2.create_subnet(
VpcId=vpc.id,
CidrBlock="172.28.7.0/26",
AvailabilityZone=AWS_REGION_EU_WEST_1_AZB,
)
lb = conn.create_load_balancer(
Name="my-lb",
Subnets=[subnet1.id, subnet2.id],
SecurityGroups=[security_group.id],
Scheme="internal",
Type="application",
)["LoadBalancers"][0]
conn.modify_load_balancer_attributes(
LoadBalancerArn=lb["LoadBalancerArn"],
Attributes=[
{
"Key": "routing.http.drop_invalid_header_fields.enabled",
"Value": "false",
},
],
)
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
with (
mock.patch(
GLOBAL_PROVIDER_PATCH,
return_value=set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
),
),
mock.patch(
ELBV2_CLIENT_PATCH,
new=ELBv2(
set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
create_default_organization=False,
)
),
),
):
check = get_check_class()()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == FAIL_STATUS_EXTENDED
assert result[0].resource_id == "my-lb"
assert result[0].resource_arn == lb["LoadBalancerArn"]
@mock_aws
def test_elbv2_network_load_balancer_ignored(self):
conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1)
ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1)
vpc = ec2.create_vpc(
CidrBlock="172.28.7.0/24",
InstanceTenancy="default",
)
subnet1 = ec2.create_subnet(
VpcId=vpc.id,
CidrBlock="172.28.7.192/26",
AvailabilityZone=AWS_REGION_EU_WEST_1_AZA,
)
conn.create_load_balancer(
Name="my-nlb",
Subnets=[subnet1.id],
Scheme="internal",
Type="network",
)
from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2
with (
mock.patch(
GLOBAL_PROVIDER_PATCH,
return_value=set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
),
),
mock.patch(
ELBV2_CLIENT_PATCH,
new=ELBv2(
set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1],
create_default_organization=False,
)
),
),
):
check = get_check_class()()
result = check.execute()
assert len(result) == 0
View File
File diff suppressed because it is too large Load Diff
@@ -179,3 +179,60 @@ class Test_iam_service_account_unused:
assert result[1].project_id == GCP_PROJECT_ID
assert result[1].location == GCP_US_CENTER1_LOCATION
assert result[1].resource == iam_client.service_accounts[1]
def test_iam_service_account_disabled(self):
iam_client = mock.MagicMock()
monitoring_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.iam_client",
new=iam_client,
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.services.iam.iam_service import ServiceAccount
from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import (
iam_service_account_unused,
)
iam_client.project_ids = [GCP_PROJECT_ID]
iam_client.region = GCP_US_CENTER1_LOCATION
iam_client.service_accounts = [
ServiceAccount(
name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com",
email="disabled-sa@my-project.iam.gserviceaccount.com",
display_name="Disabled service account",
keys=[],
project_id=GCP_PROJECT_ID,
uniqueId="999888877776666",
disabled=True,
)
]
# The account is absent from the usage metrics, so a non-disabled
# account here would FAIL. Being disabled must take precedence and
# PASS, since a disabled account cannot authenticate or be used.
monitoring_client.sa_api_metrics = set()
monitoring_client.audit_config = {"max_unused_account_days": 30}
check = iam_service_account_unused()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used."
)
assert result[0].resource_id == iam_client.service_accounts[0].email
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].resource == iam_client.service_accounts[0]
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.gcp.services.logging.logging_service import Logging
from tests.providers.gcp.gcp_fixtures import (
@@ -66,3 +66,74 @@ class TestLoggingService:
== "resource.type=gae_app AND severity>=ERROR"
)
assert logging_client.metrics[1].project_id == GCP_PROJECT_ID
def test_org_sinks_fetched_when_project_has_organization(self):
"""_get_org_sinks() appends org-level sinks when projects have an org."""
from prowler.providers.gcp.models import GCPOrganization, GCPProject
org_id = "999888777"
provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
provider.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"),
)
}
mock_client = MagicMock()
mock_client.sinks().list().execute.return_value = {
"sinks": [
{
"name": "org-sink",
"destination": "storage.googleapis.com/org-bucket",
"filter": "all",
"includeChildren": True,
}
]
}
mock_client.sinks().list_next.return_value = None
mock_client.projects().metrics().list().execute.return_value = {"metrics": []}
mock_client.projects().metrics().list_next.return_value = None
with (
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
new=mock_is_api_active,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
return_value=mock_client,
),
):
logging_svc = Logging(provider)
org_sinks = [
s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}"
]
assert len(org_sinks) == 1
assert org_sinks[0].name == "org-sink"
assert org_sinks[0].include_children is True
assert org_sinks[0].filter == "all"
def test_org_sinks_skipped_when_no_organization(self):
"""_get_org_sinks() adds nothing when projects have no organization."""
with (
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
new=mock_is_api_active,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
new=mock_api_client,
),
):
logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
org_sinks = [
s for s in logging_svc.sinks if s.project_id.startswith("organizations/")
]
assert org_sinks == []
@@ -1,6 +1,6 @@
from unittest.mock import MagicMock, patch
from prowler.providers.gcp.models import GCPProject
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from tests.providers.gcp.gcp_fixtures import (
GCP_EU1_LOCATION,
GCP_PROJECT_ID,
@@ -268,6 +268,7 @@ class Test_logging_sink_created:
sink.name = None
sink.filter = "all"
sink.project_id = GCP_PROJECT_ID
sink.include_children = False
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
@@ -311,9 +312,10 @@ class Test_logging_sink_created:
)
# Create a MagicMock sink object without name attribute
sink = MagicMock(spec=["filter", "project_id"])
sink = MagicMock(spec=["filter", "project_id", "include_children"])
sink.filter = "all"
sink.project_id = GCP_PROJECT_ID
sink.include_children = False
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
@@ -336,3 +338,175 @@ class Test_logging_sink_created:
assert result[0].resource_id == "unknown"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_org_level_sink_with_include_children_passes(self):
"""Projects covered by an org-level sink with includeChildren=True should PASS."""
logging_client = MagicMock()
org_id = "111222333"
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
new=logging_client,
),
):
from prowler.providers.gcp.services.logging.logging_service import Sink
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
logging_sink_created,
)
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
logging_client.sinks = [
Sink(
name="org-sink",
destination="storage.googleapis.com/org-bucket",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
check = logging_sink_created()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == "org-sink"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_org_level_sink_without_include_children_fails(self):
"""Projects NOT covered by includeChildren should still FAIL if no direct project sink."""
logging_client = MagicMock()
org_id = "111222333"
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
new=logging_client,
),
):
from prowler.providers.gcp.services.logging.logging_service import Sink
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
logging_sink_created,
)
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
logging_client.sinks = [
Sink(
name="org-sink-no-children",
destination="storage.googleapis.com/org-bucket",
filter="all",
project_id=f"organizations/{org_id}",
include_children=False,
)
]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
check = logging_sink_created()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].project_id == GCP_PROJECT_ID
def test_project_sink_takes_precedence_over_org_sink(self):
"""A direct project sink should be reported even when an org-level sink also covers the project."""
logging_client = MagicMock()
org_id = "111222333"
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
new=logging_client,
),
):
from prowler.providers.gcp.services.logging.logging_service import Sink
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
logging_sink_created,
)
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
logging_client.sinks = [
Sink(
name="project-sink",
destination="storage.googleapis.com/project-bucket",
filter="all",
project_id=GCP_PROJECT_ID,
),
Sink(
name="org-sink",
destination="storage.googleapis.com/org-bucket",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
),
]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
check = logging_sink_created()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == "project-sink"
assert result[0].project_id == GCP_PROJECT_ID
+17
View File
@@ -10,6 +10,23 @@ All notable changes to the **Prowler UI** are documented in this file.
---
## [1.29.2] (Prowler v5.29.2)
### 🔄 Changed
- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
### 🐞 Fixed
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
### 🔐 Security
- Vitest toolchain upgraded `4.0.18``4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
---
## [1.29.0] (Prowler v5.29.0)
### 🚀 Added
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
QueryResultAttributes,
} from "@/types/attack-paths";
const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
const API = process.env.NEXT_PUBLIC_API_BASE_URL;
type JsonApiErrorBody = {
errors: Array<{ detail: string; status: string }>;
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
);
},
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -220,4 +220,45 @@ describe("AccountsSelector", () => {
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
});
it("shows the provider icon next to the name in the trigger for a single selection", async () => {
render(
<AccountsSelector
providers={providers}
onBatchChange={vi.fn()}
selectedValues={["provider-1"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
expect(within(trigger).getByText("Production AWS")).toBeInTheDocument();
});
it("renders one icon per selected account without deduping by provider type", async () => {
const secondAws = {
...providers[0],
id: "provider-2",
attributes: {
...providers[0].attributes,
uid: "999999999999",
alias: "Staging AWS",
},
};
render(
<AccountsSelector
providers={[providers[0], secondAws]}
onBatchChange={vi.fn()}
selectedValues={["provider-1", "provider-2"]}
/>,
);
const trigger = screen.getByTestId("trigger");
// Two AWS accounts -> two AWS icons in the trigger (no dedupe).
expect(await within(trigger).findAllByText("AWS")).toHaveLength(2);
expect(
within(trigger).getByText("2 Providers selected"),
).toBeInTheDocument();
});
});
@@ -1,26 +1,12 @@
"use client";
import { useSearchParams } from "next/navigation";
import { ReactNode, useState } from "react";
import { useState } from "react";
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
ProviderTypeIcon,
ProviderTypeIconStack,
} from "@/components/icons/providers-badge/provider-type-icon";
import { Badge } from "@/components/shadcn";
import {
MultiSelect,
@@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = {
type AccountSelectorFilter =
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
azure: <AzureProviderBadge width={18} height={18} />,
gcp: <GCPProviderBadge width={18} height={18} />,
kubernetes: <KS8ProviderBadge width={18} height={18} />,
m365: <M365ProviderBadge width={18} height={18} />,
github: <GitHubProviderBadge width={18} height={18} />,
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
iac: <IacProviderBadge width={18} height={18} />,
image: <ImageProviderBadge width={18} height={18} />,
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
openstack: <OpenStackProviderBadge width={18} height={18} />,
vercel: <VercelProviderBadge width={18} height={18} />,
okta: <OktaProviderBadge width={18} height={18} />,
};
/** Common props shared by both batch and instant modes. */
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
@@ -158,10 +125,36 @@ export function AccountsSelector({
if (selectedIds.length === 1) {
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
return <span className="truncate">{name}</span>;
return (
<span className="flex min-w-0 items-center gap-2">
{p && (
<span aria-hidden="true">
<ProviderTypeIcon type={p.attributes.provider} />
</span>
)}
<span className="truncate">{name}</span>
</span>
);
}
// One icon per selected account (no dedupe): two accounts of the same
// provider show two icons, disambiguated by the UID tooltip on hover.
const items = selectedIds
.map((selectedId) =>
providers.find((pr) => getProviderValue(pr) === selectedId),
)
.filter((p): p is ProviderProps => Boolean(p))
.map((p) => ({
key: p.id,
type: p.attributes.provider as ProviderType,
tooltip: p.attributes.uid,
}));
return (
<span className="truncate">{selectedIds.length} Providers selected</span>
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack items={items} />
<span className="truncate">
{selectedIds.length} Providers selected
</span>
</span>
);
};
@@ -208,7 +201,6 @@ export function AccountsSelector({
const isDisabled = disabledValuesSet.has(value);
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
const searchKeywords = [
displayName,
p.attributes.alias,
@@ -228,7 +220,9 @@ export function AccountsSelector({
if (closeOnSelect) setSelectorOpen(false);
}}
>
<span aria-hidden="true">{icon}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{displayName}</span>
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ProviderTypeSelector } from "./provider-type-selector";
@@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
<div>{children}</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => {
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
it("shows one icon per selected type and a count in the trigger", async () => {
const azure = {
...providers[0],
id: "provider-2",
attributes: { ...providers[0].attributes, provider: "azure" as const },
};
render(
<ProviderTypeSelector
providers={[providers[0], azure]}
onBatchChange={vi.fn()}
selectedValues={["aws", "azure"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
expect(
within(trigger).getByText("2 Provider Types selected"),
).toBeInTheDocument();
});
});
@@ -1,8 +1,12 @@
"use client";
import { useSearchParams } from "next/navigation";
import { type ComponentType, lazy, Suspense } from "react";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
ProviderTypeIconStack,
} from "@/components/icons/providers-badge/provider-type-icon";
import {
MultiSelect,
MultiSelectContent,
@@ -14,163 +18,6 @@ import {
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type ProviderProps, ProviderType } from "@/types/providers";
const AWSProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AWSProviderBadge,
})),
);
const AzureProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AzureProviderBadge,
})),
);
const GCPProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GCPProviderBadge,
})),
);
const KS8ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.KS8ProviderBadge,
})),
);
const M365ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.M365ProviderBadge,
})),
);
const GitHubProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GitHubProviderBadge,
})),
);
const IacProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.IacProviderBadge,
})),
);
const ImageProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.ImageProviderBadge,
})),
);
const OracleCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OracleCloudProviderBadge,
})),
);
const MongoDBAtlasProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.MongoDBAtlasProviderBadge,
})),
);
const AlibabaCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AlibabaCloudProviderBadge,
})),
);
const CloudflareProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.CloudflareProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
})),
);
const GoogleWorkspaceProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GoogleWorkspaceProviderBadge,
})),
);
const VercelProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.VercelProviderBadge,
})),
);
const OktaProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OktaProviderBadge,
})),
);
type IconProps = { width: number; height: number };
const IconPlaceholder = ({ width, height }: IconProps) => (
<div style={{ width, height }} />
);
const PROVIDER_DATA: Record<
ProviderType,
{ label: string; icon: ComponentType<IconProps> }
> = {
aws: {
label: "Amazon Web Services",
icon: AWSProviderBadge,
},
azure: {
label: "Microsoft Azure",
icon: AzureProviderBadge,
},
gcp: {
label: "Google Cloud Platform",
icon: GCPProviderBadge,
},
kubernetes: {
label: "Kubernetes",
icon: KS8ProviderBadge,
},
m365: {
label: "Microsoft 365",
icon: M365ProviderBadge,
},
github: {
label: "GitHub",
icon: GitHubProviderBadge,
},
googleworkspace: {
label: "Google Workspace",
icon: GoogleWorkspaceProviderBadge,
},
iac: {
label: "Infrastructure as Code",
icon: IacProviderBadge,
},
image: {
label: "Container Registry",
icon: ImageProviderBadge,
},
oraclecloud: {
label: "Oracle Cloud Infrastructure",
icon: OracleCloudProviderBadge,
},
mongodbatlas: {
label: "MongoDB Atlas",
icon: MongoDBAtlasProviderBadge,
},
alibabacloud: {
label: "Alibaba Cloud",
icon: AlibabaCloudProviderBadge,
},
cloudflare: {
label: "Cloudflare",
icon: CloudflareProviderBadge,
},
openstack: {
label: "OpenStack",
icon: OpenStackProviderBadge,
},
vercel: {
label: "Vercel",
icon: VercelProviderBadge,
},
okta: {
label: "Okta",
icon: OktaProviderBadge,
},
};
/** Common props shared by both batch and instant modes. */
interface ProviderTypeSelectorBaseProps {
providers: ProviderProps[];
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
.map((p) => p.attributes.provider),
),
)
.filter((type): type is ProviderType => type in PROVIDER_DATA)
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
.sort((a, b) =>
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
);
const renderIcon = (providerType: ProviderType) => {
const IconComponent = PROVIDER_DATA[providerType].icon;
return (
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
<IconComponent width={24} height={24} />
</Suspense>
);
};
const selectedLabel = () => {
if (selectedTypes.length === 0) return null;
if (selectedTypes.length === 1) {
const providerType = selectedTypes[0] as ProviderType;
return (
<span className="flex min-w-0 items-center gap-2">
{renderIcon(providerType)}
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="truncate">
{PROVIDER_TYPE_DATA[providerType].label}
</span>
</span>
);
}
return (
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack
items={(selectedTypes as ProviderType[]).map((type) => ({
key: type,
type,
tooltip: PROVIDER_TYPE_DATA[type].label,
}))}
/>
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
</span>
</span>
);
};
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
<MultiSelectItem
key={providerType}
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
keywords={[providerType, PROVIDER_DATA[providerType].label]}
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
keywords={[
providerType,
PROVIDER_TYPE_DATA[providerType].label,
]}
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} size={24} />
</span>
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
</MultiSelectItem>
))}
</>
+3
View File
@@ -109,6 +109,9 @@ const SSRDataTable = async ({
roles,
canBeExpelled,
currentTenantId: canBeExpelled ? currentTenantId : undefined,
// Users may only delete their own account; gate the delete action so the
// UI matches the backend rule and never offers an action that would fail.
isCurrentUser: user.id === currentUserId,
};
});
@@ -0,0 +1,45 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderType } from "@/types/providers";
import { ProviderIconCell } from "./provider-icon-cell";
// Render the lazy provider badges as plain text so we can assert on them. The
// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose.
vi.mock("@/components/icons/providers-badge", () => ({
AWSProviderBadge: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
describe("ProviderIconCell", () => {
it("renders the shared provider-type icon for a known provider", async () => {
render(<ProviderIconCell provider="aws" />);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
it("renders a '?' placeholder for a provider type missing from the map", () => {
render(
<ProviderIconCell
provider={"future-provider" as unknown as ProviderType}
/>,
);
expect(screen.getByText("?")).toBeInTheDocument();
});
});
@@ -1,43 +1,10 @@
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { cn } from "@/lib/utils";
import { ProviderType } from "@/types";
export const PROVIDER_ICONS = {
aws: AWSProviderBadge,
azure: AzureProviderBadge,
gcp: GCPProviderBadge,
kubernetes: KS8ProviderBadge,
m365: M365ProviderBadge,
github: GitHubProviderBadge,
googleworkspace: GoogleWorkspaceProviderBadge,
iac: IacProviderBadge,
image: ImageProviderBadge,
oraclecloud: OracleCloudProviderBadge,
mongodbatlas: MongoDBAtlasProviderBadge,
alibabacloud: AlibabaCloudProviderBadge,
cloudflare: CloudflareProviderBadge,
openstack: OpenStackProviderBadge,
vercel: VercelProviderBadge,
okta: OktaProviderBadge,
} as const;
interface ProviderIconCellProps {
provider: ProviderType;
size?: number;
@@ -49,9 +16,9 @@ export const ProviderIconCell = ({
size = 26,
className = "size-8 rounded-md bg-white",
}: ProviderIconCellProps) => {
const IconComponent = PROVIDER_ICONS[provider];
if (!IconComponent) {
// Unknown provider types (present in the data but missing from the shared
// PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
if (!(provider in PROVIDER_TYPE_DATA)) {
return (
<div className={cn("flex items-center justify-center", className)}>
<span className="text-text-neutral-secondary text-xs">?</span>
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
className,
)}
>
<IconComponent width={size} height={size} />
<ProviderTypeIcon type={provider} size={size} />
</div>
);
};
@@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderType } from "@/types/providers";
import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon";
// A provider type the API may return but this UI build does not know about.
const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType;
// Render the lazy provider badges as plain text so we can assert on them.
vi.mock("@/components/icons/providers-badge", () => ({
AWSProviderBadge: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
// Render the tooltip pieces inline so the hover content is queryable in jsdom.
vi.mock("@/components/shadcn", () => ({
Badge: ({ children }: { children: React.ReactNode }) => (
<span data-testid="badge">{children}</span>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<span data-testid="tooltip">{children}</span>
),
}));
describe("ProviderTypeIcon", () => {
it("renders the badge for the given provider type", async () => {
render(<ProviderTypeIcon type="aws" />);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
it("renders a sized placeholder instead of crashing for an unknown type", () => {
// Regression guard for #9991: an unknown provider type must not throw.
const { container } = render(
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
);
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
expect(container.querySelector("div")).toHaveStyle({
width: "24px",
height: "24px",
});
});
});
describe("ProviderTypeIconStack", () => {
it("renders one icon per item without deduping by type", async () => {
render(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: "aws", tooltip: "222" },
]}
/>,
);
// Two AWS accounts -> two AWS icons (no dedupe).
expect(await screen.findAllByText("AWS")).toHaveLength(2);
});
it("shows each item's tooltip text on the icon", async () => {
render(
<ProviderTypeIconStack
items={[{ key: "a", type: "aws", tooltip: "account-uid-123" }]}
/>,
);
expect(await screen.findByTestId("tooltip")).toHaveTextContent(
"account-uid-123",
);
});
it("collapses items beyond `max` into a +N badge", async () => {
render(
<ProviderTypeIconStack
max={3}
items={[
{ key: "a", type: "aws", tooltip: "1" },
{ key: "b", type: "azure", tooltip: "2" },
{ key: "c", type: "gcp", tooltip: "3" },
{ key: "d", type: "github", tooltip: "4" },
{ key: "e", type: "okta", tooltip: "5" },
]}
/>,
);
expect(await screen.findByTestId("badge")).toHaveTextContent("+2");
// First icon is shown; items sliced beyond `max` never reach the DOM.
expect(await screen.findByText("AWS")).toBeInTheDocument();
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
});
it("renders known icons and skips unknown types without crashing", async () => {
// Regression guard for #9991: an unknown type in the stack must not throw.
render(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: UNKNOWN_TYPE, tooltip: "222" },
]}
/>,
);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
});
@@ -0,0 +1,250 @@
"use client";
import { type ComponentType, lazy, Suspense } from "react";
import {
Badge,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { cn } from "@/lib/utils";
import type { ProviderType } from "@/types/providers";
type IconProps = { width: number; height: number };
const IconPlaceholder = ({ width, height }: IconProps) => (
<div style={{ width, height }} />
);
// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred
// chunk instead of being eagerly bundled wherever a selector is imported.
const AWSProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AWSProviderBadge,
})),
);
const AzureProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AzureProviderBadge,
})),
);
const GCPProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GCPProviderBadge,
})),
);
const KS8ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.KS8ProviderBadge,
})),
);
const M365ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.M365ProviderBadge,
})),
);
const GitHubProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GitHubProviderBadge,
})),
);
const GoogleWorkspaceProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GoogleWorkspaceProviderBadge,
})),
);
const IacProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.IacProviderBadge,
})),
);
const ImageProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.ImageProviderBadge,
})),
);
const OracleCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OracleCloudProviderBadge,
})),
);
const MongoDBAtlasProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.MongoDBAtlasProviderBadge,
})),
);
const AlibabaCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AlibabaCloudProviderBadge,
})),
);
const CloudflareProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.CloudflareProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
})),
);
const VercelProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.VercelProviderBadge,
})),
);
const OktaProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OktaProviderBadge,
})),
);
/**
* Single source of truth mapping each provider type to its human-readable
* label and (lazy) badge component. Shared by the account and provider-type
* selectors so both stay in sync on labels, icons, and sizing.
*/
export const PROVIDER_TYPE_DATA: Record<
ProviderType,
{ label: string; icon: ComponentType<IconProps> }
> = {
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
azure: { label: "Microsoft Azure", icon: AzureProviderBadge },
gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge },
kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge },
m365: { label: "Microsoft 365", icon: M365ProviderBadge },
github: { label: "GitHub", icon: GitHubProviderBadge },
googleworkspace: {
label: "Google Workspace",
icon: GoogleWorkspaceProviderBadge,
},
iac: { label: "Infrastructure as Code", icon: IacProviderBadge },
image: { label: "Container Registry", icon: ImageProviderBadge },
oraclecloud: {
label: "Oracle Cloud Infrastructure",
icon: OracleCloudProviderBadge,
},
mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge },
alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge },
cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge },
openstack: { label: "OpenStack", icon: OpenStackProviderBadge },
vercel: { label: "Vercel", icon: VercelProviderBadge },
okta: { label: "Okta", icon: OktaProviderBadge },
};
interface ProviderTypeIconProps {
type: ProviderType;
size?: number;
}
/**
* Renders a single provider-type badge with a sized placeholder fallback.
*
* Falls back to the placeholder for provider types missing from
* `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
* build does not). The `type` is statically typed as `ProviderType`, so this
* only guards the runtime case see #9991, which fixed the same crash class.
*/
export const ProviderTypeIcon = ({
type,
size = 18,
}: ProviderTypeIconProps) => {
const data = PROVIDER_TYPE_DATA[type];
if (!data) return <IconPlaceholder width={size} height={size} />;
const Icon = data.icon;
return (
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
<Icon width={size} height={size} />
</Suspense>
);
};
export interface ProviderTypeIconStackItem {
/** Stable React key (account id for accounts, provider type for types). */
key: string;
type: ProviderType;
/** Text shown on hover to disambiguate the icon (e.g. an account UID). */
tooltip?: string;
}
interface ProviderTypeIconStackProps {
items: ProviderTypeIconStackItem[];
max?: number;
size?: number;
className?: string;
}
/**
* Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a
* Radix portal, so the tooltip is not clipped by the selector trigger and we do
* not need to portal it ourselves. `delayDuration` is set on the tooltip itself
* because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider`
* (delay 0), which would otherwise override an ancestor provider's delay.
*/
const IconWithTooltip = ({
item,
size,
}: {
item: ProviderTypeIconStackItem;
size: number;
}) => {
const icon = (
<span className="inline-flex shrink-0">
<ProviderTypeIcon type={item.type} size={size} />
</span>
);
if (!item.tooltip) return icon;
return (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{icon}</TooltipTrigger>
<TooltipContent side="top">{item.tooltip}</TooltipContent>
</Tooltip>
);
};
/**
* Renders up to `max` provider-type icons followed by a `+N` badge for the
* remainder. Each icon shows its `tooltip` on hover. Items are rendered as
* passed (one per selection) callers decide whether to dedupe.
*/
export const ProviderTypeIconStack = ({
items,
max = 3,
size = 18,
className,
}: ProviderTypeIconStackProps) => {
const visible = items.slice(0, max);
const overflow = items.slice(max);
const overflowLabel = overflow
.map((item) => item.tooltip)
.filter(Boolean)
.join(", ");
return (
<span className={cn("flex shrink-0 items-center gap-1", className)}>
<span className="flex items-center gap-1">
{visible.map((item) => (
<IconWithTooltip key={item.key} item={item} size={size} />
))}
</span>
{overflow.length > 0 && (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<Badge variant="tag" className="px-1.5 py-0.5 text-xs font-medium">
+{overflow.length}
</Badge>
</TooltipTrigger>
{overflowLabel && (
<TooltipContent side="top" className="max-w-xs">
{overflowLabel}
</TooltipContent>
)}
</Tooltip>
)}
</span>
);
};
@@ -8,7 +8,10 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -279,11 +282,14 @@ export const S3IntegrationForm = ({
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
const providerOptions = providers.map((provider) => {
const Icon = PROVIDER_ICONS[provider.attributes.provider];
const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
@@ -10,7 +10,10 @@ import { useEffect, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({
? "Connected"
: "Disconnected";
const Icon = PROVIDER_ICONS[provider.attributes.provider];
const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
@@ -0,0 +1,92 @@
"use client";
import Link from "next/link";
import { InfoIcon } from "@/components/icons/Icons";
import { Button, Card, CardContent } from "@/components/shadcn";
import { cn } from "@/lib/utils";
const NO_PROVIDERS_ADDED_ACTION = {
BUTTON: "button",
LINK: "link",
} as const;
interface NoProvidersAddedBaseProps {
containerClassName?: string;
}
interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON;
onOpenWizard: () => void;
href?: never;
}
interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps {
action: typeof NO_PROVIDERS_ADDED_ACTION.LINK;
href: string;
onOpenWizard?: never;
}
type NoProvidersAddedProps =
| NoProvidersAddedButtonProps
| NoProvidersAddedLinkProps;
const renderCta = (props: NoProvidersAddedProps) => {
if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) {
return (
<Button
asChild
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
>
<Link href={props.href}>Get Started</Link>
</Button>
);
}
return (
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={props.onOpenWizard}
>
Get Started
</Button>
);
};
export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
return (
<div
role="region"
aria-labelledby="no-providers-added-title"
className={cn(
"flex min-h-[calc(100dvh-10rem)] items-center justify-center",
props.containerClassName,
)}
>
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2
id="no-providers-added-title"
className="text-2xl font-bold text-gray-800 dark:text-white"
>
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
{renderCta(props)}
</CardContent>
</Card>
</div>
);
};
@@ -1,11 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
refreshMock: vi.fn(),
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
useRouter: () => ({
refresh: refreshMock,
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/table", () => ({
SkeletonTableProviders: () => <div data-testid="providers-skeleton" />,
}));
vi.mock("@/components/providers/add-provider-button", () => ({
AddProviderButton: () => <button type="button">Add provider</button>,
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
<button type="button" onClick={onOpenWizard}>
Add Provider
</button>
),
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
@@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
}));
vi.mock("@/components/providers/providers-filters", () => ({
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
<div data-testid="providers-filters">
Filters
{actions}
</div>
),
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
ProviderWizardModal: ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div role="dialog">
Provider wizard
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
}));
import { ProvidersAccountsView } from "./providers-accounts-view";
@@ -36,8 +80,55 @@ const metadata: MetaDataProps = {
version: "latest",
};
const disconnectedProviders: ProviderProps[] = [
{
id: "provider-1",
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production",
status: "completed",
resources: 0,
connection: {
connected: false,
last_checked_at: "2026-04-13T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-13T00:00:00Z",
updated_at: "2026-04-13T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
},
];
describe("ProvidersAccountsView", () => {
it("keeps the same vertical spacing between filters and table as other views", () => {
afterEach(() => {
vi.restoreAllMocks();
searchParamsValue.current = "";
window.history.replaceState({}, "", "/");
});
it("shows a full page empty state without filters or table when there are no providers", () => {
// Given/When
render(
<ProvidersAccountsView
isCloud={false}
@@ -48,11 +139,170 @@ describe("ProvidersAccountsView", () => {
/>,
);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: /no providers configured/i }),
).toHaveClass("min-h-[calc(100dvh-28rem)]");
expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument();
expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument();
});
it("opens the provider wizard from the no providers CTA", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => {
// Given
searchParamsValue.current = "tab=connected&addProvider=true";
window.history.replaceState(
{},
"",
"/providers?tab=connected&addProvider=true",
);
// Spy only after the URL setup so we measure what the component does on mount.
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
expect(replaceStateSpy).not.toHaveBeenCalled();
});
it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => {
// Given
searchParamsValue.current = "tab=connected&addProvider=true";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /close/i }));
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// The URL is cleaned via the History API (no RSC refetch). We must NOT
// refresh/replace here: re-running the /providers Server Component on close
// read as a full page reload. The provider-creation actions already
// revalidatePath("/providers"), so the table is fresh behind the modal.
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
"/providers?tab=connected",
);
expect(refreshMock).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
it("does not touch the URL or refetch when a manually opened wizard closes", async () => {
// Given: no addProvider param in the URL, wizard opened via the CTA.
searchParamsValue.current = "";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When: open the wizard from the empty-state CTA, then close it.
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
await user.click(screen.getByRole("button", { name: /close/i }));
// Then: nothing to clean and no refresh — the creation actions own the
// data refresh via revalidatePath.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(replaceStateSpy).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
it("keeps filters and table visible when providers are disconnected", () => {
// Given/When
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// Then
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
"flex",
"flex-col",
"gap-6",
);
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
expect(
screen.queryByText("No Providers Configured"),
).not.toBeInTheDocument();
});
it("opens the provider wizard from the normal Add Provider button", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /add provider/i }));
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
});
@@ -1,9 +1,11 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import { AddProviderButton } from "@/components/providers/add-provider-button";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
import { ProvidersFilters } from "@/components/providers/providers-filters";
import { ProviderWizardModal } from "@/components/providers/wizard";
@@ -11,6 +13,10 @@ import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import {
ADD_PROVIDER_SEARCH_PARAM,
ADD_PROVIDER_SEARCH_VALUE,
} from "@/lib/providers-navigation";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
@@ -29,7 +35,14 @@ export function ProvidersAccountsView({
providers,
rows,
}: ProvidersAccountsViewProps) {
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const pathname = usePathname();
const searchParams = useSearchParams();
const hasNoProviders = providers.length === 0;
const shouldOpenProviderWizardFromUrl =
searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE;
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(
() => shouldOpenProviderWizardFromUrl,
);
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
ProviderWizardInitialData | undefined
>(undefined);
@@ -52,38 +65,64 @@ export function ProvidersAccountsView({
const handleWizardOpenChange = (open: boolean) => {
setIsProviderWizardOpen(open);
if (!open) {
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
if (open) return;
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
// Only clean the one-shot ?addProvider intent from the URL bar, via the
// History API so it does NOT trigger an RSC refetch. We must not refresh
// here: the provider-creation actions (addProvider / addCredentialsProvider
// / checkConnectionProvider) already revalidatePath("/providers"), so the
// table updates behind the modal. A router.refresh()/replace() on close
// re-ran the whole /providers Server Component, which read as a full reload.
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(ADD_PROVIDER_SEARCH_PARAM);
const query = params.toString();
window.history.replaceState(
null,
"",
query ? `${pathname}?${query}` : pathname,
);
}
};
return (
<>
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
{hasNoProviders ? (
<NoProvidersAdded
action="button"
containerClassName="min-h-[calc(100dvh-28rem)]"
onOpenWizard={() => openProviderWizard()}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
) : (
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
refreshOnClose={false}
/>
</>
);
@@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
// When false, the caller skips the post-close router.refresh() and relies on
// the provider-creation actions' revalidatePath("/providers") to refresh the
// data. Defaults to true so standalone callers keep refreshing.
refreshOnClose?: boolean;
}
export function useProviderWizardController({
@@ -57,6 +61,7 @@ export function useProviderWizardController({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose = true,
}: UseProviderWizardControllerProps) {
const router = useRouter();
const initialProviderId = initialData?.providerId ?? null;
@@ -185,7 +190,9 @@ export function useProviderWizardController({
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
router.refresh();
if (refreshOnClose) {
router.refresh();
}
};
const handleDialogOpenChange = (nextOpen: boolean) => {
@@ -38,6 +38,7 @@ interface ProviderWizardModalProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
refreshOnClose?: boolean;
}
export function ProviderWizardModal({
@@ -45,6 +46,7 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose,
}: ProviderWizardModalProps) {
const {
backToProviderFlow,
@@ -72,6 +74,7 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose,
});
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
@@ -1,38 +0,0 @@
"use client";
import { Button, Card, CardContent } from "@/components/shadcn";
import { InfoIcon } from "../icons/Icons";
interface NoProvidersAddedProps {
onOpenWizard: () => void;
}
export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={onOpenWizard}
>
Get Started
</Button>
</CardContent>
</Card>
</div>
);
@@ -1,73 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
}));
vi.mock("./no-providers-connected", () => ({
NoProvidersConnected: () => <div>No Connected Providers</div>,
}));
describe("ScansProvidersEmptyState", () => {
afterEach(() => {
vi.clearAllMocks();
searchParamsValue.current = "";
});
it("shows the add provider message and opens the provider wizard", async () => {
const user = userEvent.setup();
it("shows the add provider message with a providers page CTA", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders />);
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("clears the launch scan URL intent before opening the provider wizard", async () => {
// Given
searchParamsValue.current = "tab=completed&launchScan=true";
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
scroll: false,
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
const cta = screen.getByRole("link", {
name: /open add provider modal/i,
});
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF);
expect(cta.tagName).toBe("A");
});
it("does not render the provider wizard in Scans", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders />);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows the no connected providers message", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
// Then
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
@@ -1,12 +1,6 @@
"use client";
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
interface ScansProvidersEmptyStateProps {
@@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps {
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const openProviderWizard = () => {
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
}
setIsProviderWizardOpen(true);
};
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded onOpenWizard={openProviderWizard} />
<NoProvidersAdded action="link" href={ADD_PROVIDER_HREF} />
) : (
<NoProvidersConnected />
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={setIsProviderWizardOpen}
/>
</>
);
}
@@ -0,0 +1,159 @@
import { Row } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
// The forms pull in server actions (`@/actions/users/users`) that can't run in
// jsdom, so stub them with identifiable markers to assert which modal opens.
vi.mock("../forms", () => ({
DeleteForm: ({ userId }: { userId: string }) => (
<div data-testid="delete-form">delete-form:{userId}</div>
),
EditForm: ({ userId }: { userId: string }) => (
<div data-testid="edit-form">edit-form:{userId}</div>
),
ExpelUserForm: ({ userId }: { userId: string }) => (
<div data-testid="expel-form">expel-form:{userId}</div>
),
}));
import { DataTableRowActions } from "./data-table-row-actions";
interface RowOptions {
id?: string;
isCurrentUser?: boolean;
canBeExpelled?: boolean;
currentTenantId?: string;
}
const createRow = ({
id = "user-1",
isCurrentUser,
canBeExpelled,
currentTenantId,
}: RowOptions = {}) =>
({
original: {
id,
attributes: {
name: "Jane Doe",
email: "jane@example.com",
company_name: "Acme",
role: { name: "admin" },
},
isCurrentUser,
canBeExpelled,
currentTenantId,
},
}) as unknown as Row<{ id: string }>;
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "Open actions menu" }));
};
describe("DataTableRowActions (users)", () => {
it("always renders the Edit User action", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow()} />);
await openMenu(user);
expect(screen.getByText("Edit User")).toBeInTheDocument();
});
it("shows Delete User only for the current user's row", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
await openMenu(user);
expect(screen.getByText("Delete User")).toBeInTheDocument();
expect(screen.getByText("Danger zone")).toBeInTheDocument();
});
it("does NOT show Delete User for another user's row", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: false })} />);
await openMenu(user);
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("does NOT show Delete User when isCurrentUser is undefined", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({})} />);
await openMenu(user);
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({ isCurrentUser: false, canBeExpelled: false })}
/>,
);
await openMenu(user);
// Only the non-destructive Edit action remains.
expect(screen.getByText("Edit User")).toBeInTheDocument();
expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
expect(
screen.queryByText("Expel from organization"),
).not.toBeInTheDocument();
});
it("shows Expel but not Delete User for an expellable, non-current user", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({
isCurrentUser: false,
canBeExpelled: true,
currentTenantId: "tenant-1",
})}
/>,
);
await openMenu(user);
expect(screen.getByText("Danger zone")).toBeInTheDocument();
expect(screen.getByText("Expel from organization")).toBeInTheDocument();
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("renders Delete User with destructive styling", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
await openMenu(user);
const menuItem = screen
.getByText("Delete User")
.closest("[role='menuitem']");
expect(menuItem).toBeInTheDocument();
expect(menuItem).toHaveClass("text-text-error-primary");
});
it("opens the delete confirmation modal when Delete User is selected", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({ id: "user-42", isCurrentUser: true })}
/>,
);
await openMenu(user);
await user.click(screen.getByText("Delete User"));
expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument();
expect(screen.getByTestId("delete-form")).toHaveTextContent(
"delete-form:user-42",
);
});
});
@@ -29,6 +29,7 @@ interface UserRowData {
attributes?: UserRowAttributes;
canBeExpelled?: boolean;
currentTenantId?: string;
isCurrentUser?: boolean;
}
interface DataTableRowActionsProps<UserProps extends UserRowData> {
@@ -57,6 +58,10 @@ export function DataTableRowActions<UserProps extends UserRowData>({
row.original.canBeExpelled === true && !!row.original.currentTenantId;
const currentTenantId = row.original.currentTenantId;
// A user can only delete their own account (enforced by the backend), so the
// delete action is shown exclusively for the current user's row.
const canDeleteUser = row.original.isCurrentUser === true;
return (
<>
<Modal
@@ -74,14 +79,16 @@ export function DataTableRowActions<UserProps extends UserRowData>({
setIsOpen={setIsEditOpen}
/>
</Modal>
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
>
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
</Modal>
{canDeleteUser && (
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
>
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
</Modal>
)}
{canExpelUser && currentTenantId && (
<Modal
open={isExpelOpen}
@@ -104,22 +111,26 @@ export function DataTableRowActions<UserProps extends UserRowData>({
label="Edit User"
onSelect={() => setIsEditOpen(true)}
/>
<ActionDropdownDangerZone>
{canExpelUser && (
<ActionDropdownItem
icon={<UserMinus aria-hidden="true" />}
label="Expel from organization"
destructive
onSelect={() => setIsExpelOpen(true)}
/>
)}
<ActionDropdownItem
icon={<Trash2 aria-hidden="true" />}
label="Delete User"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
</ActionDropdownDangerZone>
{(canExpelUser || canDeleteUser) && (
<ActionDropdownDangerZone>
{canExpelUser && (
<ActionDropdownItem
icon={<UserMinus aria-hidden="true" />}
label="Expel from organization"
destructive
onSelect={() => setIsExpelOpen(true)}
/>
)}
{canDeleteUser && (
<ActionDropdownItem
icon={<Trash2 aria-hidden="true" />}
label="Delete User"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
)}
</ActionDropdownDangerZone>
)}
</ActionDropdown>
</div>
</>
+12 -12
View File
@@ -778,26 +778,26 @@
{
"section": "devDependencies",
"name": "@vitest/browser",
"from": "4.1.6",
"to": "4.0.18",
"from": "4.0.18",
"to": "4.1.8",
"strategy": "installed",
"generatedAt": "2026-05-14T10:22:47.378Z"
"generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser-playwright",
"from": "4.1.6",
"to": "4.0.18",
"from": "4.0.18",
"to": "4.1.8",
"strategy": "installed",
"generatedAt": "2026-05-14T10:22:47.378Z"
"generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
"name": "@vitest/coverage-v8",
"from": "4.1.6",
"to": "4.0.18",
"from": "4.0.18",
"to": "4.1.8",
"strategy": "installed",
"generatedAt": "2026-05-14T10:22:47.378Z"
"generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
@@ -978,10 +978,10 @@
{
"section": "devDependencies",
"name": "vitest",
"from": "4.1.6",
"to": "4.0.18",
"from": "4.0.18",
"to": "4.1.8",
"strategy": "installed",
"generatedAt": "2026-05-14T10:22:47.378Z"
"generatedAt": "2026-06-02T11:34:46.264Z"
},
{
"section": "devDependencies",
+3
View File
@@ -0,0 +1,3 @@
export const ADD_PROVIDER_SEARCH_PARAM = "addProvider";
export const ADD_PROVIDER_SEARCH_VALUE = "true";
export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`;
+4 -4
View File
@@ -133,9 +133,9 @@
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"@vitest/browser": "4.0.18",
"@vitest/browser-playwright": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"@vitest/browser": "4.1.8",
"@vitest/browser-playwright": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "16.6.1",
"dotenv-expand": "12.0.3",
@@ -158,7 +158,7 @@
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.1.18",
"typescript": "5.5.4",
"vitest": "4.0.18",
"vitest": "4.1.8",
"vitest-browser-react": "2.0.4"
},
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
+103 -110
View File
@@ -325,14 +325,14 @@ importers:
specifier: 5.1.2
version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/browser':
specifier: 4.0.18
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
specifier: 4.1.8
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser-playwright':
specifier: 4.0.18
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
specifier: 4.1.8
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)
specifier: 4.1.8
version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
@@ -400,11 +400,11 @@ importers:
specifier: 5.5.4
version: 5.5.4
vitest:
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
specifier: 4.1.8
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
vitest-browser-react:
specifier: 2.0.4
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18)
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8)
packages:
@@ -737,6 +737,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
@@ -4795,54 +4798,54 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
'@vitest/browser-playwright@4.1.8':
resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
peerDependencies:
playwright: '*'
vitest: 4.0.18
vitest: 4.1.8
'@vitest/browser@4.0.18':
resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
'@vitest/browser@4.1.8':
resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
peerDependencies:
vitest: 4.0.18
vitest: 4.1.8
'@vitest/coverage-v8@4.0.18':
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
'@vitest/coverage-v8@4.1.8':
resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
peerDependencies:
'@vitest/browser': 4.0.18
vitest: 4.0.18
'@vitest/browser': 4.1.8
vitest: 4.1.8
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.0.18':
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
'@vitest/expect@4.1.8':
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
'@vitest/mocker@4.0.18':
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
'@vitest/mocker@4.1.8':
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0-0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.0.18':
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
'@vitest/pretty-format@4.1.8':
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
'@vitest/runner@4.0.18':
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
'@vitest/runner@4.1.8':
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
'@vitest/snapshot@4.0.18':
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
'@vitest/snapshot@4.1.8':
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
'@vitest/spy@4.0.18':
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
'@vitest/spy@4.1.8':
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
'@vitest/utils@4.1.8':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -5048,8 +5051,8 @@ packages:
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
ast-v8-to-istanbul@0.3.12:
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
ast-v8-to-istanbul@1.0.3:
resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
@@ -5650,9 +5653,6 @@ packages:
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@@ -7246,10 +7246,6 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pixelmatch@7.1.0:
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
hasBin: true
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -7537,6 +7533,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -7829,8 +7826,8 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
@@ -8347,20 +8344,23 @@ packages:
'@types/react-dom':
optional: true
vitest@4.0.18:
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
vitest@4.1.8:
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.0.18
'@vitest/browser-preview': 4.0.18
'@vitest/browser-webdriverio': 4.0.18
'@vitest/ui': 4.0.18
'@vitest/browser-playwright': 4.1.8
'@vitest/browser-preview': 4.1.8
'@vitest/browser-webdriverio': 4.1.8
'@vitest/coverage-istanbul': 4.1.8
'@vitest/coverage-v8': 4.1.8
'@vitest/ui': 4.1.8
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
@@ -8374,6 +8374,10 @@ packages:
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
@@ -9319,6 +9323,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blazediff/core@1.9.1': {}
'@braintree/sanitize-url@7.1.1': {}
'@cfworker/json-schema@4.1.1': {}
@@ -14261,29 +14267,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
'@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
dependencies:
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
playwright: 1.56.1
tinyrainbow: 3.1.0
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
'@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
dependencies:
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/utils': 4.0.18
'@blazediff/core': 1.9.1
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/utils': 4.1.8
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
ws: 8.20.1
transitivePeerDependencies:
- bufferutil
@@ -14291,60 +14297,62 @@ snapshots:
- utf-8-validate
- vite
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)':
'@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
ast-v8-to-istanbul: 0.3.12
'@vitest/utils': 4.1.8
ast-v8-to-istanbul: 1.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 3.10.0
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
optionalDependencies:
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/expect@4.0.18':
'@vitest/expect@4.1.8':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.0.18
'@vitest/utils': 4.0.18
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
'@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.0.18
'@vitest/spy': 4.1.8
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)
'@vitest/pretty-format@4.0.18':
'@vitest/pretty-format@4.1.8':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.0.18':
'@vitest/runner@4.1.8':
dependencies:
'@vitest/utils': 4.0.18
'@vitest/utils': 4.1.8
pathe: 2.0.3
'@vitest/snapshot@4.0.18':
'@vitest/snapshot@4.1.8':
dependencies:
'@vitest/pretty-format': 4.0.18
'@vitest/pretty-format': 4.1.8
'@vitest/utils': 4.1.8
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.0.18': {}
'@vitest/spy@4.1.8': {}
'@vitest/utils@4.0.18':
'@vitest/utils@4.1.8':
dependencies:
'@vitest/pretty-format': 4.0.18
'@vitest/pretty-format': 4.1.8
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@webassemblyjs/ast@1.14.1':
@@ -14604,7 +14612,7 @@ snapshots:
ast-types-flow@0.0.8: {}
ast-v8-to-istanbul@0.3.12:
ast-v8-to-istanbul@1.0.3:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
@@ -15273,8 +15281,6 @@ snapshots:
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
es-module-lexer@1.7.0: {}
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
@@ -17280,10 +17286,6 @@ snapshots:
picomatch@4.0.4: {}
pixelmatch@7.1.0:
dependencies:
pngjs: 7.0.0
pkce-challenge@5.0.1: {}
pkg-types@1.3.1:
@@ -17980,7 +17982,7 @@ snapshots:
statuses@2.0.2: {}
std-env@3.10.0: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
@@ -18487,31 +18489,31 @@ snapshots:
terser: 5.47.1
yaml: 2.9.0
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18):
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0):
vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
'@vitest/spy': 4.0.18
'@vitest/utils': 4.0.18
es-module-lexer: 1.7.0
'@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.8
'@vitest/runner': 4.1.8
'@vitest/snapshot': 4.1.8
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
@@ -18521,20 +18523,11 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.10.8
'@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
'@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
jsdom: 27.4.0
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
w3c-keyname@2.2.8: {}
+27 -4
View File
@@ -132,6 +132,24 @@ export async function addAWSProvider(
await scansPage.verifyPageLoaded();
}
/**
* Waits for the providers page to settle and reports whether the data table is
* present. With zero providers the page renders a full-page empty state
* ("No Providers Configured") instead of the table, so callers must not assume
* the table is always there.
*/
async function providersTableVisibleOrEmptyState(
page: ProvidersPage,
): Promise<boolean> {
const emptyState = page.page.getByRole("region", {
name: /no providers configured/i,
});
await expect(page.providersTable.or(emptyState)).toBeVisible({
timeout: 10000,
});
return page.providersTable.isVisible().catch(() => false);
}
export async function deleteProviderIfExists(
page: ProvidersPage,
providerUID: string,
@@ -140,7 +158,11 @@ export async function deleteProviderIfExists(
// Navigate to providers page
await page.goto();
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
// With zero providers the page shows the empty state, not the table, so there
// is nothing to delete.
if (!(await providersTableVisibleOrEmptyState(page))) {
return;
}
const allRows = page.providersTable.locator("tbody tr");
@@ -180,7 +202,7 @@ export async function deleteProviderIfExists(
// Provider not found, nothing to delete
// Navigate back to providers page to ensure clean state
await page.goto();
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
await providersTableVisibleOrEmptyState(page);
return;
}
@@ -217,7 +239,8 @@ export async function deleteProviderIfExists(
// Wait for modal to close (this indicates deletion was initiated)
await expect(modal).not.toBeVisible({ timeout: 10000 });
// Navigate back to providers page to ensure clean state
// Navigate back to providers page to ensure clean state. Deleting the last
// provider reveals the empty state instead of an empty table.
await page.goto();
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
await providersTableVisibleOrEmptyState(page);
}
+7 -2
View File
@@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage {
name: /Adding A Provider|Update Provider Credentials/i,
});
// Button to add a new provider
// Button to add a new provider. When providers exist this is the filter-bar
// "Add Provider" control; with zero providers the page renders the empty
// state whose CTA is labelled "Open Add Provider modal" (button on
// /providers, link on /scans). Only one of these is ever in the DOM at once.
this.addProviderButton = page
.getByRole("button", {
name: "Add Provider",
@@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage {
name: "Add Provider",
exact: true,
}),
);
)
.or(page.getByRole("button", { name: "Open Add Provider modal" }))
.or(page.getByRole("link", { name: "Open Add Provider modal" }));
// Table displaying existing providers
this.providersTable = page.getByRole("table");
+125
View File
@@ -0,0 +1,125 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
// Runtime (Node / Next.js)
NODE_ENV: "development" | "production" | "test";
NEXT_RUNTIME?: "nodejs" | "edge";
// Public client config
NEXT_PUBLIC_API_BASE_URL: string;
NEXT_PUBLIC_API_DOCS_URL?: string;
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string;
NEXT_PUBLIC_SENTRY_DSN?: string;
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
// Auth (NextAuth)
AUTH_URL: string;
AUTH_SECRET: string;
AUTH_TRUST_HOST?: "true" | "false";
NEXTAUTH_URL?: string;
// Sentry (server / build)
SENTRY_DSN?: string;
SENTRY_ENVIRONMENT?: string;
SENTRY_RELEASE?: string;
SENTRY_ORG?: string;
SENTRY_PROJECT?: string;
SENTRY_AUTH_TOKEN?: string;
// Social OAuth
SOCIAL_GOOGLE_OAUTH_CLIENT_ID?: string;
SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET?: string;
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL?: string;
SOCIAL_GITHUB_OAUTH_CLIENT_ID?: string;
SOCIAL_GITHUB_OAUTH_CLIENT_SECRET?: string;
SOCIAL_GITHUB_OAUTH_CALLBACK_URL?: string;
// Feature integrations
PROWLER_MCP_SERVER_URL?: string;
// JSON-encoded array, parsed in actions/feeds
RSS_FEED_SOURCES?: string;
// Environment detection
CI?: string;
DOCKER?: string;
KUBERNETES_SERVICE_HOST?: string;
// E2E test credentials (Playwright only)
E2E_ADMIN_USER?: string;
E2E_ADMIN_PASSWORD?: string;
E2E_NEW_USER_PASSWORD?: string;
E2E_MANAGE_CLOUD_PROVIDERS_USER?: string;
E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD?: string;
E2E_INVITE_AND_MANAGE_USERS_USER?: string;
E2E_INVITE_AND_MANAGE_USERS_PASSWORD?: string;
E2E_UNLIMITED_VISIBILITY_USER?: string;
E2E_UNLIMITED_VISIBILITY_PASSWORD?: string;
E2E_MANAGE_INTEGRATIONS_USER?: string;
E2E_MANAGE_INTEGRATIONS_PASSWORD?: string;
E2E_MANAGE_ACCOUNT_USER?: string;
E2E_MANAGE_ACCOUNT_PASSWORD?: string;
E2E_MANAGE_SCANS_USER?: string;
E2E_MANAGE_SCANS_PASSWORD?: string;
E2E_ORGANIZATION_ID?: string;
// E2E AWS
E2E_AWS_PROVIDER_ACCOUNT_ID?: string;
E2E_AWS_PROVIDER_ACCESS_KEY?: string;
E2E_AWS_PROVIDER_SECRET_KEY?: string;
E2E_AWS_PROVIDER_ROLE_ARN?: string;
E2E_AWS_ORGANIZATION_ID?: string;
E2E_AWS_ORGANIZATION_ROLE_ARN?: string;
// E2E Azure
E2E_AZURE_SUBSCRIPTION_ID?: string;
E2E_AZURE_CLIENT_ID?: string;
E2E_AZURE_SECRET_ID?: string;
E2E_AZURE_TENANT_ID?: string;
// E2E Microsoft 365
E2E_M365_DOMAIN_ID?: string;
E2E_M365_CLIENT_ID?: string;
E2E_M365_TENANT_ID?: string;
E2E_M365_SECRET_ID?: string;
E2E_M365_CERTIFICATE_CONTENT?: string;
// E2E GCP
E2E_GCP_PROJECT_ID?: string;
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY?: string;
// E2E Kubernetes
E2E_KUBERNETES_CONTEXT?: string;
E2E_KUBERNETES_KUBECONFIG_PATH?: string;
// E2E GitHub
E2E_GITHUB_USERNAME?: string;
E2E_GITHUB_PERSONAL_ACCESS_TOKEN?: string;
E2E_GITHUB_APP_ID?: string;
E2E_GITHUB_BASE64_APP_PRIVATE_KEY?: string;
E2E_GITHUB_ORGANIZATION?: string;
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN?: string;
// E2E Oracle Cloud
E2E_OCI_TENANCY_ID?: string;
E2E_OCI_USER_ID?: string;
E2E_OCI_FINGERPRINT?: string;
E2E_OCI_KEY_CONTENT?: string;
E2E_OCI_REGION?: string;
// E2E Alibaba Cloud
E2E_ALIBABACLOUD_ACCOUNT_ID?: string;
E2E_ALIBABACLOUD_ACCESS_KEY_ID?: string;
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET?: string;
E2E_ALIBABACLOUD_ROLE_ARN?: string;
// E2E Google Workspace
E2E_GOOGLEWORKSPACE_CUSTOMER_ID?: string;
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON?: string;
E2E_GOOGLEWORKSPACE_DELEGATED_USER?: string;
}
}
}
export {};