Compare commits

...
Author SHA1 Message Date
Pablo F.G 32222627dd fix(ui): feed the destinations column from the reworked alerts fixtures
- Seed a rule's channels by id, as the mapping table now holds them
- Anchor the list reader on the Destinations column it really renders
2026-08-20 16:53:47 +02:00
Pablo F.G 6b166576ba feat(ui): show a rule's destinations in the alerts list
- Retitle the Recipients column to Destinations per design D6
- Summarize emails and Slack channels side by side, at a glance
- Cover list-altitude destination summaries in the integration tests
2026-08-20 16:53:47 +02:00
Pablo F.G 0f643be330 feat(ui): align alert Slack destinations with the signed API contract
- Offer the channels the eligible-channels endpoint returns
- Write slack_channels as objects carrying only the channel id
- Drop the destination states the server-side cascade made impossible
- Point the empty-pool notice at the connection check
2026-08-20 16:53:30 +02:00
Pablo F.G 8600a334ba feat(ui): let alert rules target authorized Slack channels
- Add the channels field to the alert modal below Recipients
- Keep stored channels visible when de-authorized or disconnected
- Cover the alerts page spec with browser-mode integration tests
- Repair the unit tests the form rework touched
2026-08-20 16:53:30 +02:00
Pablo F.G f2a97b8549 feat(ui): scaffold Slack channel destinations for alerts
- Remove the alert form's method literal; destinations are the truth
- Add slackChannels to form values, schema, adapter and rule payload
- Add MSW alerts handlers and fixtures with the D3 channel contract
- Add the alerts page browser harness with channel vocabulary
2026-08-20 16:53:30 +02:00
Pablo F.G a1003491f1 fix(ui): speak of authorized channels on the Slack entry points
- Rework the page and card copy to the authorized-set vocabulary
- Send the install callback back to authorize channels, not pick one
- Drop the stale single-channel wording from the channels endpoint
2026-08-20 16:49:42 +02:00
Pablo F.G 7feaf213e6 feat(ui): align Slack channel authorization with the signed API contract
- Write the authorized set as channel objects naming only their ids
- Read the stored confirmations, workspace ids and verification state
- Replace the test-message copy with the check's one-time confirmation
- Warn that dropping a channel drops it from the alert rules too
2026-08-20 16:01:19 +02:00
Pablo F.G 82e286c37d feat(ui): authorize multiple Slack destination channels
- Add shared SlackChannelMultiSelect with private chips identified
- Rework the Slack manager to the authorized-set model
- Test message and connection check cover every authorized channel
- Retire the single-channel selector and its vocabulary
2026-08-20 14:02:51 +02:00
Pablo F.G 4a832cdd0d docs: drop the Slack test message from the tutorial
- The UI verifies the destination through the connection check the save
  runs, so the test-message section, its screenshot and its reference
  row no longer describe anything the page offers
2026-08-20 11:38:42 +02:00
Pablo F.G 221886d637 docs: move the Slack tutorial availability to 5.40.0
- The Slack integration ships in 5.40.0; the released 5.39.x line does
  not contain it
- Recapture all six screenshots so their footers name the same version
2026-08-20 11:38:42 +02:00
Pablo F.G 28a60d8e14 docs: scope the Slack channel listing to what Prowler reads
- Stop saying the picker lists every public channel: the read is
  bounded, so a very large workspace can get a partial list
- Explain the partial-list report: listed channels stay usable, and a
  missing one is not necessarily missing an invite
2026-08-20 11:38:42 +02:00
Pablo F.G 6f1be74f71 docs: stop promising unconditional Slack revocation on disconnect
- Say disconnecting attempts to revoke access, everywhere the guide
  claimed it simply does
- Document the unreported outcome next to success and failure: it makes
  no revocation claim, and certainty means checking Slack app settings
2026-08-20 11:38:42 +02:00
Pablo F.G 110a1693cc docs: re-stage the connected-state Slack screenshots on the contract
- Capture the post-install card as "Not checked yet" with no check time,
  the state the contract defines right after the OAuth return
- Show the next-step sentence and the disabled Test connection the API
  refuses while no destination channel is recorded
- Describe that state in the prose, replacing the Connected status and
  last-checked time the shots never could have shown
2026-08-20 11:38:42 +02:00
Pablo F.G 196df313f0 docs: name both cloud products in the Slack tutorial availability
- The Prerequisites line said Prowler Cloud only, contradicting the
  SubscriptionBanner above it
- Prowler Private Cloud runs the cloud codebase, so the isCloud() gate is
  true and the Slack endpoints are served there too
2026-08-20 11:38:42 +02:00
Pablo F.G b3e0099ed3 docs: name Prowler Local Server in the Slack tutorial prerequisites 2026-08-20 11:38:42 +02:00
Pablo F.G 0ffa2f538b docs: mark the Slack tutorial as subscription-gated
- Swap the single-product AppliesTo for the SubscriptionBanner
- Add the sidebar Cloud marker for the Slack tutorial page
2026-08-20 11:38:42 +02:00
Pablo F.G a041130693 chore(ui): drop the docs-link changelog entry
The Slack integration ships one changelog entry on the stack's first PR
2026-08-20 11:38:42 +02:00
Pablo F.G 317f270f48 docs: add Slack integration guide for Prowler Cloud
- Cover the install flow via Add to Slack, choosing the default channel,
  sending a test message, and what disconnecting revokes
- State the four bot scopes Prowler requests, that `chat:write.public`
  reads broader than it behaves, and that a private channel appears only
  once `@Prowler` is invited to it in Slack
- Point the Slack card's Learn more link at the new page
- Distinguish the integration from the Prowler CLI's `--slack` output
2026-08-20 11:38:42 +02:00
Pablo F.G ea7cdcd00f test(ui): wait for the Slack reconnect affordance instead of sampling it
- The link needs a consent URL the page mints on demand, so it lands a
  beat after the notice that explains it; a synchronous read raced it
  and failed in CI while passing locally
2026-08-20 11:38:00 +02:00
Pablo F.G dcf32f0300 docs(ui): trim the Slack revoked-credential test comment
- Compress the four-line setup rationale to the claim it carries
2026-08-20 11:26:34 +02:00
Pablo F.G c6c2915eaa test(ui): reach the revoked credential through the connection check
- The test message is gone, so the recovery test now gets into the
  revoked state the way the page still can, and lets Slack accept the
  credential again before the save that clears the notice
2026-08-20 11:25:25 +02:00
Pablo F.G e992be8190 test(ui): follow the sorted Slack picker in the partial-read case
- The picker sorts what it offers, so the assertion for a refused later
  cursor page still expected the API's page order and failed
2026-08-20 11:25:25 +02:00
Pablo F.G 03e53c26dc docs(ui): trim the Slack disconnect test and fixture comments
- Drop assertion narration and keep contract facts stated once
2026-08-20 11:25:25 +02:00
Pablo F.G 9529e48fa7 docs(ui): trim the Slack disconnect component comments
- Cut each rationale to its load-bearing clause and drop restatements
2026-08-20 11:25:25 +02:00
Pablo F.G d51d754fe4 docs(ui): trim the Slack disconnect action and unit-test comments
- Keep only the non-obvious why, stated once at its load-bearing site
2026-08-20 11:25:25 +02:00
Pablo F.G 1cab262607 fix(ui): keep the refusal code when only part of the channel list loads
- Carry the code of the refusal that truncated the read, so a grant
  refused on a later page still offers the reconnect path
2026-08-20 11:25:25 +02:00
Pablo F.G b5e0c51183 fix(ui): keep naming the workspace in the failed-revocation notice
- Snapshot the workspace name when disconnecting, so the notice keeps
  naming it after the refresh removes the integration record
2026-08-20 11:25:25 +02:00
Pablo F.G 4bc7d63c9d fix(ui): move the revoked-credential notice only on Slack's answer
- Clear the notice when a save Slack validated goes through
- Keep it up when a later check fails without Slack naming a reason
2026-08-20 11:25:25 +02:00
Pablo F.G 7d2e0023f3 fix(ui): validate the integration id in the Slack disconnect action
- Refuse a malformed id before the request is built, closing the open
  request-forgery alert
- Put the disconnect action under the shared malformed-id contract
2026-08-20 11:25:24 +02:00
Pablo F.G bf1e75a3a7 test(ui): cover the unreported Slack revocation outcome
- Let the revocation fixture answer with no body at all
- Anchor the disconnect harness on each outcome's own wording instead
  of the shared toast title
- Assert the neutral copy when nothing reports the revocation
2026-08-20 11:25:24 +02:00
Pablo F.G 198ce8d640 fix(ui): keep a Slack upstream fault inside the action's answer
- Await the refusal mapping so a server fault becomes the action's own
  error instead of escaping the catch
- Expect the refusal code the actions now carry in their unit tests
2026-08-20 11:25:24 +02:00
Pablo F.G 154df41456 fix(ui): keep a never-checked integration off the disconnected badge
- Restore the neutral badge for Jira, S3 and Security Hub when the
  connection has not been checked yet
2026-08-20 11:25:24 +02:00
Pablo F.G 0c9e650407 chore(ui): drop the disconnect and reconnect changelog entries
The Slack integration ships one changelog entry on the stack's first PR
2026-08-20 11:25:24 +02:00
Pablo F.G 845551c091 fix(ui): say when Slack has stopped accepting Prowler's access
- Recognise a dead credential through the shared Slack error vocabulary,
  so the channel listing, the channel save, the test message and the
  connection check all offer a reconnect rather than a retry
- Word the notice from the refusal's code instead of showing Slack's
  raw reason, which is a protocol token and not user-facing copy
- Report a failed revocation as the API states it: the integration is
  gone from Prowler, and access may still need removing by hand
2026-08-20 11:25:24 +02:00
Pablo F.G 7d24a5303a feat(ui): add Slack disconnect and revoked-credential recovery
- Disconnect a Slack workspace behind a confirmation, returning the
  page to its unconnected state
- Report the revocation outcome from the DELETE response meta, telling
  the user when access still has to be removed in Slack by hand
- Offer to connect the workspace again when Slack stops accepting the
  stored credential
- Widen the integration `connected` attribute to allow null, which the
  OAuth exchange returns until the first connection check runs
2026-08-20 11:25:24 +02:00
Pablo F.G cc0a1c89cf refactor(ui): drop the Slack copy left behind by the test message
- Remove slackUnknownReasonMessage: its only caller was the test-message
  action, and its wording named a message nothing posts any more
- Keep the fallback assertion it carried, on slackErrorMessage itself
- Correct the unit-test header, which named flows the page now has
2026-08-20 11:22:40 +02:00
Pablo F.G 8e78fa94ad docs(ui): trim the Slack channel-check comments
- Compress the save-runs-the-check rationale to one-liners
- Drop the docstring restating the constant and the bug-report aside
2026-08-20 11:20:18 +02:00
Pablo F.G d18f2b7fdb feat(ui): check the Slack connection when the channel is saved
- Drop the Send test message affordance and everything behind it: the
  action, its MSW handler and fixtures, and the harness helpers
- Run the connection check on a successful channel save, so a channel
  Prowler cannot reach is reported then rather than on a second click
- Keep the destination on record when that check fails: the save
  succeeded, only the check did not
2026-08-20 11:13:30 +02:00
Pablo F.G 2451074021 fix(ui): say what unblocks the Slack connection check
- Move the reason under the disabled Test connection button and tie it
  to the control with aria-describedby: the explanation sat across the
  row from it, so a check waiting on a channel read as a broken one
- Assert the reason through the control it blocks, not through copy
  found anywhere on the page
2026-08-20 11:13:30 +02:00
Pablo F.G c1a6461430 feat(ui): make the Slack channel picker searchable and sorted
- Replace the plain select with a command popover the user can type into
- Offer the channels alphabetically rather than in the API's page order
- Drop the private lock icon; the Private tag alone carries the marker
2026-08-20 11:13:30 +02:00
Pablo F.G eb4319408b fix(ui): stop naming the integration type when saving the Slack channel
- Send only configuration.channel_id in the channel save: the write
  serializer refuses any attribute it does not accept, so the save
  answered 400 and recorded nothing
- Pin the exact save body in the action test
- Refuse extra attributes in the MSW handler, as the API does
2026-08-20 11:13:30 +02:00
Pablo F.G 86b257e298 fix(ui): harden the Slack channel listing against malformed pages
- Keep already-read channel pages when a later page answers a 5xx
- Skip channel resources without a usable id
- Flatten the PATCH body test interface per TypeScript guidelines
2026-08-20 11:13:30 +02:00
Pablo F.G bc87d8e20e refactor(ui): model Slack channel and test-message state as unions
- Collapse the channel-list fetch state into one discriminated union
- Collapse the test-message flag and outcome into one status union
- Group the saved-channel mirror and sync marker into channel refs
2026-08-20 11:13:30 +02:00
Pablo F.G 256f6f4150 chore(ui): drop the channel test-message changelog entry
The Slack integration ships one changelog entry on the stack's first PR
2026-08-20 11:13:30 +02:00
Pablo F.G e9d49fe3bc docs(ui): trim the Slack channel page-test and harness comments
- Shorten Given/When/Then notes to one clause
- Compress harness JSDoc to single informative lines
2026-08-20 11:13:30 +02:00
Pablo F.G 525e5e4c00 docs(ui): trim the Slack channel unit-test comments
- Keep the Given/When/Then scaffold without essayistic tails
- Keep pagination-origin and body-read gotchas as one-liners
2026-08-20 11:13:30 +02:00
Pablo F.G 9c005a7736 docs(ui): trim the Slack channel MSW comments
- Keep fixture semantics and handler precedence notes
- Drop narrative around refusal shapes
2026-08-20 11:13:30 +02:00
Pablo F.G 1e936df7fd docs(ui): trim the Slack channel component comments
- Compress state and rendering rationale to one-liners
- Drop prose restating the JSX
2026-08-20 11:13:30 +02:00
Pablo F.G e5bb46ac23 docs(ui): trim the Slack channel action comments
- Cut narrative prose to the load-bearing why
- Keep ordering, security, and contract anchors
2026-08-20 11:13:30 +02:00
Pablo F.G 39c730c3ae fix(ui): label the Slack channel picker only where there is one
- Stop the destination label pointing at an element the error and empty
  states never render
- Match the Slack copy in tests as the sentences it is, so rewording it
  cannot turn an assertion into an invalid pattern
2026-08-20 11:13:30 +02:00
Pablo F.G dafd384307 chore(ui): drop the unread Slack wiring in the generic integration paths
- Remove the Slack entry from the shared test-connection copy map and
  the Slack branch of the generic update, both unreachable: the Slack
  page runs its connection test and channel save through its own actions
2026-08-20 11:13:30 +02:00
Pablo F.G 665507fa23 fix(ui): stop the Slack card contradicting its own channel state
- Read as still loading on the server-rendered first paint, instead of
  telling a healthy workspace to go create or invite in Slack
- Say a saved channel is recorded even when its name is not on hand
2026-08-20 11:13:30 +02:00
Pablo F.G 4be3b97422 fix(ui): keep an unworded Slack refusal inside Prowler's sentence
- Wrap a reason code this UI has no copy for instead of showing the
  raw protocol token as the whole message
- Share the reason-shape gate with the OAuth callback instead of
  keeping a private copy of it
2026-08-20 11:13:30 +02:00
Pablo F.G 7afb01805e fix(ui): show the Slack channels that were read when the list is partial
- Keep the picker usable when a later page is refused, with the refusal
  as the explanation instead of a blocking error
- Say when the workspace has more channels than one read covers, so a
  missing channel is not misread as a missing @Prowler invite
2026-08-20 11:13:30 +02:00
Pablo F.G 2efcd17274 fix(ui): report a Slack channel-flow server fault to Sentry
- Give the channel listing, the channel save and the test message the
  same upstream-fault reporting the OAuth actions already have
- Keep every user-facing message exactly as it was
2026-08-20 11:13:30 +02:00
Pablo F.G dbea8642c6 fix(ui): report an unreadable Slack channel save as its own outcome
- Read the save result like the install result: an empty or non-JSON
  answer is an unknown outcome, not a parser message shown verbatim
- Refresh the cached pages before judging the answer, since the API
  records the channel before answering
- Stop a resource without a configuration from reaching the card
2026-08-20 11:13:30 +02:00
Pablo F.G 4fa70864b4 fix(ui): track the recorded Slack channel from a single source
- Gate the connection check and its next-step hint on the same
  acknowledged save that reveals the test message
- Follow a destination recorded elsewhere when the page data refreshes,
  without clobbering a pick the user has not saved yet
2026-08-20 11:13:30 +02:00
Pablo F.G aad35d4ed9 fix(ui): validate the integration id in the Slack channel actions
- Refuse an id that is not API-shaped before it reaches a request URL,
  answering the open request-forgery alerts on these actions
- Align the Slack fixtures with the ids the API actually issues
2026-08-20 11:13:29 +02:00
Pablo F.G bd0eca4d29 fix(ui): keep the Slack channel pagination on the API origin
- Resolve a relative `links.next` against the page it arrived on, not
  the API root, so a cursor-only link keeps the listing's path
- End pagination instead of following a link that leaves the API origin
  carrying the tenant's token
2026-08-20 11:13:29 +02:00
Pablo F.G 08760dc681 fix(ui): tell the user why Slack refused a channel or message
Read the refusal's `code` on every Slack-backed flow, not only on the
OAuth ones: the channel listing kept the wait Slack asked for nowhere,
the channel save reported the two channel refusals as one sentence, and
the test message showed Slack's raw reason as if it were copy.

- Keep `Retry-After` on a rate-limited channel listing, so the user is
  told when to come back instead of only that it failed
- Record the destination through a Slack action, so "the channel is
  gone" and "invite @Prowler to it" stop collapsing into one message
- Map the reason the test-message task settles with, falling back to the
  API's own wording when it is not one Prowler has copy for
- Give the MSW refusals the API's real body shape, code and all
2026-08-20 11:13:29 +02:00
Pablo F.G 225d499ee3 feat(ui): pick a Slack channel and send a test message
Offer the connected workspace's channels — public, plus the private ones
@Prowler has been invited to — through a props-driven picker the alerts
form can import unchanged, record the choice as the integration's default
(only channel_id travels; the API derives the name), and prove delivery
with a test message that polls the task the API hands back.
2026-08-20 11:13:29 +02:00
Pablo F.G d6c8570195 docs(ui): trim the comments on the dropped Slack check
- Compress the rationale to one-liners, as the rest of the stack reads
2026-08-20 11:12:15 +02:00
Pablo F.G 84aa8668b5 fix(ui): drop the Slack connection check nothing can enable yet
- Stop rendering Test connection on the connect slice: the API refuses
  a check until a destination channel is recorded, and no state this
  slice can reach records one, so the control could never be enabled
- Read an absent control as "not offered" in the harness, so the test
  covers the page as shipped rather than a fixture-only state
2026-08-20 09:27:25 +02:00
Pablo F.G 3ff71a49f7 docs(ui): trim the Slack install-guard comment 2026-08-19 13:36:36 +02:00
Pablo F.G 3bb8e1c9b5 test(ui): pin the exact OAuth scopes Prowler asks Slack for 2026-08-19 13:36:36 +02:00
Pablo F.G d4dc8acc4e fix(ui): hide the decorative Slack icon from assistive technology 2026-08-19 13:36:36 +02:00
Pablo F.G 43a8d62b11 fix(ui): confirm a Slack install only from a Slack-typed resource
- Require a non-empty id, the integrations resource type and the slack
  integration kind before reporting the workspace as installed
2026-08-19 13:36:36 +02:00
Pablo F.G 5fb4014842 fix(ui): keep the Slack page usable on an unreadable check time
- Guard the last-checked timestamp with isValid before formatting
- Fall back to the never-checked rendering instead of the error boundary
2026-08-19 13:36:36 +02:00
Pablo F.G f4b9dd6eb5 fix(ui): confirm a Slack install only from a readable integration
- Validate the exchange body as a minimal JSON:API resource before the cast
- Report truthy-but-unreadable payloads as the existing unconfirmed result
2026-08-19 13:36:36 +02:00
Pablo F.G 0ec94b4759 fix(ui): only link an install to Slack's own consent screen
- Require HTTPS, the Slack hostname, and the OAuth v2 path
- Refuse hostile schemes, origins, and lookalike hosts with the existing copy
2026-08-19 13:36:36 +02:00
Pablo F.G 014234d262 fix(ui): validate the input to the Slack exchange action
- Reject a malformed exchange argument before it reaches the API
- Derive IntegrationType from a const object
- Honor explicit width and height on the Slack icon
- Pin the revalidated paths on a completed install
2026-08-19 13:36:36 +02:00
Pablo F.G 1e36cb322e fix(ui): refresh the Slack page after a connection test
- Revalidate the Slack path so the badge and last-checked date update
- Hide the decorative Slack icon from assistive technology
- Keep the Slack integration card a Server Component
- Match Slack error codes on own properties only
- Assert the callback redirects back to the integration page
2026-08-19 13:36:36 +02:00
Pablo F.G e5f6250070 test(ui): give the Slack OAuth callback its own page test file
`/integrations/slack/callback` is a route of its own, so its 11 tests move
out of the Slack page file, which now covers only `/integrations/slack`.
2026-08-19 13:36:36 +02:00
Pablo F.G 23e3d0da88 docs(ui): focus the Slack changelog entry on what the user can do 2026-08-19 13:36:36 +02:00
Pablo F.G e72f81b0ce test(ui): stop compiling Server Components in the browser suite
Next runs the React Compiler on the client compilation only, so a Server
Component ships uncompiled. The browser project compiled every module,
and its injected `useMemoCache` needs a dispatcher the page harnesses
cannot provide, which is why two components carried `"use no memo"`.

- Skip the compiler for `app/` modules without `"use client"`
- Pre-bundle `react/compiler-runtime`, which plugin-react no longer adds
- Drop both `"use no memo"` directives
2026-08-19 13:36:36 +02:00
Pablo F.G 063f991cb5 docs(ui): trim the Slack harness and page comments
- Drop the JSDoc that restated harness method names
- Keep the server-component, cache-stub and copy-overlap gotchas
2026-08-19 13:36:36 +02:00
Pablo F.G 8533de96a5 docs(ui): trim the Slack page integration-test comments
- Remove the product arguments the assertions make on their own
- Keep the contract statuses and why each rejection path is covered
2026-08-19 13:36:36 +02:00
Pablo F.G 6baf78af00 docs(ui): trim the Slack MSW handler and fixture comments
- Cut the retellings of the API contract each fixture already shows
- Keep the status meanings and the sources of the fixture values
2026-08-19 13:36:36 +02:00
Pablo F.G 87bbbfea8a docs(ui): trim the Slack unit-test comments
- Remove the Given/When/Then prose that restated the assertions
- Keep the notes explaining why a case exists at all
2026-08-19 13:36:36 +02:00
Pablo F.G e2251ad2af docs(ui): trim the Slack action and error-mapping comments
- Drop the outcome-by-outcome rationale the result types already state
- Keep the status contract, the read-before-throw order and the revalidate why
2026-08-19 13:36:36 +02:00
Pablo F.G d83cec328b docs(ui): trim the Slack component comments to the non-obvious why
- Cut the design essays on the callback, the manager and the card header
- Keep the single-use code, never-checked badge and 400-on-no-channel notes
2026-08-19 13:36:36 +02:00
Pablo F.G 912f6efdcb fix(ui): stop titling an unconfirmed Slack install as not connected
- Give the unreadable success answer its own outcome instead of an error
  string, matching how the other Slack outcomes are modelled
- Title the two outcomes whose result is unknown for what they are, and
  keep the failure wording for the outcomes that really are failures
- Anchor the callback test helpers on the escape link rather than on the
  title copy
2026-08-19 13:36:36 +02:00
Pablo F.G 17b812e649 docs(ui): credit the right mechanism in the Slack callback comment
- Name router.replace, not the ref, as what keeps a back navigation away
  from a completed install
2026-08-19 13:36:36 +02:00
Pablo F.G e9a88ba99e fix(ui): keep a failed install read visible when Slack is unavailable
- Render the notice stack before the cards, so an unavailable
  environment no longer hides that the tenant's install could not be read
- Pin the ordering with the combined-failure case
2026-08-19 13:36:36 +02:00
Pablo F.G de29f2994a fix(ui): report a Slack upstream server fault to Sentry
- Route a 5xx other than the ship-dark 503 through the shared server
  error handling, so an upstream fault is no longer only user copy
- Await the classifier so its throw reaches the action's own catch
- Answer a 502 the API described in HTML or an empty body in Prowler's
  own words
- Cover both the reported and the deliberately unreported statuses
2026-08-19 13:36:36 +02:00
Pablo F.G 2eff780d95 fix(ui): only echo a Slack error reason that looks like a code
- Render the reason from the callback URL only when it has the shape of
  a Slack error token, so URL text cannot pose as Prowler's own copy
- Fall back to owned wording for anything else
- Keep an unrecognised but real code visible for diagnosis
2026-08-19 13:36:36 +02:00
Pablo F.G 3dfbc35c49 fix(ui): report an unreadable Slack install result as its own outcome
- Guard the OAuth success paths against a response body the UI cannot
  parse, instead of leaking a parser message to the user
- Keep the install pages revalidated on that path, since the API has
  already connected the workspace
- Add owned wording for a result Prowler could not read
- Cover the empty, HTML and resource-less answers
2026-08-19 13:36:36 +02:00
Pablo F.G cff28c1f7f fix(ui): stop the Slack callback spinning when the exchange fails
- Report an unconfirmed result instead of spinning forever when the
  exchange call never returns
- Tolerate a created integration that carries no configuration
- Cover both paths with callback unit tests
2026-08-19 13:36:36 +02:00
Pablo F.G 639753d0e0 fix(ui): keep the Slack page usable when the install read fails
- Handle a server error from the integrations read instead of
  letting it reach the error boundary
- Reuse the existing server-error wording rather than surfacing the
  API's own message
- Add a server-error scenario to the Slack test handlers
2026-08-19 13:36:36 +02:00
Pablo F.G 30c898be87 fix(ui): report an unverified integration as never checked
- Widen the shared integration type so `connected` carries the
  never-checked state the API can return
- Show a neutral badge instead of a red "Disconnected" for an
  integration whose connection has not been checked yet
- Stop offering the Slack connection check while no destination
  channel is recorded, since the API refuses it
- Cover the post-install state in the Slack page tests
2026-08-19 13:36:36 +02:00
Pablo F.G 60c8f3cc64 fix(ui): match the Slack error model the API implements
- Carry Slack's reason in the JSON:API error code, not in the detail
- Map each code to copy that says what to do, falling back to the detail
- Refuse a second workspace with a conflict, named by its code
- Say when to come back when Slack is rate limiting, instead of
  reporting Slack as unavailable in the environment
- Serialize the bot user, and omit the channel keys until one is chosen
2026-08-19 13:36:36 +02:00
Pablo F.G eaa856152d feat(ui): add Slack integration connect flow for Prowler Cloud
- Add Slack card and management page, gated on Prowler Cloud
- Connect a workspace by approving Prowler in Slack, with no token to paste
- Complete the install on return from Slack and report the outcome
- Cover the flow with browser-mode integration tests
2026-08-19 13:36:36 +02:00
Eugene C.andHugo P.Brito 0b9791ffdc feat(ecr): add ecr_repository_image_no_secrets check (#12123)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-18 11:10:07 +01:00
Prowler Botandprowler-bot f6defefb58 chore(changelog): v5.39.1 forward-sync to master (#12483)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-18 11:32:20 +02:00
ye11oc4tandHugo P.Brito f3224d0988 fix(ses): evaluate all identity authorization policies (#12464)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 14:19:45 +01:00
Pepe Fagoaga 450e6ba553 chore(api): drop temporary SDK pin overrides after cryptography cap bump (#12473) 2026-08-17 13:42:09 +02:00
2cd93fe119 fix(sdk): skip undescribed ECS task definitions (#12217)
Co-authored-by: Nguyễn Công Thuận Huy <nguyencongthuanhuy@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 12:42:06 +01:00
Pablo Fernandez Guerra (PFE) f807b22ea6 ci: lint .github markdown and fix the violations it exposed (#12290) 2026-08-17 13:00:32 +02:00
Pepe Fagoaga b6e9967da6 fix(deps): make published wheels installable and add package checks (#12467) 2026-08-17 12:34:11 +02:00
Hugo Pereira Brito 16e62f7514 ci(labeler): cover existing provider labels (#12476) 2026-08-17 11:33:22 +01:00
Adrián Peña 13ce9436b3 chore: update Trivy to 0.74.0 (#12466) 2026-08-17 10:25:28 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> d3ced63397 fix(docs): typos and grammar (#12468)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-17 09:21:56 +02:00
Adrián Peña 758b696ca5 feat(ui): highlight imported providers (#12447) 2026-08-17 08:53:14 +02:00
Pepe Fagoaga 0d3ce45374 fix(pypi): bump to pypa/gh-action-pypi-publish v1.14.2 (#12456) 2026-08-14 14:57:07 +02:00
Alejandro Bailo f35666ff0a fix(ui): settle scan auto-refresh safely (#12455) 2026-08-14 11:48:08 +02:00
Pedro Martín 0758c3585d feat(rolesanywhere): flag profiles with unscoped sessions (#12416) 2026-08-13 17:06:24 +02:00
dd882c70e7 chore(release): Bump versions to v5.40.0 (#12443)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-13 13:49:26 +02:00
Hugo Pereira BritoandPablo F.G d05c9fbb31 feat(ui): add trial usage sidebar banner (#12420)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-08-13 11:36:59 +01:00
Josema Camacho 7bde42ffb9 docs: update attack paths documentation for grouped graphs (#12440) 2026-08-13 12:25:14 +02:00
Pepe Fagoaga 0d3df0fd0b chore(changelog): v5.39.0 release highlights (#12432) 2026-08-13 12:08:15 +02:00
Pedro Martín ab996417e6 fix(ci): bump Trivy to v0.73.0 to fix CVE-2026-46600 (#12444) 2026-08-13 12:04:51 +02:00
Prowler Botandprowler-bot 5f109bc00e chore(changelog): v5.39.0 (#12433)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-13 09:01:51 +02:00
Pepe Fagoaga 472e04f4cc docs(triage): manual PASS verification for MANUAL findings (#12431) 2026-08-12 15:46:54 +02:00
Rubén De la Torre Vico b848aace33 docs(mcp): document the Cloud organization tools and Jira dispatch options (#12427) 2026-08-12 15:05:24 +02:00
Pedro Martín 94c20eb9fe feat(ui): add CMMC compliance framework (#12414) 2026-08-12 14:52:09 +02:00
lydiavilchezpedroootcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>pedrooot
02df22ca19 fix(html): escape provider identity fields in report header (#12424)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pedrooot <56402503+pedrooot@users.noreply.github.com>
2026-08-12 13:58:31 +02:00
Hugo Pereira BritoandJosema Camacho de64df11b9 fix(api): normalize social account names (#12413)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-12 12:41:44 +01:00
183 changed files with 16952 additions and 489 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+2 -2
View File
@@ -64,7 +64,7 @@ runs:
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.72.0'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
@@ -81,7 +81,7 @@ runs:
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.72.0'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
+6 -6
View File
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
### For Check Logic Bug
```
```markdown
### AI Assessment [Experimental]: Check Logic Bug
**Component**: {component from issue template}
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Bug (non-check)
```
```markdown
### AI Assessment [Experimental]: Bug
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Already Fixed
```
```markdown
### AI Assessment [Experimental]: Already Fixed
**Component**: {component}
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Feature Request
```
```markdown
### AI Assessment [Experimental]: Feature Request
**Component**: {component}
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Not a Bug
```
```markdown
### AI Assessment [Experimental]: Not a Bug
**Component**: {component}
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Needs More Information
```
```markdown
### AI Assessment [Experimental]: Needs More Information
**Component**: {component or "Unknown"}
+15
View File
@@ -52,6 +52,16 @@ provider/alibabacloud:
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
provider/huaweicloud:
- changed-files:
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
provider/image:
- changed-files:
- any-glob-to-any-file: "prowler/providers/image/**"
- any-glob-to-any-file: "tests/providers/image/**"
provider/cloudflare:
- changed-files:
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
@@ -82,6 +92,11 @@ provider/linode:
- any-glob-to-any-file: "prowler/providers/linode/**"
- any-glob-to-any-file: "tests/providers/linode/**"
provider/stackit:
- changed-files:
- any-glob-to-any-file: "prowler/providers/stackit/**"
- any-glob-to-any-file: "tests/providers/stackit/**"
github_actions:
- changed-files:
- any-glob-to-any-file: ".github/workflows/*"
+5 -5
View File
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
### Available Templates
**Container Releases**
#### Container Releases
- `container-release-started.json`: Simple one-line notification when container push starts
- `container-release-completed.json`: Simple one-line notification when container release completes
**Deployments**
#### Deployments
- `deployment-started.json`: Deployment start notification with Block Kit formatting
- `deployment-completed.json`: Deployment completion notification (updates the start message)
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
### Container Release (Simple One-Line)
**Start message:**
```
```text
API container release 4.5.0 push started... View run
```
**Completion message (success):**
```
```text
[✓] API container release 4.5.0 push completed successfully! View run
```
**Completion message (failure):**
```
```text
[✗] API container release 4.5.0 push failed View run
```
+5 -1
View File
@@ -55,6 +55,10 @@ jobs:
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
# disables postinstall scripts on transitives the same way.
#
# Files come from `git ls-files` because markdownlint doesn't traverse
# dot-directories, so `.github/**/*.md` went unlinted.
# `.markdownlintignore` still applies to the listed paths.
env:
pnpm_config_ignore_scripts: 'true'
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
- name: Publish prowler-mcp package to PyPI
if: steps.pypi-check.outputs.skip != 'true'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
print-hash: true
+196
View File
@@ -0,0 +1,196 @@
name: 'SDK: Package Checks'
# Rehearses the PyPI release on every packaging change and once a week, from the
# consumer's side. Two incidents this guards against:
#
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
# the conflict inside the repo; pip could not install the wheel and silently
# resolved `pip install prowler` to 5.37.1 for a week.
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
# 2.5 and the twine bundled in the publish action rejected it.
#
# Both were only detectable at release time because nothing built and installed the
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
schedule:
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
- cron: '0 6 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
env:
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
# A metadata check that passes here must pass there.
TWINE_VERSION: '7.0.0'
jobs:
changes:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
# input changed. Jobs skipped this way still report success to branch protection.
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
- name: Checkout repository
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Detect packaging changes
if: github.event_name == 'push' || github.event_name == 'pull_request'
id: filter
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
pyproject.toml
uv.lock
README.md
util/replicate_pypi_package.py
util/check_yanked_pins.py
api/pyproject.toml
api/uv.lock
mcp_server/pyproject.toml
mcp_server/uv.lock
.github/workflows/sdk-package-checks.yml
.github/workflows/sdk-pypi-release.yml
.github/actions/setup-python-uv/**
install-from-wheel:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
package:
- 'prowler'
include:
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
# one Python is enough to prove the rename and its build still work.
- python-version: '3.12'
package: 'prowler-cloud'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
install-dependencies: 'false'
- name: Rename package to prowler-cloud
if: matrix.package == 'prowler-cloud'
run: |
pip install --no-cache-dir toml
python util/replicate_pypi_package.py
- name: Build sdist and wheel
run: uv build
- name: Check metadata with the release workflow's twine
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
- name: Install the wheel with pip into a clean virtualenv
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
# override-dependencies or constraint-dependencies, so neither does this step.
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Smoke test the installed CLI
run: |
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/prowler" --version
# Loads every AWS check module from the installed wheel: catches files missing
# from the package. grep fails the step if the summary line never appears.
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
pinned-releases-not-yanked:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.12'
- name: Check every pinned and locked release against PyPI
run: python util/check_yanked_pins.py . api mcp_server
+22 -2
View File
@@ -84,8 +84,18 @@ jobs:
- name: Build Prowler package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish Prowler package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
@@ -128,7 +138,17 @@ jobs:
- name: Build prowler-cloud package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish prowler-cloud package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
+2 -6
View File
@@ -135,8 +135,8 @@ vulnerabilities:
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
# CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a
# cloned repository. Trivy 0.72.0 contains go-git 5.19.1, and even the latest published
# Trivy release, 0.73.0, still pins that vulnerable version:
# cloned repository. Trivy 0.73.0, the latest published release and the version the
# images ship, still pins that vulnerable version:
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
# Trivy main already contains the 5.19.2 fix, but no published release includes it yet:
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
@@ -164,7 +164,3 @@ vulnerabilities:
purls:
- "pkg:golang/oras.land/oras-go/v2"
expired_at: 2026-12-31
- id: CVE-2026-39822
purls:
- "pkg:golang/stdlib"
expired_at: 2026-12-31
+4 -3
View File
@@ -8,15 +8,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.72.0
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
@@ -26,6 +26,7 @@ ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed9
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
+31
View File
@@ -4,6 +4,37 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.40.1] (Prowler v5.39.1)
### 🔄 Changed
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🐞 Fixed
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🔐 Security
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
---
## [1.40.0] (Prowler v5.39.0)
### 🔄 Changed
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
### 🐞 Fixed
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
---
## [1.39.0] (Prowler v5.38.0)
### 🚀 Added
+4 -3
View File
@@ -7,15 +7,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.72.0
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
@@ -36,6 +36,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -1 +0,0 @@
`/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing
@@ -1 +0,0 @@
Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails
@@ -1 +0,0 @@
`GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication
+12 -8
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.40.0"
version = "1.41.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -92,8 +92,7 @@ extend-select = [
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
# workos and pyopenssl run ahead of master: the versions master pins cap cryptography
# below 48, so both were bumped to versions that allow it (PROWLER-2310).
# workos is api-only; pyopenssl matches master (PROWLER-2310).
constraint-dependencies = [
"about-time==4.2.1",
"adal==1.2.7",
@@ -130,7 +129,7 @@ constraint-dependencies = [
"alibabacloud-sls20201230==5.9.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.5",
"alibabacloud-tea-openapi==0.4.6",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
@@ -339,7 +338,7 @@ constraint-dependencies = [
"nltk==3.9.4",
"numpy==2.2.6",
"oauthlib==3.3.1",
"oci==2.183.0",
"oci==2.184.1",
"openai==1.109.1",
"openstacksdk==4.2.0",
"opentelemetry-api==1.39.1",
@@ -380,7 +379,7 @@ constraint-dependencies = [
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
"pyopenssl==26.2.0",
"pyopenssl==26.4.0",
"pyparsing==3.3.2",
"pyreadline3==3.5.4",
"pysocks==1.7.1",
@@ -458,7 +457,7 @@ constraint-dependencies = [
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
"zstd==1.5.7.3"
"zstd==1.5.7.2"
]
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
# Attack Paths does not ingest Okta today, so override the Cartography
@@ -485,7 +484,12 @@ constraint-dependencies = [
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
"cryptography==50.0.0",
"azure-mgmt-containerservice==34.1.0",
"microsoft-kiota-abstractions==1.9.10",
+27 -4
View File
@@ -12,11 +12,37 @@ from api.models import (
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@staticmethod
def _get_social_account_name(extra_data: dict, email: str) -> str:
name_field = User._meta.get_field("name")
for value in (
extra_data.get("name"),
extra_data.get("login"),
extra_data.get("username"),
email,
):
if not isinstance(value, str):
continue
candidate = value.strip()[: name_field.max_length].rstrip()
if not candidate:
continue
try:
name_field.run_validators(candidate)
except ValidationError:
continue
return candidate
raise ValueError("Social account does not provide a valid user identity.")
@staticmethod
def get_user_by_email(email: str):
try:
@@ -116,11 +142,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
if provider != "saml":
# Handle other providers (e.g., GitHub, Google)
user.name = self._get_social_account_name(extra, user.email)
user.save(using=MainRouter.admin_db)
social_account_name = extra.get("name")
if social_account_name:
user.name = social_account_name
user.save(using=MainRouter.admin_db)
invitation_token = self._get_invitation_token(request)
if invitation_token:
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.40.0
version: 1.41.0
description: |-
Prowler API specification.
+104
View File
@@ -111,6 +111,110 @@ def _verify_local_email(user):
)
def test_social_account_name_falls_back_to_login_for_blank_name():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": " ", "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
def test_social_account_name_ignores_unusable_provider_names(provider_name):
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": provider_name, "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_uses_login_when_name_is_missing():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_falls_back_to_username_then_email():
adapter = ProwlerSocialAccountAdapter()
username_name = adapter._get_social_account_name(
{"name": "ab", "login": None, "username": " monalisa "},
"verified@example.com",
)
email_name = adapter._get_social_account_name({}, " verified@example.com ")
assert username_name == "monalisa"
assert email_name == "verified@example.com"
def test_social_account_name_trims_and_limits_provider_name():
adapter = ProwlerSocialAccountAdapter()
max_length = User._meta.get_field("name").max_length
trimmed_name = adapter._get_social_account_name(
{"name": " Ada Lovelace "},
"verified@example.com",
)
limited_name = adapter._get_social_account_name(
{"name": "a" * (max_length + 1)},
"verified@example.com",
)
assert trimmed_name == "Ada Lovelace"
assert limited_name == "a" * max_length
def test_social_account_name_rejects_missing_identity():
adapter = ProwlerSocialAccountAdapter()
with pytest.raises(
ValueError,
match="Social account does not provide a valid user identity",
):
adapter._get_social_account_name({}, "")
def test_save_user_applies_normalized_social_account_name(rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "github"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
user = User(email="verified@example.com")
user.save = MagicMock()
invitation = SimpleNamespace(tenant_id="tenant-id")
with (
patch("api.adapters.super") as mock_super,
patch("api.adapters.transaction.atomic"),
patch("api.adapters.write_db_alias"),
patch.object(adapter, "_get_invitation_token", return_value="token"),
patch(
"api.adapters.accept_invitation_for_user",
return_value=(invitation, True),
),
):
mock_super.return_value.save_user.return_value = user
saved_user = adapter.save_user(request, sociallogin)
assert saved_user.name == "octocat"
assert request.prowler_invitation_token == "token"
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
def test_get_user_by_email_returns_user(self, create_test_user):
Generated
+37 -49
View File
@@ -45,7 +45,7 @@ constraints = [
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
{ name = "alibabacloud-tea", specifier = "==0.4.3" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
@@ -254,7 +254,7 @@ constraints = [
{ name = "nltk", specifier = "==3.9.4" },
{ name = "numpy", specifier = "==2.2.6" },
{ name = "oauthlib", specifier = "==3.3.1" },
{ name = "oci", specifier = "==2.183.0" },
{ name = "oci", specifier = "==2.184.1" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "openstacksdk", specifier = "==4.2.0" },
{ name = "opentelemetry-api", specifier = "==1.39.1" },
@@ -295,7 +295,7 @@ constraints = [
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
{ name = "pyopenssl", specifier = "==26.2.0" },
{ name = "pyopenssl", specifier = "==26.4.0" },
{ name = "pyparsing", specifier = "==3.3.2" },
{ name = "pyreadline3", specifier = "==3.5.4" },
{ name = "pysocks", specifier = "==1.7.1" },
@@ -373,7 +373,7 @@ constraints = [
{ name = "zipp", specifier = "==3.23.0" },
{ name = "zope-event", specifier = "==6.1" },
{ name = "zope-interface", specifier = "==8.2" },
{ name = "zstd", specifier = "==1.5.7.3" },
{ name = "zstd", specifier = "==1.5.7.2" },
]
overrides = [
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
@@ -860,7 +860,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
[[package]]
name = "alibabacloud-tea-openapi"
version = "0.4.5"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
@@ -869,9 +869,9 @@ dependencies = [
{ name = "cryptography" },
{ name = "darabonba-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/34/1918a2d780676494365c7f945bfab397ecddb988054d78025bd26f438977/alibabacloud_tea_openapi-0.4.6.tar.gz", hash = "sha256:dafc32401712f5b21c12dc3d05ba887a91ad156d9b49a7662279f9fd90526fb2", size = 26742, upload-time = "2026-08-17T08:34:11.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" },
{ url = "https://files.pythonhosted.org/packages/35/00/2f534f5884e5f299d9cb3a1e8be2def8071bc6a6e2a192ba4ff2a8cd5e02/alibabacloud_tea_openapi-0.4.6-py3-none-any.whl", hash = "sha256:c9e1727b9fb2936f487d050fc3590c99f9f2065256dc3a927e5b61f414674ed6", size = 33448, upload-time = "2026-08-17T08:34:10.472Z" },
]
[[package]]
@@ -4426,7 +4426,7 @@ wheels = [
[[package]]
name = "oci"
version = "2.183.0"
version = "2.184.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -4439,9 +4439,9 @@ dependencies = [
{ name = "pytz" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/2a/77bd6cbf1c69b2f368fe3d6462d84369b0cba15e37ce713cdc08d459b95a/oci-2.183.0.tar.gz", hash = "sha256:ff572ef5f2030a788796bb509d257e6a41c6510ef9b4b6a75a079efd06e533ce", size = 17759723, upload-time = "2026-07-28T06:02:29.76Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/2d/fa5368cfabb868f4111c6978e8b5f66aa3a55076c40c1a59ac3081b0227b/oci-2.184.1.tar.gz", hash = "sha256:617dad69caf8dd6e521d224dbc3e8a8bc289906943a0214fd2c3419094e26435", size = 17990631, upload-time = "2026-08-11T11:01:26.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/de/8574b3e527996a099d196e87794a4652d91a0c3185fcc7fdbb5649b75a8a/oci-2.183.0-py3-none-any.whl", hash = "sha256:bd789c98a94d7c5ea08c20d11dcf68c9cd1ad479b134727d80a930b84387070b", size = 36133501, upload-time = "2026-07-28T06:02:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/5f/63/5ae22e42aaf96a5da74dc2b9de449c78b4d7418cce621d5da723b3e49f32/oci-2.184.1-py3-none-any.whl", hash = "sha256:bd814e38a70da2190e721937455a08689ab13c0750bd2ef8dd0c98b2dc5a38ea", size = 36628063, upload-time = "2026-08-11T11:01:18.178Z" },
]
[[package]]
@@ -4835,8 +4835,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.38.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" }
version = "5.40.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4935,7 +4935,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.40.0"
version = "1.41.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5426,15 +5426,15 @@ wheels = [
[[package]]
name = "pyopenssl"
version = "26.2.0"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
]
[[package]]
@@ -6623,39 +6623,27 @@ wheels = [
[[package]]
name = "zstd"
version = "1.5.7.3"
version = "1.5.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" },
{ url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" },
{ url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" },
{ url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" },
{ url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" },
{ url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" },
{ url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" },
{ url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" },
{ url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" },
{ url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" },
{ url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" },
{ url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" },
{ url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" },
{ url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" },
{ url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" },
{ url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" },
{ url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" },
{ url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" },
{ url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" },
{ url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" },
{ url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" },
{ url = "https://files.pythonhosted.org/packages/43/2a/0885f6f1921ec1ef4a8f8ab29ab0a335cc867abe4c7aaa4e5031435a32a5/zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b", size = 269702, upload-time = "2025-06-23T12:50:11.695Z" },
{ url = "https://files.pythonhosted.org/packages/05/e6/629cf6b77e47fc7149f5724fb4853c48edcdeb10d8c64e391d7026cb10e1/zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042", size = 228145, upload-time = "2025-06-23T12:50:10.411Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b8/9ddefd4670bfe9328ca6657ad335eb8d9c657466247e234a579818b6b0b9/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773", size = 1536530, upload-time = "2025-06-23T13:51:38.853Z" },
{ url = "https://files.pythonhosted.org/packages/d1/6a/1bb836c18760dc1e28ca7a9706016e482ebdea633b980d8505dbb65e18f8/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a", size = 1616141, upload-time = "2025-06-23T13:51:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/b5/7a/bb6c6e2cb2a066e347dc27d45d5205058b69d6c8b8d4ae2ee7d6b91c64a5/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6", size = 322188, upload-time = "2025-06-23T13:01:48.704Z" },
{ url = "https://files.pythonhosted.org/packages/5a/4f/cf0669c8a89fdcc91814bf92bd05cc363d5d12a79b656418c0add6f2d266/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a", size = 302736, upload-time = "2025-06-23T13:05:33.649Z" },
{ url = "https://files.pythonhosted.org/packages/be/bc/e5f8b7f61826323e39e099db1eb5c0e09b18315df1b1ff778f7ae9aadcac/zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806", size = 1522687, upload-time = "2025-06-23T13:51:35.494Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7660a949a020ac9d02b3166a25dd1c12144572d77b11ae92a31d341016da/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58", size = 2098794, upload-time = "2025-06-23T13:51:37.219Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b2/730c811a78d670104d40c7f08cc8092577cdff870cba42b3158f20fceb57/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7", size = 2112266, upload-time = "2025-06-23T13:51:31.258Z" },
{ url = "https://files.pythonhosted.org/packages/44/74/2c16e1632094db36c8920d4c13b8e2e843024d548ae26888c2d22af6a676/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29", size = 2109465, upload-time = "2025-06-23T13:51:32.884Z" },
{ url = "https://files.pythonhosted.org/packages/58/6e/b9c9a834769d96cab2122da1be8c8c700d3f76be796d2b7516e85d2eca0e/zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b", size = 149448, upload-time = "2025-06-23T13:06:21.144Z" },
{ url = "https://files.pythonhosted.org/packages/47/b7/fc22ad6292a32d7676ab815de3a23573beac3679e8abd9914288d1496ceb/zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218", size = 166592, upload-time = "2025-06-23T13:06:22.126Z" },
{ url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" },
{ url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" },
{ url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" },
{ url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" },
{ url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c9/a6495a7bf168a78f0a0c01d61d830ebfb401315a64fd1ae8d725c458114c/zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a", size = 315542, upload-time = "2025-06-23T12:39:27.598Z" },
]
+111
View File
@@ -4,6 +4,117 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.39.0" description="August 13, 2026">
### 🤖 Lighthouse AI — Finding Skills
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI now embeds a Skills menu on every finding, answering the questions an analyst actually asks. **Contextual Fix** produces the fix for the finding, **Triage Decision** judges whether it is real and closes it out when it is not, and **Systemic Scope** determines whether the problem is a one-off or everywhere. A free-form "Ask Lighthouse anything" prompt sits in the same menu, and each run shows its progress and offers follow-up actions such as creating a Jira issue or muting the finding.
![Lighthouse AI Skills menu on a finding resource](/images/changelog/v5.39.0-lighthouse-finding-skills.png)
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
### ☁️ Azure Management Group Onboarding
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have.
![Azure onboarding method selector with the Management Group option](/images/changelog/v5.39.0-azure-mg-selector.png)
Read more in the [Azure Management Groups documentation](https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups).
### ✅ Findings Triage — Verify MANUAL Findings as PASS
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Checks that require human judgment report `MANUAL` findings. For these findings, and only for them, the triage status selector now offers **Resolved**: choosing it asks for the required written evidence and verifies the finding as passing. The finding then reports an effective `PASS` while preserving the raw `MANUAL` scan result, across findings, finding groups, compliance reports, and scans, with the attestation's author, evidence, and validity always visible. Attestations expire automatically after 90 days, or as soon as a new scan reports a real failure, returning the finding to the review queue.
![Triage status selector offering Resolved on a MANUAL finding](/images/changelog/v5.39.0-manual-pass-selector.png)
![Manual Pass details showing evidence, author, and validity](/images/changelog/v5.39.0-manual-pass-details.png)
Read more in the [Findings Triage documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage#verify-a-manual-finding-as-pass).
### ☁️ Prowler Cloud MCP — Organizations Management and Grouped Jira Dispatch
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
The hosted Prowler Cloud MCP server adds eight organization tools, so an agent can onboard and manage entire cloud organizations end to end: create the organization, discover its accounts, subscriptions, and projects, apply the selection, and manage the resulting providers. The tools cover AWS Organizations, GCP organizations, and Azure tenant root management groups, and they are available to Lighthouse AI.
`prowler_send_findings_to_jira` also gains Cloud-only dispatch capabilities: select failed findings by check IDs against the latest completed scan, and send them in grouped mode, one Jira work item per check listing up to 50 affected resources, with per-group error reporting.
Read more in the [Prowler MCP tools documentation](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools) and its [Jira operations reference](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools#jira-operations).
### 🕸️ Attack Paths — Grouped Graph with Outcome Destinations
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
The Attack Paths graph now reads from source to destination. Resources of the same class collapse into a single expandable node with a count, clicking reveals its members, and every path terminates in an explicit outcome node naming the destination impact: code execution, privilege escalation, public exposure, or resource inventory. The per-account hub node is gone, and the clicked resource stays highlighted while its findings are expanded.
![Attack Paths graph from the Internet to a public exposure outcome node](/images/changelog/v5.39.0-attack-paths-graph.png)
Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths).
Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths).
### 📚 New Compliance Framework — CMMC 2.0
The Cybersecurity Maturity Model Certification (CMMC) is the certification the US Department of Defense requires from contractors and suppliers that handle federal contract data. Prowler now includes CMMC 2.0 as a universal framework with all 149 requirements defined by the CMMC Program rule (32 CFR Part 170), organized in its three levels:
- **Level 1 (Foundational):** 15 requirements for the basic safeguarding of Federal Contract Information, from FAR 52.204-21.
- **Level 2 (Advanced):** 110 requirements from NIST SP 800-171 Rev 2, protecting Controlled Unclassified Information.
- **Level 3 (Expert):** 24 enhanced requirements from NIST SP 800-172 for the most sensitive programs.
Requirements map to Prowler checks across AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud, and Microsoft 365, so one framework reports the compliance posture of the whole estate.
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
### 🔍 Checks
#### Microsoft 365
Twenty new Entra ID checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0:
- **Password protection:** custom banned password list, on-premises enforcement, and lockout threshold and duration.
- **Default user permissions:** security group and Microsoft 365 group creation restricted, and guest invitations limited to allowed domains.
- **Conditional Access:** high and medium sign-in risk blocked, authentication transfer blocked, untrusted locations blocked, trusted named locations defined, sign-in frequency enforced, and token protection enforced.
- **Sessions and authentication methods:** idle session timeout configured, email one-time passcodes disabled, and Microsoft Authenticator context shown.
- **PIM and access reviews:** approval required to activate the Global Administrator and Privileged Role Administrator roles, and access reviews configured for guest users and privileged roles.
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
#### AWS
Two new checks detect hardcoded secrets:
- `batch_job_definition_no_secrets` scans Batch job definition environment variables and command parameters. Thanks to @praneetrajv!
- `awslambda_layer_no_secrets_in_content` scans Lambda layer package content. Thanks to @ganiganesh25!
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @praneetrajv: AWS `batch_job_definition_no_secrets` check ([#12117](https://github.com/prowler-cloud/prowler/pull/12117))
- @ganiganesh25: AWS `awslambda_layer_no_secrets_in_content` check ([#12233](https://github.com/prowler-cloud/prowler/pull/12233))
- @andoniaf: GitHub `organization_repository_creation_limited` now reports low severity when repository creation is limited to private or internal visibility ([#12164](https://github.com/prowler-cloud/prowler/pull/12164))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.39.0) for the complete list of changes.
</Update>
<Update label="v5.38.0" description="August 6, 2026">
### 📌 Compliance Watchlist
+2 -2
View File
@@ -224,9 +224,9 @@ Each check **must** populate the report with a unique identifier for the audited
- `resource_name`: Description of the configuration (e.g., "SharePoint Settings")
- GitHub
- Resource ID — `report.resource_id`.
- The ID of the Github resource. This is a system-generated integer that uniquely identifies the resource within the Github platform.
- The ID of the GitHub resource. This is a system-generated integer that uniquely identifies the resource within the GitHub platform.
- Resource Name — `report.resource_name`.
- The name of the Github resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
- The name of the GitHub resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
### Configurable Checks in Prowler
+3 -3
View File
@@ -107,7 +107,7 @@ Once you have decided the provider you want or need to add to Prowler, the next
- **SDK Providers**: Low complexity. You have mature examples like AWS, Azure, GCP, Kubernetes, etc. that you can leverage to implement your provider.
- **API Providers**: Medium complexity. You need to implement the authentication and session management, and the API calls to the provider. You now have NHN and MongoDB Atlas as example to follow.
- **Tool/Wrapper Providers**: High complexity. You need to implement the argument/output mapping to the provider and handle problems that the tool/wrapper may have. You now have IAC and the PowerShell wrapper as example to follow.
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and Github (PyGithub SDK + graphql API requests) as examples.
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and GitHub (PyGithub SDK + graphql API requests) as examples.
### Determining Regional vs Non-Regional Architecture
@@ -814,7 +814,7 @@ class YourProviderMutelist(Mutelist):
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
<Note>
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
</Note>
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
@@ -1773,7 +1773,7 @@ The implementation of the mutelist is the same as the [SDK providers](#step-5-im
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
<Note>
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
</Note>
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
+2 -1
View File
@@ -182,7 +182,8 @@
"pages": [
"user-guide/tutorials/prowler-app-s3-integration",
"user-guide/tutorials/prowler-app-security-hub-integration",
"user-guide/tutorials/prowler-app-jira-integration"
"user-guide/tutorials/prowler-app-jira-integration",
"user-guide/tutorials/prowler-app-slack-integration"
]
},
{
@@ -11,10 +11,10 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
| Prowler Hub | 10 tools | No | Cloud and Local MCP Server |
| Prowler Documentation | 2 tools | No | Cloud and Local MCP Server |
| Prowler Cloud, Private Cloud & Local Server | 49 tools | Yes | Cloud and Local MCP Server |
| Prowler Cloud management | 32 tools | Yes | Cloud MCP Server only |
| Prowler Cloud management | 40 tools | Yes | Cloud MCP Server only |
<Note>
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools.
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools. `prowler_send_findings_to_jira` is exposed by both servers but accepts two [extra parameters](#jira-operations) on the Cloud MCP Server.
</Note>
## Tool Naming Convention
@@ -124,7 +124,20 @@ Tools for managing where Prowler sends its results: Amazon S3 buckets, AWS Secur
#### Jira Operations
- **`prowler_get_jira_issue_types`** - List the issue types available in a Jira project, fetched live from Jira
- **`prowler_send_findings_to_jira`** - Create one Jira work item per finding, with its severity, resource, risk, and remediation steps
- **`prowler_send_findings_to_jira`** - Create Jira work items from findings, each carrying the check title, severity, status, provider, region, resource, risk, and remediation steps. Select the findings either by ID with `finding_ids`, or — on Prowler Cloud only — by check with `check_ids`, and choose between one work item per finding or one per check with `dispatch_mode`
<Note>
`check_ids` and `dispatch_mode` are **Prowler Cloud only**:
- **`check_ids`** - Send the failing findings of a check (for example `s3_bucket_public_access`) without listing their IDs. Prowler resolves them server-side, taking only the failed findings of the latest completed scan of every provider. Get the check IDs from `prowler_list_finding_groups`. Exactly one of `finding_ids` or `check_ids` is required — Prowler combines both filters, so sending both would only dispatch their intersection. A Local MCP Server rejects `check_ids` with a client error.
- **`dispatch_mode`** - `individual` (the default) creates one work item per finding. `grouped` creates one work item per check instead, listing up to 50 affected resources and linking back to the finding group in Prowler Cloud, which keeps a noisy check to a single ticket. Grouped dispatch only covers failed, unmuted findings of the latest completed scan of every provider. A Local MCP Server ignores `dispatch_mode` instead of rejecting it, and creates one work item per finding.
In `grouped` mode the response counters change meaning: `created_count` counts work items (one per check) rather than findings, `failed_count` counts the entries of the new `failed_groups` field, and `failed_groups` details each failure with its reason and the `check_id` whose work item could not be created.
</Note>
<Warning>
`prowler_send_findings_to_jira` creates real work items that Prowler cannot delete or update afterwards. Only retry the same dispatch when the previous response returned `safe_to_retry: true`, otherwise the work items already created are duplicated. Combining `check_ids` with the default `individual` mode opens one work item per failing resource, which can be hundreds of them — use `dispatch_mode="grouped"` to keep it to one per check.
</Warning>
### Attack Paths Analysis
@@ -167,6 +180,23 @@ Manage Prowler Cloud-only features and configuration. **Requires authentication.
These tools are available **only on the Cloud MCP Server** (`https://mcp.prowler.com/mcp`). A Local MCP Server does not expose them, because the features they manage exist only in Prowler Cloud.
</Note>
### Organizations
Tools for onboarding a cloud provider organization as a whole — an AWS Organization, an Azure tenant with its management groups, or a GCP organization with its folders. An organization holds org-level credentials, discovers the real account, subscription, or project structure in the cloud, and turns a selection from that discovery into Prowler providers linked into a hierarchy of nodes. Every tool that changes something — creating, updating, deleting, discovering, applying a discovery, or adjusting provider membership — requires the **Manage Providers** permission; listing and reading do not.
<Note>
Use these tools for the whole organization. To register providers one by one, use the [Provider Management](#provider-management) tools instead; to build arbitrary RBAC buckets of providers, use provider groups.
</Note>
- **`prowler_cloud_list_organizations`** - Browse the registered organizations with lightweight data (name, type, external id, provider and node counts), filtered by type or cloud-side external id
- **`prowler_cloud_get_organization`** - Get one organization in full: attributes, linked providers, credentials status, latest discovery, and the OU / management group / folder hierarchy. Set `include_hierarchy` to `false` to skip the tree on large organizations
- **`prowler_cloud_create_organization`** - Register an organization, optionally storing its org-level credentials in the same call. Idempotent: an organization with the same type and external id is reused and its credentials rotated, reported as `created: false`
- **`prowler_cloud_update_organization`** - Rename an organization, replace its metadata, and/or create or rotate its org-level credentials. `org_type` and `external_id` are immutable after creation
- **`prowler_cloud_delete_organization`** - Delete an organization, its entire hierarchy, and every linked provider
- **`prowler_cloud_discover_organization`** - Enumerate the real cloud structure: AWS accounts and OUs, Azure subscriptions and management groups, or GCP projects and folders. Each item comes back with its registration state so you can choose what to onboard
- **`prowler_cloud_apply_organization_discovery`** - Turn a discovery selection into Prowler providers and hierarchy nodes
- **`prowler_cloud_manage_organization_providers`** - Manually `add`, `replace`, or `remove` the providers linked to an organization or to one of its hierarchy nodes. Providers are detached, never deleted
### Scan Configurations
Tools for managing reusable scan configurations — per-provider check and compliance selections — and attaching them to providers. Providers without a configuration attached use the default.
@@ -128,12 +128,12 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.38.0"
PROWLER_API_VERSION="5.38.0"
PROWLER_UI_VERSION="5.39.0"
PROWLER_API_VERSION="5.39.0"
```
<Note>
You can find the latest versions of Prowler Local Server in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
You can find the latest versions of Prowler Local Server in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
</Note>
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+1
View File
@@ -92,6 +92,7 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
@@ -495,7 +495,7 @@ aws:
# AWS CloudTrail Configuration
# aws.cloudtrail_threat_detection_privilege_escalation
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is an privilege_escalation attack event, by default is 0.2 (20%)
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is a privilege_escalation attack event, by default is 0.2 (20%)
threat_detection_privilege_escalation_minutes: 1440 # Past minutes to search from now for privilege_escalation attacks, by default is 1440 minutes (24 hours)
threat_detection_privilege_escalation_actions:
[
@@ -16,26 +16,24 @@ Attack Paths analyzes relationships between cloud resources, permissions, and se
By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources.
<Note>
Attack Paths is currently available for **AWS** providers. Support for
additional providers is planned.
Attack Paths is currently available for **AWS** providers. Support for additional providers is planned.
</Note>
## Prerequisites
The following prerequisites are required for Attack Paths:
- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
- **An AWS provider is configured** with valid credentials. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
- **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
## How Attack Paths Scans Work
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources.
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. When a scan produces graph data, it maps relationships between IAM principals, policies, trust configurations, and other resources. A scan can complete without producing graph data.
Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready.
When graph data is ready, the scan appears in the Attack Paths scan table with a check in the **Graph** column and can be selected regardless of its current status. Scans without graph data remain visible but cannot be selected. If a new scan cycle starts after graph data is available, the previous cycle remains available while the new scan runs.
<Note>
Since Prowler scans all configured providers every **24 hours** by default,
Attack Paths data stays up to date automatically.
Prowler Cloud and Prowler Private Cloud scan configured providers every **24 hours** by default, so Attack Paths data stays up to date automatically.
</Note>
## Accessing Attack Paths
@@ -66,7 +64,7 @@ The scans table displays all Attack Paths scans with the following columns:
- **Graph:** Whether Attack Paths graph data is available for the scan.
- **Duration:** Total scan time.
To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data.
To select a scan for analysis, click any row with a check in the **Graph** column. A row can remain selectable while a new scan cycle runs because Attack Paths keeps the graph from the previous completed cycle available.
<img
src="/images/prowler-app/attack-paths/scan-list-table.png"
@@ -75,8 +73,7 @@ To select a scan for analysis, click the radio button on any row with a **Comple
/>
<Note>
Only scans with graph data can be selected. Disabled rows include a tooltip
that explains why the graph is not available yet.
Only scans with graph data can be selected. Disabled rows include a tooltip that explains why the graph is not available yet.
</Note>
## Choosing a Query
@@ -97,9 +94,7 @@ To choose a query, click the dropdown and select from the available options. Eac
Once selected, a description panel appears below the dropdown with more context about the query.
<Note>
In Prowler Cloud and Prowler Private Cloud, the query selector hides queries
confirmed empty for the selected scan, so only queries that return data remain
visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
In Prowler Cloud and Prowler Private Cloud, the query selector hides built-in queries confirmed empty for the selected scan. Built-in queries without a confirmed empty result and the **Custom openCypher query** remain visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
</Note>
## Configuring Query Parameters
@@ -120,7 +115,7 @@ For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key**
## Writing Custom openCypher Queries
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue.
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalog.
To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query.
@@ -192,11 +187,7 @@ Custom queries traverse the same Cartography graph the built-in queries use. Nod
For the complete reference, including the graph model, list-typed and JSON-encoded properties, performance guidance, and openCypher compatibility rules, see [Attack Paths Queries](/developer-guide/attack-paths-queries) in the Developer Guide.
<Note>
AI assistants connected through Prowler MCP Server can fetch the exact
Cartography schema for the active scan via the
`prowler_get_attack_paths_cartography_schema` tool. This guarantees that
generated queries match the schema version pinned by the running Prowler
release.
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan via the `prowler_get_attack_paths_cartography_schema` tool. This guarantees that generated queries match the schema version pinned by the running Prowler release.
</Note>
## Executing a Query
@@ -221,12 +212,22 @@ If the query returns no results, an informational message appears. Common reason
After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings.
### Grouped Graphs and Query Outcomes
<VersionBadge version="5.39.0" />
Prowler Cloud and Prowler Private Cloud group resources of the same class and graph level into expandable nodes. Built-in query graphs also end with a query outcome, which states the result that the path can lead to.
Prowler Local Server keeps the flat graph view, including the provider root. Custom openCypher queries do not have a catalog outcome, so their graphs do not include an outcome node.
### Node Types
- **Provider root nodes:** Represent the AWS account or provider root for the selected scan.
- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets.
- **Grouped resource nodes:** Represent multiple resources of the same class in Prowler Cloud and Prowler Private Cloud. A number in the upper-right corner shows how many resources the node contains. A red outline indicates that one or more resources in the group have findings.
- **Resource nodes:** Represent individual cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. A class with one resource remains an individual node.
- **Internet nodes:** Represent exposure from the public internet.
- **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low.
- **Outcome nodes:** Mark the terminal result of a built-in query in Prowler Cloud and Prowler Private Cloud. The orange node displays outcomes such as **Code execution**, **Privilege escalation**, **Public exposure**, or **Resource inventory**. A dashed ring and the label **Latent outcome** indicate an inventory or another partial outcome.
- **Provider root nodes:** Represent the AWS account or provider root in the Prowler Local Server flat graph.
### Edge Types
@@ -234,7 +235,7 @@ After a successful execution, the graph visualization renders below the query bu
- **Finding edges:** Dashed relationships between resources and their associated findings.
- **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding.
The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view.
The standard graph view includes a minimap and a legend below the canvas. The legend shows the visible node types, finding risk levels, node states, and edge types present in the current view. Prowler Local Server also displays the provider root in the legend.
<img
src="/images/prowler-app/attack-paths/graph-visualization.png"
@@ -244,17 +245,32 @@ The standard graph view includes a minimap and a legend below the canvas. The le
## Interacting with the Graph
The graph banner describes the main interactions:
The graph supports these interactions:
- Click a node with a number in the upper-right corner to expand its resource group.
- Click a finding to focus its connected path.
- Click a resource with findings to show or hide its related findings.
- Hover a node to highlight its connected path.
### Expanding Resource Groups
In Prowler Cloud and Prowler Private Cloud, a node with a number in its upper-right corner represents a resource group. The number is the total number of resources in the group.
- Click the group node to display its resources. Multiple groups can remain expanded, and the graph automatically fits the visible nodes to the canvas.
- Double-click any resource revealed from a group to collapse that group.
- Click **Collapse all groups** in the graph toolbar to close every expanded group. This control appears only while at least one group is expanded.
<img
src="/images/prowler-app/attack-paths/graph-visualization-expanded.png"
alt="Attack Paths graph showing an expanded AWS Role group and remaining numbered resource groups"
width="700"
/>
### Showing Related Findings
Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them.
The graph automatically fits the selected resource and its related findings when the findings are shown.
The selected resource is highlighted in green while its findings are visible. The graph automatically fits the selected resource and its related findings when the findings are shown.
### Focusing a Finding Path
@@ -279,12 +295,12 @@ The toolbar in the top-right corner of the graph provides:
- **Zoom in / Zoom out:** Adjust the zoom level
- **Fit graph to view:** Reset the view to fit the visible graph
- **Export graph:** Download the current graph as a PNG file
- **Collapse all groups:** Close every expanded resource group. This control appears only when a group is expanded
- **Export graph:** Download the current graph, including its grouped or expanded state and outcome, as a PNG file
- **Fullscreen:** Open the graph in a full-size modal
<Note>
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within
the graph area.
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within the graph area.
</Note>
## Viewing Finding Details
@@ -42,12 +42,12 @@ The status selector includes manual statuses. Prowler also sets automatic status
| **Remediating** | Manual | Work is in progress to fix the finding. |
| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. |
| **False Positive** | Manual | The finding does not apply and should be muted. |
| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. |
| **Resolved** | Automatic / Manual | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. On `MANUAL` findings, select it to verify the finding as passing (see [Verify a MANUAL Finding as Pass](#verify-a-manual-finding-as-pass)). |
| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. |
![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png)
Resolved and Reopened are not manual selector options.
**Reopened** is never a manual selector option. **Resolved** appears in the selector only on `MANUAL` findings, where it starts the [Manual Pass verification](#verify-a-manual-finding-as-pass).
These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot.
@@ -93,6 +93,39 @@ Triage notes are visible only to the team in the current organization. Each note
To remove an existing note, clear the note text and save the change.
## Verify a MANUAL Finding as Pass
<VersionBadge version="5.39.0" />
Checks that Prowler cannot judge automatically report `MANUAL` findings. When a team verifies such a control outside Prowler, the triage selector on that finding offers **Resolved**: choosing it records a Manual Pass attestation, and the finding reports an effective `PASS` while keeping the raw `MANUAL` scan result.
![Triage selector offering Resolved on a MANUAL finding](/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png)
<Steps>
<Step title="Filter MANUAL findings">
Go to **Findings** and filter by status **Manual**.
</Step>
<Step title="Open the triage selector">
Expand a Finding Group and click the current status in the **Triage** column of an individual finding.
</Step>
<Step title="Choose Resolved">
Select **Resolved**. Prowler opens the triage note modal with a required **Manual pass evidence** field.
</Step>
<Step title="Record the evidence">
Describe how the control was verified, then click **Save**. The evidence supports up to 500 characters.
</Step>
</Steps>
![Manual Pass attestation with required evidence](/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png)
After saving, the finding reports `PASS` in finding tables, finding groups, compliance reports, and scans. While the attestation is active, the triage status is managed automatically and cannot be changed. **View Manual Pass details** shows who verified the finding, the evidence, the attestation time, and its expiration.
![Manual Pass details showing evidence, author, and validity](/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png)
### Attestation Expiration
A Manual Pass attestation is valid for 90 days. It also ends early when a later scan reports a real failure for the finding. In both cases the finding returns to its raw `MANUAL` status for a new review.
## Mutelist Behavior
Findings Triage uses Mutelist when a status means the finding should be muted:
@@ -118,7 +151,7 @@ Confirm that the user role has **Manage Scans** permission. Prowler Local Server
### Resolved or Reopened is missing from the selector
This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes.
**Reopened** is always automatic. **Resolved** is set automatically from scan result changes and appears as a selector option only on `MANUAL` findings, where it records a [Manual Pass](#verify-a-manual-finding-as-pass). On findings with any other status, this is expected.
### Risk Accepted or False Positive muted a finding
@@ -0,0 +1,171 @@
---
title: "Slack Integration"
sidebarTitle: 'Slack'
description: 'Connect a Slack workspace to Prowler Cloud, choose the channel Prowler posts to, and verify the connection.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.40.0" />
<SubscriptionBanner />
Prowler Cloud connects to a Slack workspace so security updates arrive where teams already work. Connecting takes one approval in Slack — there is no bot token to create, copy, or store by hand — and Prowler records a single destination channel it posts to.
Integrating Prowler Cloud with Slack provides:
* **Approval-based setup:** Approve Prowler once in Slack instead of building a Slack app and pasting a token.
* **A verified destination:** Saving a channel checks it, so a channel Prowler cannot reach is reported straight away rather than when something depends on it.
* **Controlled reach:** Prowler posts only to the channel recorded on the integration, and private channels stay invisible until the Prowler app is invited to them.
<Note>
This guide covers the Slack integration in Prowler Cloud. It is unrelated to the Prowler CLI `--slack` flag, which posts a scan summary from the command line using a self-created Slack app and the `SLACK_API_TOKEN` and `SLACK_CHANNEL_NAME` environment variables — see [CLI Integrations](/user-guide/cli/tutorials/integrations) for that feature.
</Note>
## How the Slack Integration Works
When connected and configured:
1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted.
2. Prowler reads the channels it can post to: the workspace's public channels, plus the private channels the Prowler app has been invited to.
3. One channel is recorded on the integration as the default destination.
4. Saving that channel checks the connection against it, covering both the credential and the channel.
5. Disconnecting removes the integration from Prowler and attempts to revoke Prowler's access at Slack.
## Prerequisites
The Slack integration is available only in **Prowler Cloud** and **Prowler Private Cloud**. Prowler Local Server does not serve the Slack endpoints at all, so the Slack card does not appear on the Integrations page and the management page redirects away.
Configuring and using the Slack integration requires the **Manage Integrations** permission. The integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group.
One Slack workspace connects per tenant. Approving Prowler again in the same workspace refreshes the stored credential, while approving it in a *different* workspace is refused until the current workspace is disconnected — a workspace is never swapped out silently.
## Permissions Prowler Requests in Slack
Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes:
| Scope | Why Prowler Requests It |
|-------|-------------------------|
| `chat:write` | Post to the recorded channel. |
| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. |
| `channels:read` | List public channels for the destination-channel picker and resolve the chosen one. |
| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the picker. |
Two of these read more broadly than they behave, and both are worth understanding before approving the app.
### What `chat:write.public` Does Not Grant
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channel recorded on the integration.** The scope exists so that recording a public channel does not also require someone to invite the Prowler app to it first.
### Why a Private Channel Is Missing From the Picker
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the picker only after someone invites `@Prowler` to it in Slack:
```text
/invite @Prowler
```
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
## Connecting a Slack Workspace
To connect a Slack workspace to Prowler Cloud:
1. Navigate to **Integrations** in Prowler Cloud.
2. Locate the **Slack** card and click **Manage**.
![Slack card on the Integrations page in Prowler Cloud](/images/prowler-app/slack/integrations-tab.png)
3. Click **Add to Slack**.
![Slack management page before a workspace is connected, showing the Add to Slack action](/images/prowler-app/slack/no-workspace-connected.png)
4. In Slack, select the workspace to connect and approve the permissions listed on the consent screen.
5. Slack returns to Prowler Cloud, which completes the install and shows the connected workspace.
![Connected Slack workspace with no destination channel recorded yet](/images/prowler-app/slack/connected-workspace.png)
The connected card reports the workspace name and a **Not checked yet** status: the connection is checked against the destination channel, so no check has run at this point. Choosing that channel is the next step. Once one is recorded, **Test connection** verifies that Prowler can still reach both the workspace and that channel.
<Note>
Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again.
</Note>
## Choosing the Default Channel
Prowler posts to one channel, recorded on the integration as its default destination.
1. Open the **Destination channel** picker. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**.
![Destination channel picker listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png)
2. Select a channel.
3. Click **Save channel**.
Prowler validates the selection against Slack and derives the channel name itself, so the recorded name can never drift from the channel it belongs to. Once a channel is saved, the page reports where Prowler posts.
If the picker reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
A workspace can hold more channels than Prowler reads in one go. When that happens, the picker says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to.
## Disconnecting a Slack Workspace
Disconnecting removes the integration from Prowler **and** attempts to revoke Prowler's access at Slack.
1. On the Slack management page, click **Disconnect**.
2. Review the confirmation, then click **Disconnect workspace**.
![Disconnect Slack workspace confirmation dialog](/images/prowler-app/slack/disconnect-confirmation.png)
The page returns to its unconnected state, ready for a new install.
### What Revocation Means
Revocation is attempted at Slack, and it is best-effort:
* **Revocation succeeded:** The stored credential no longer grants Prowler anything, and the integration is gone from Prowler.
* **Revocation failed:** The integration and the stored credential are gone from Prowler either way, so there is nothing to retry. Slack did not confirm the revocation, which means the Prowler app may still be installed in the workspace. Remove it from that workspace's Slack app settings.
* **Revocation unreported:** Slack's answer carried no outcome either way. The integration is gone from Prowler, and the disconnect is reported without any claim about revocation. When certainty matters, check the workspace's Slack app settings and remove the Prowler app if it is still installed.
Prowler reports the outcome it received: a failed revocation always names the manual cleanup step, and an unreported one is never presented as revoked.
<Warning>
Disconnecting cannot be undone. Reconnecting means approving Prowler in Slack again, and the destination channel has to be chosen again.
</Warning>
## Integration Status
The Slack management page reports the state of the connection and offers these actions:
| Button | Purpose | Notes |
|--------|---------|-------|
| **Test connection** | Verify Prowler can reach the workspace and the recorded channel | Checks both the credential and the channel, and updates the last-checked time |
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
| **Save channel** | Record the selected channel as the default destination | Enabled once a channel other than the current default is selected |
| **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
## Troubleshooting
### Slack Is Not Available in This Environment Yet
The Prowler Slack app is not configured for the deployment being used, so no workspace can be connected. This resolves without any action on the tenant's side — the page starts working as soon as the app is configured.
### A Private Channel Does Not Appear in the Picker
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
### Connection Test Fails
* Confirm the recorded channel still exists and has not been archived.
* For a private destination channel, confirm the Prowler app is still a member of it.
* Confirm the Prowler app is still installed in the workspace.
### Prowler's Access Has Been Revoked
When Slack stops accepting the stored credential — because a workspace administrator revoked it, or the app was removed from the workspace — Prowler reports the workspace as disconnected and offers **Reconnect to Slack**. Approving Prowler in Slack again restores access.
### The Connection Check Fails on the Channel
* Confirm the destination channel saved on the integration is the channel being watched in Slack.
* Check the outcome reported on the page: when Slack refuses the channel, the reason Slack gave is shown there — an archived or deleted channel surfaces here rather than failing silently.
* For a private channel, confirm the Prowler app is still a member of it.
@@ -31,6 +31,8 @@
"ec2:GetInstanceMetadataDefaults",
"ecr:Describe*",
"ecr:GetRegistryScanningConfiguration",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"elasticfilesystem:DescribeBackupPolicy",
"glue:GetConnections",
"glue:GetSecurityConfiguration*",
@@ -42,6 +44,7 @@
"lightsail:GetRelationalDatabases",
"macie2:GetMacieSession",
"macie2:GetAutomatedDiscoveryConfiguration",
"rolesanywhere:ListProfiles",
"rolesanywhere:ListTagsForResource",
"rolesanywhere:ListTrustAnchors",
"s3:GetAccountPublicAccessBlock",
@@ -203,6 +203,8 @@ Resources:
- "ec2:GetInstanceMetadataDefaults"
- "ecr:Describe*"
- "ecr:GetRegistryScanningConfiguration"
- "ecr:BatchGetImage"
- "ecr:GetDownloadUrlForLayer"
- "elasticfilesystem:DescribeBackupPolicy"
- "glue:GetConnections"
- "glue:GetSecurityConfiguration*"
@@ -213,6 +215,7 @@ Resources:
- "lightsail:GetRelationalDatabases"
- "macie2:GetMacieSession"
- "macie2:GetAutomatedDiscoveryConfiguration"
- "rolesanywhere:ListProfiles"
- "rolesanywhere:ListTagsForResource"
- "rolesanywhere:ListTrustAnchors"
- "s3:GetAccountPublicAccessBlock"
@@ -469,6 +472,8 @@ Resources:
- "ec2:GetInstanceMetadataDefaults"
- "ecr:Describe*"
- "ecr:GetRegistryScanningConfiguration"
- "ecr:BatchGetImage"
- "ecr:GetDownloadUrlForLayer"
- "elasticfilesystem:DescribeBackupPolicy"
- "glue:GetConnections"
- "glue:GetSecurityConfiguration*"
+37
View File
@@ -4,6 +4,43 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.39.1] (Prowler v5.39.1)
### 🐞 Fixed
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 so the published wheel installs with cryptography 50.0.0; 5.38.0 declared cryptography 50.0.0 while those packages capped it below 50, so pip could not install it and `pip install prowler` silently fell back to 5.37.1 [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
- ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails before container evidence is gathered [(#12478)](https://github.com/prowler-cloud/prowler/pull/12478)
- `ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review [(#12480)](https://github.com/prowler-cloud/prowler/pull/12480)
### 🔐 Security
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the SDK container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
---
## [5.39.0] (Prowler v5.39.0)
### 🚀 Added
- `batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets [(#12117)](https://github.com/prowler-cloud/prowler/pull/12117)
- 7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions [(#12153)](https://github.com/prowler-cloud/prowler/pull/12153)
- 7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls [(#12154)](https://github.com/prowler-cloud/prowler/pull/12154)
- `entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls [(#12155)](https://github.com/prowler-cloud/prowler/pull/12155)
- `awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets [(#12233)](https://github.com/prowler-cloud/prowler/pull/12233)
- CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails [(#12401)](https://github.com/prowler-cloud/prowler/pull/12401)
### 🔄 Changed
- GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high [(#12164)](https://github.com/prowler-cloud/prowler/pull/12164)
### 🔐 Security
- HTML report header now HTML-escapes every provider identity field across all 23 providers, closing a stored XSS in the header block (Secur0, CWE-79) that was left unaddressed by the earlier finding-row fix in #12221 [(#12424)](https://github.com/prowler-cloud/prowler/pull/12424)
---
## [5.38.0] (Prowler v5.38.0)
### 🚀 Added
@@ -1 +0,0 @@
`awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets
@@ -1 +0,0 @@
`batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets
@@ -1 +0,0 @@
CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails
@@ -0,0 +1 @@
`ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets
@@ -0,0 +1 @@
Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals
@@ -1 +0,0 @@
GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high
@@ -1 +0,0 @@
`entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls
@@ -1 +0,0 @@
7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls
@@ -1 +0,0 @@
7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions
@@ -0,0 +1 @@
Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.39.0"
prowler_version = "5.40.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+150 -65
View File
@@ -463,6 +463,11 @@ class HTML(Output):
audited_regions = "All Regions"
else:
audited_regions = ", ".join(provider.identity.audited_regions)
account = escape(str(provider.identity.account))
profile = escape(str(profile))
audited_regions = escape(str(audited_regions))
user_id = escape(str(provider.identity.user_id))
identity_arn = escape(str(provider.identity.identity_arn))
return f"""
<div class="col-md-2">
<div class="card">
@@ -471,7 +476,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>AWS Account:</b> {provider.identity.account}
<b>AWS Account:</b> {account}
</li>
<li class="list-group-item">
<b>AWS-CLI Profile:</b> {profile}
@@ -489,10 +494,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>User Id:</b> {provider.identity.user_id}
<b>User Id:</b> {user_id}
</li>
<li class="list-group-item">
<b>Caller Identity ARN:</b> {provider.identity.identity_arn}
<b>Caller Identity ARN:</b> {identity_arn}
</li>
</ul>
</div>
@@ -530,6 +535,11 @@ class HTML(Output):
)
else:
html_identity = provider.identity.identity_id
tenant_ids = escape(" ".join(provider.identity.tenant_ids))
tenant_domain = escape(str(provider.identity.tenant_domain))
subscriptions = escape(" ".join(printed_subscriptions))
identity_type = escape(str(provider.identity.identity_type))
html_identity = escape(str(html_identity))
return f"""
<div class="col-md-2">
<div class="card">
@@ -538,13 +548,13 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Azure Tenant IDs:</b> {" ".join(provider.identity.tenant_ids)}
<b>Azure Tenant IDs:</b> {tenant_ids}
</li>
<li class="list-group-item">
<b>Azure Tenant Domain:</b> {provider.identity.tenant_domain}
<b>Azure Tenant Domain:</b> {tenant_domain}
</li>
<li class="list-group-item">
<b>Azure Subscriptions:</b> {" ".join(printed_subscriptions)}
<b>Azure Subscriptions:</b> {subscriptions}
</li>
</ul>
</div>
@@ -556,7 +566,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Azure Identity Type:</b> {provider.identity.identity_type}
<b>Azure Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>Azure Identity ID:</b> {html_identity}
@@ -591,6 +601,8 @@ class HTML(Output):
)
except AttributeError:
profile = "default"
project_ids = escape(", ".join(provider.project_ids))
profile = escape(str(profile))
return f"""
<div class="col-md-2">
<div class="card">
@@ -599,7 +611,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>GCP Project IDs:</b> {", ".join(provider.project_ids)}
<b>GCP Project IDs:</b> {project_ids}
</li>
</ul>
</div>
@@ -634,6 +646,8 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
cluster = escape(str(provider.identity.cluster))
context = escape(str(provider.identity.context))
return f"""
<div class="col-md-2">
<div class="card">
@@ -643,7 +657,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Kubernetes Cluster:</b> {provider.identity.cluster}
<b>Kubernetes Cluster:</b> {cluster}
</li>
</ul>
</div>
@@ -656,7 +670,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Kubernetes Context:</b> {provider.identity.context}
<b>Kubernetes Context:</b> {context}
</li>
</ul>
</div>
@@ -679,11 +693,13 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
auth_method = escape(str(provider.auth_method))
if hasattr(provider.identity, "account_name"):
# GithubIdentityInfo (Personal Access Token, OAuth)
account_name = escape(str(provider.identity.account_name))
account_info_items = f"""
<li class="list-group-item">
<b>GitHub account:</b> {provider.identity.account_name}
<b>GitHub account:</b> {account_name}
</li>
"""
# Add email if available
@@ -691,23 +707,27 @@ class HTML(Output):
hasattr(provider.identity, "account_email")
and provider.identity.account_email
):
account_email = escape(str(provider.identity.account_email))
account_info_items += f"""
<li class="list-group-item">
<b>GitHub account email:</b> {provider.identity.account_email}
<b>GitHub account email:</b> {account_email}
</li>"""
elif hasattr(provider.identity, "app_id"):
# GithubAppIdentityInfo (GitHub App)
# Assessment items: App Name and Installations
app_name = escape(str(provider.identity.app_name))
account_info_items = f"""
<li class="list-group-item">
<b>GitHub App Name:</b> {provider.identity.app_name}
<b>GitHub App Name:</b> {app_name}
</li>"""
# Add installations if available
if (
hasattr(provider.identity, "installations")
and provider.identity.installations
):
installations_display = ", ".join(provider.identity.installations)
installations_display = escape(
", ".join(provider.identity.installations)
)
account_info_items += f"""
<li class="list-group-item">
<b>Installations:</b> {installations_display}
@@ -719,26 +739,27 @@ class HTML(Output):
</li>"""
# Credentials items: Authentication method and App ID
app_id = escape(str(provider.identity.app_id))
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>
<li class="list-group-item">
<b>GitHub App ID:</b> {provider.identity.app_id}
<b>GitHub App ID:</b> {app_id}
</li>"""
else:
# Fallback for other identity types
account_info_items = ""
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>"""
# For PAT/OAuth, use default credentials structure
if hasattr(provider.identity, "account_name"):
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>"""
return f"""
@@ -779,6 +800,18 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
tenant_domain = escape(str(provider.identity.tenant_domain))
identity_type = escape(str(provider.identity.identity_type))
identity_id = escape(str(provider.identity.identity_id))
user_item = ""
if (
hasattr(provider.identity, "user")
and provider.identity.user is not None
):
user = escape(str(provider.identity.user))
user_item = f"""<li class="list-group-item">
<b>M365 User:</b> {user}
</li>"""
return f"""
<div class="col-md-2">
<div class="card">
@@ -787,9 +820,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Tenant Domain:</b> {
provider.identity.tenant_domain
}
<b>M365 Tenant Domain:</b> {tenant_domain}
</li>
</ul>
</div>
@@ -801,19 +832,12 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Identity Type:</b> {provider.identity.identity_type}
<b>M365 Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>M365 Identity ID:</b> {provider.identity.identity_id}
<b>M365 Identity ID:</b> {identity_id}
</li>
{
f'''<li class="list-group-item">
<b>M365 User:</b> {provider.identity.user}
</li>'''
if hasattr(provider.identity, "user")
and provider.identity.user is not None
else ""
}
{user_item}
</ul>
</div>
</div>"""
@@ -834,6 +858,9 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
tenant_domain = escape(str(provider.identity.tenant_domain))
identity_type = escape(str(provider.identity.identity_type))
identity_id = escape(str(provider.identity.identity_id))
return f"""
<div class="col-md-2">
<div class="card">
@@ -842,7 +869,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>NHN Tenant Domain:</b> {provider.identity.tenant_domain}
<b>NHN Tenant Domain:</b> {tenant_domain}
</li>
</ul>
</div>
@@ -854,10 +881,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>NHN Identity Type:</b> {provider.identity.identity_type}
<b>NHN Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>NHN Identity ID:</b> {provider.identity.identity_id}
<b>NHN Identity ID:</b> {identity_id}
</li>
</ul>
</div>
@@ -880,6 +907,7 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
organization_name = escape(str(provider.identity.organization_name))
return f"""
<div class="col-md-2">
<div class="card">
@@ -889,7 +917,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>MongoDB Atlas organization:</b> {provider.identity.organization_name}
<b>MongoDB Atlas organization:</b> {organization_name}
</li>
</ul>
</div>
@@ -925,6 +953,13 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
if provider.scan_repository_url:
target_info = "<b>IAC repository URL:</b> " + str(
escape(str(provider.scan_repository_url))
)
else:
target_info = "<b>IAC path:</b> " + str(escape(str(provider.scan_path)))
auth_method = escape(str(provider.auth_method))
return f"""
<div class="col-md-2">
<div class="card">
@@ -934,7 +969,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
{"<b>IAC repository URL:</b> " + provider.scan_repository_url if provider.scan_repository_url else "<b>IAC path:</b> " + provider.scan_path}
{target_info}
</li>
</ul>
</div>
@@ -947,7 +982,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>IAC authentication method:</b> {provider.auth_method}
<b>IAC authentication method:</b> {auth_method}
</li>
</ul>
</div>
@@ -971,10 +1006,13 @@ class HTML(Output):
"""
try:
if provider.registry:
target_info = f"<b>Registry URL:</b> {provider.registry}"
registry = escape(str(provider.registry))
target_info = f"<b>Registry URL:</b> {registry}"
else:
target_info = f'<b>Images:</b> {", ".join(provider.images)}'
images = escape(", ".join(provider.images))
target_info = f"<b>Images:</b> {images}"
auth_method = escape(str(provider.auth_method))
return f"""
<div class="col-md-2">
<div class="card">
@@ -997,7 +1035,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Image authentication method:</b> {provider.auth_method}
<b>Image authentication method:</b> {auth_method}
</li>
</ul>
</div>
@@ -1020,6 +1058,14 @@ class HTML(Output):
str: HTML assessment summary for the LLM provider
"""
try:
model = escape(str(provider.model))
plugins = escape(", ".join(provider.plugins))
max_concurrency = escape(str(provider.max_concurrency))
config_file = escape(
str(provider.config_path)
if provider.config_path
else "Using promptfoo defaults"
)
return f"""
<div class="card">
<div class="card-header">
@@ -1031,16 +1077,16 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Target LLM:</b> {provider.model}
<b>Target LLM:</b> {model}
</li>
<li class="list-group-item">
<b>Plugins:</b> {", ".join(provider.plugins)}
<b>Plugins:</b> {plugins}
</li>
<li class="list-group-item">
<b>Max concurrency:</b> {provider.max_concurrency}
<b>Max concurrency:</b> {max_concurrency}
</li>
<li class="list-group-item">
<b>Config file:</b> {provider.config_path if provider.config_path else "Using promptfoo defaults"}
<b>Config file:</b> {config_file}
</li>
</ul>
</div>
@@ -1069,6 +1115,10 @@ class HTML(Output):
tenancy_name = getattr(provider.identity, "tenancy_name", "unknown")
tenancy_id = getattr(provider.identity, "tenancy_id", "unknown")
tenancy = escape(
str(tenancy_name if tenancy_name != "unknown" else tenancy_id)
)
profile = escape(str(profile))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1077,7 +1127,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>OracleCloud Tenancy:</b> {tenancy_name if tenancy_name != "unknown" else tenancy_id}
<b>OracleCloud Tenancy:</b> {tenancy}
</li>
</ul>
</div>
@@ -1116,10 +1166,11 @@ class HTML(Output):
project_name = getattr(provider.identity, "project_name", "")
audited_regions = getattr(provider.identity, "audited_regions", set())
project_id = escape(str(project_id))
project_name_item = (
f"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
<b>Project Name:</b> {escape(str(project_name))}
</li>"""
if project_name
else ""
@@ -1128,7 +1179,7 @@ class HTML(Output):
regions_item = (
f"""
<li class="list-group-item">
<b>Regions:</b> {", ".join(sorted(audited_regions))}
<b>Regions:</b> {escape(", ".join(sorted(audited_regions)))}
</li>"""
if audited_regions
else ""
@@ -1182,7 +1233,7 @@ class HTML(Output):
# Build assessment summary items (only non-None values)
assessment_items = ""
if provider.accounts:
accounts = ", ".join([acc.id for acc in provider.accounts])
accounts = escape(", ".join([str(acc.id) for acc in provider.accounts]))
assessment_items += f"""
<li class="list-group-item">
<b>Accounts:</b> {accounts}
@@ -1208,6 +1259,7 @@ class HTML(Output):
provider.session, "api_email", None
)
if email:
email = escape(str(email))
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
@@ -1261,11 +1313,15 @@ class HTML(Output):
account_name_item = (
f"""
<li class="list-group-item">
<b>Account Name:</b> {account_name}
<b>Account Name:</b> {escape(str(account_name))}
</li>"""
if account_name
else ""
)
account_id = escape(str(account_id))
audited_regions = escape(str(audited_regions))
user_name = escape(str(user_name))
identity_arn = escape(str(identity_arn))
return f"""
<div class="col-md-2">
@@ -1326,7 +1382,7 @@ class HTML(Output):
project_name_item = (
f"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
<b>Project Name:</b> {escape(str(project_name))}
</li>"""
if project_name
else ""
@@ -1335,11 +1391,14 @@ class HTML(Output):
user_id_item = (
f"""
<li class="list-group-item">
<b>User ID:</b> {user_id}
<b>User ID:</b> {escape(str(user_id))}
</li>"""
if user_id
else ""
)
project_id = escape(str(project_id))
region_name = escape(str(region_name))
username = escape(str(username))
return f"""
<div class="col-md-2">
@@ -1389,6 +1448,9 @@ class HTML(Output):
str: HTML assessment summary for the Google Workspace provider
"""
try:
domain = escape(str(provider.identity.domain))
customer_id = escape(str(provider.identity.customer_id))
delegated_user = escape(str(provider.identity.delegated_user))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1397,10 +1459,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Domain:</b> {provider.identity.domain}
<b>Domain:</b> {domain}
</li>
<li class="list-group-item">
<b>Customer ID:</b> {provider.identity.customer_id}
<b>Customer ID:</b> {customer_id}
</li>
</ul>
</div>
@@ -1412,7 +1474,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Delegated User:</b> {provider.identity.delegated_user}
<b>Delegated User:</b> {delegated_user}
</li>
<li class="list-group-item">
<b>Authentication Method:</b> Service Account with Domain-Wide Delegation
@@ -1482,9 +1544,11 @@ class HTML(Output):
team = getattr(provider.identity, "team", None)
if team:
team_name = escape(str(team.name))
team_id = escape(str(team.id))
assessment_items += f"""
<li class="list-group-item">
<b>Team:</b> {team.name} ({team.id})
<b>Team:</b> {team_name} ({team_id})
</li>"""
credentials_items = """
@@ -1494,6 +1558,7 @@ class HTML(Output):
email = getattr(provider.identity, "email", None)
if email:
email = escape(str(email))
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
@@ -1501,6 +1566,7 @@ class HTML(Output):
username = getattr(provider.identity, "username", None)
if username:
username = escape(str(username))
credentials_items += f"""
<li class="list-group-item">
<b>Username:</b> {username}
@@ -1543,17 +1609,20 @@ class HTML(Output):
str: HTML assessment summary for the Okta provider
"""
try:
org_domain = escape(str(provider.identity.org_domain))
auth_method = escape(str(provider.auth_method))
client_id = escape(str(provider.identity.client_id))
assessment_items = f"""
<li class="list-group-item">
<b>Okta Domain:</b> {provider.identity.org_domain}
<b>Okta Domain:</b> {org_domain}
</li>"""
credentials_items = f"""
<li class="list-group-item">
<b>Authentication:</b> {provider.auth_method}
<b>Authentication:</b> {auth_method}
</li>
<li class="list-group-item">
<b>Client ID:</b> {provider.identity.client_id}
<b>Client ID:</b> {client_id}
</li>"""
return f"""
@@ -1593,9 +1662,10 @@ class HTML(Output):
str: HTML assessment summary for the Scaleway provider
"""
try:
organization_id = escape(str(provider.identity.organization_id))
assessment_items = f"""
<li class="list-group-item">
<b>Organization ID:</b> {provider.identity.organization_id}
<b>Organization ID:</b> {organization_id}
</li>"""
credentials_items = """
@@ -1605,6 +1675,7 @@ class HTML(Output):
access_key = getattr(provider.session, "access_key", None)
if access_key:
access_key = escape(str(access_key))
credentials_items += f"""
<li class="list-group-item">
<b>Access Key:</b> {access_key}
@@ -1615,6 +1686,8 @@ class HTML(Output):
bearer_id = getattr(provider.identity, "bearer_id", None)
if bearer_type:
bearer_label = bearer_email or bearer_id or "-"
bearer_type = escape(str(bearer_type))
bearer_label = escape(str(bearer_label))
credentials_items += f"""
<li class="list-group-item">
<b>Bearer:</b> {bearer_type} ({bearer_label})
@@ -1622,6 +1695,7 @@ class HTML(Output):
region = getattr(provider.session, "default_region", None)
if region:
region = escape(str(region))
credentials_items += f"""
<li class="list-group-item">
<b>Default Region:</b> {region}
@@ -1668,6 +1742,9 @@ class HTML(Output):
email = getattr(provider.identity, "email", None) or "-"
account_id = getattr(provider.identity, "account_id", None) or "-"
username = escape(str(username))
email = escape(str(email))
account_id = escape(str(account_id))
assessment_items = f"""
<li class="list-group-item">
<b>Account ID:</b> {account_id}
@@ -1732,6 +1809,14 @@ class HTML(Output):
audited_regions = "All Regions"
else:
audited_regions = ", ".join(provider.identity.regions)
account_id = escape(str(provider.identity.account_id))
account_name = escape(str(provider.identity.account_name))
profile = escape(str(profile))
audited_regions = escape(str(audited_regions))
domain_id = escape(str(provider.identity.domain_id))
user_id = escape(str(provider.identity.user_id))
user_name = escape(str(provider.identity.user_name))
identity_type = escape(str(provider.identity.identity_type))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1740,10 +1825,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Account ID:</b> {provider.identity.account_id}
<b>Account ID:</b> {account_id}
</li>
<li class="list-group-item">
<b>Account Name:</b> {provider.identity.account_name}
<b>Account Name:</b> {account_name}
</li>
<li class="list-group-item">
<b>Profile:</b> {profile}
@@ -1761,16 +1846,16 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Domain ID:</b> {provider.identity.domain_id}
<b>Domain ID:</b> {domain_id}
</li>
<li class="list-group-item">
<b>User ID:</b> {provider.identity.user_id}
<b>User ID:</b> {user_id}
</li>
<li class="list-group-item">
<b>User Name:</b> {provider.identity.user_name}
<b>User Name:</b> {user_name}
</li>
<li class="list-group-item">
<b>Identity Type:</b> {provider.identity.identity_type}
<b>Identity Type:</b> {identity_type}
</li>
</ul>
</div>
@@ -0,0 +1,42 @@
{
"Provider": "aws",
"CheckID": "ecr_repository_image_no_secrets",
"CheckTitle": "ECR repository image contains no hardcoded secrets",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Sensitive Data Identifications/Passwords",
"Effects/Data Exposure"
],
"ServiceName": "ecr",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsEcrRepository",
"ResourceGroup": "container",
"Description": "The **latest image** pushed to each **Amazon ECR repository** is analyzed for **embedded secrets**: environment variables and build history (Dockerfile instructions) recorded in the image configuration, plus the file contents of every filesystem layer. Findings reference the variable, build step, or file, never the secret value.",
"Risk": "Anyone able to pull the image obtains any **credentials, tokens, or keys** embedded at build time via `ENV`, `ARG`, inline `RUN` commands, or files copied into the image (e.g. `COPY .env .`).\n\nLeaked credentials enable unauthorized access to databases, APIs, or cloud resources, and rotation is harder once secrets are baked into distributed image artifacts.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/AmazonECR/latest/userguide/security-best-practices.html",
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html",
"https://docs.docker.com/build/building/secrets/"
],
"Remediation": {
"Code": {
"CLI": "aws ecr batch-delete-image --repository-name <repository-name> --image-ids imageDigest=<image-digest>",
"NativeIaC": "",
"Other": "1. Remove the secret from the Dockerfile (ENV/ARG/RUN) or from any file copied into the build context, and rebuild the image without it.\n2. Provide the secret at runtime instead: reference AWS Secrets Manager or SSM Parameter Store from your ECS task definition, EKS pod (Secrets Store CSI driver), or application code.\n3. Push the rebuilt image and delete the compromised image versions from the repository.\n4. Rotate the exposed credential immediately.",
"Terraform": ""
},
"Recommendation": {
"Text": "Never bake secrets into images with `ENV`, `ARG`, inline `RUN` commands, or copied files. Use **BuildKit build secrets** (`--mount=type=secret`) at build time and **AWS Secrets Manager**/Parameter Store at runtime. Add secret scanning to CI/CD before pushing images.",
"Url": "https://hub.prowler.com/check/ecr_repository_image_no_secrets"
}
},
"Categories": [
"secrets"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Only the most recently pushed image in each repository is scanned; older tagged images are not scanned. The latest scannable image is evaluated in every repository regardless of whether scan-on-push is enabled. The scanned image's configuration (environment variables and build history) plus every filesystem layer's file contents are analyzed. A multi-architecture image resolves to a single platform's manifest; other architectures in the same manifest list are not scanned. To bound cost, a single layer over 100 MB (compressed) is not downloaded, an individual file over 1 MB is not scanned, and scanning of an image stops after 5000 files or 500 MB (decompressed). When part of an image cannot be scanned this way, a clean result is reported as MANUAL (coverage was incomplete) rather than PASS, and a FAIL still discloses that some content was skipped. Requires the ecr:BatchGetImage and ecr:GetDownloadUrlForLayer permissions in addition to SecurityAudit."
}
@@ -0,0 +1,244 @@
import re
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.utils.utils import (
SecretsScanError,
annotate_verified_secrets,
detect_secrets_scan_batch,
)
from prowler.providers.aws.services.ecr.ecr_client import ecr_client
_SAFE_ENVIRONMENT_VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
class ecr_repository_image_no_secrets(Check):
"""Ensure the latest ECR repository image embeds no hardcoded secrets.
The most recently pushed image in every ECR repository is resolved to a
single scannable manifest (a multi-arch image resolves to one platform's
manifest; other architectures in the same manifest list are not
scanned) and scanned for plaintext secrets in its configuration
(environment variables, build history) and every filesystem layer's
file contents. Older tagged images are not scanned.
- PASS: no secrets detected and the whole image was scanned.
- FAIL: a potential secret was detected; the variable, build step, or
file is reported, never the secret value.
- MANUAL: the image could not be scanned in full, so a clean result would
be misleading -- the manifest could not be retrieved or resolved, the
scan itself failed, or part of the image exceeded configured size limits
or could not be retrieved.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
secrets_ignore_patterns = ecr_client.audit_config.get(
"secrets_ignore_patterns", []
)
validate = ecr_client.audit_config.get("secrets_validate", False)
# Phase 1: collect. The service yields (repository, image, scan_data)
# lazily, downloading each image's manifest, config, and layers; each
# image contributes an env/history/file payload per scannable unit so
# a finding's key maps back to a variable, build step, or file.
scanned = []
def image_payloads():
"""Yield keyed scan payloads, recording each image into `scanned`."""
for repository, image, scan_data in ecr_client._get_image_scan_data():
index = len(scanned)
scanned.append((repository, image, scan_data))
if scan_data is None or isinstance(scan_data, Exception):
continue
for env_index, entry in enumerate(scan_data.env):
yield (index, f"environment:{env_index}"), entry
for history_index, entry in enumerate(scan_data.history):
yield (index, f"history:{history_index}"), entry
for file_index, scanned_file in enumerate(scan_data.files):
yield (index, f"file:{file_index}"), scanned_file.content
# Free the file's contents once handed to the scanner. The
# report phase needs only its path and layer digest, so
# retained memory stays flat instead of growing with the
# number of repositories scanned.
scanned_file.content = ""
# Phase 2: batch — one call, chunked Kingfisher subprocesses. This
# must fully consume image_payloads() so every image is appended to
# `scanned` before Phase 3 runs; detect_secrets_scan_batch does so
# today, but a future short-circuit there would silently drop images
# from the report loop.
scan_error = None
try:
batch_results = detect_secrets_scan_batch(
image_payloads(),
excluded_secrets=secrets_ignore_patterns,
validate=validate,
)
except SecretsScanError as error:
batch_results = {}
scan_error = error
if scan_error:
# The scan failed and the payload generator may not have been
# consumed, so build the MANUAL reports from the repositories
# themselves rather than risk a false PASS or a missing finding.
for registry in ecr_client.registries.values():
for repository in registry.repositories:
image = ecr_client._get_scan_target_image(repository)
if isinstance(image, Exception):
findings.append(
self._build_scan_error_report(repository, image)
)
elif image is not None:
report = self._build_report(repository, image)
report.status = "MANUAL"
report.status_extended = (
f"Could not scan image '{image.latest_tag}' "
f"({image.latest_digest}) of ECR repository "
f"{repository.name} for secrets: {scan_error}; "
f"manual review is required."
)
findings.append(report)
return findings
# Phase 3: report — one finding per scanned image.
for index, (repository, image, scan_data) in enumerate(scanned):
if isinstance(scan_data, Exception):
findings.append(self._build_scan_error_report(repository, scan_data))
continue
report = self._build_report(repository, image)
image_reference = (
f"image '{image.latest_tag}' ({image.latest_digest}) of ECR "
f"repository {repository.name}"
)
if scan_data is None:
report.status = "MANUAL"
report.status_extended = (
f"Could not resolve or retrieve the manifest of the "
f"{image_reference} to scan it for secrets; manual "
f"review is required."
)
findings.append(report)
continue
env_findings_by_index = {
int(key[1].split(":", 1)[1]): entry_secrets
for key, entry_secrets in batch_results.items()
if key[0] == index and key[1].startswith("environment:")
}
history_findings_by_index = {
int(key[1].split(":", 1)[1]): entry_secrets
for key, entry_secrets in batch_results.items()
if key[0] == index and key[1].startswith("history:")
}
file_findings_by_index = {
int(key[1].split(":", 1)[1]): file_secrets
for key, file_secrets in batch_results.items()
if key[0] == index and key[1].startswith("file:")
}
if (
env_findings_by_index
or history_findings_by_index
or file_findings_by_index
):
secrets_found = []
all_secrets = []
for env_index, env_findings in env_findings_by_index.items():
variable = None
if 0 <= env_index < len(scan_data.env):
entry = scan_data.env[env_index]
# Only a well-formed "NAME=value" entry has a name safe
# to report; an entry with no "=" may itself be the
# secret, so it is never echoed back.
if "=" in entry:
candidate = entry.split("=", 1)[0]
if _SAFE_ENVIRONMENT_VARIABLE_NAME.fullmatch(candidate):
variable = candidate
all_secrets.extend(env_findings)
for secret in env_findings:
if variable is not None:
secrets_found.append(
f"{secret['type']} in environment variable {variable}"
)
else:
secrets_found.append(
f"{secret['type']} in image environment variables"
)
for (
history_index,
history_findings,
) in history_findings_by_index.items():
all_secrets.extend(history_findings)
for secret in history_findings:
secrets_found.append(
f"{secret['type']} in image history step {history_index + 1}"
)
for file_index, file_secrets in file_findings_by_index.items():
scanned_file = scan_data.files[file_index]
all_secrets.extend(file_secrets)
for secret in file_secrets:
secrets_found.append(
f"{secret['type']} in file {scanned_file.path} "
f"(layer {scanned_file.layer_digest})"
)
report.status = "FAIL"
report.status_extended = (
f"Potential {'secrets' if len(secrets_found) > 1 else 'secret'} "
f"found in the {image_reference} -> {', '.join(secrets_found)}."
)
if scan_data.truncated:
report.status_extended += (
" Some of the image could not be retrieved or exceeded "
"configured size limits and was not scanned."
)
annotate_verified_secrets(report, all_secrets)
elif scan_data.truncated:
# No secrets in what was scanned, but coverage was incomplete
# (size/count limits, or the config could not be retrieved), so
# a clean result would be misleading.
report.status = "MANUAL"
report.status_extended = (
f"No secrets were found in the scanned portion of the "
f"{image_reference}, but part of it could not be retrieved "
f"or exceeded configured size limits and was not scanned; "
f"manual review is required."
)
else:
report.status = "PASS"
report.status_extended = f"No secrets found in the {image_reference}."
findings.append(report)
return findings
def _build_scan_error_report(self, repository, error) -> Check_Report_AWS:
"""Build a repository-level report for a latest-image lookup failure."""
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
report.status = "MANUAL"
report.status_extended = (
f"Could not determine the latest image of ECR repository "
f"{repository.name}: {error}; manual review is required."
)
return report
def _build_report(self, repository, image) -> Check_Report_AWS:
"""Build a report scoped to a single image within a repository.
ECR images have no ARN of their own, so the repository's ARN is
reused with the image digest appended as a synthetic suffix,
mirroring how other sub-resource checks (e.g. CodeArtifact packages
within a repository) identify per-item findings.
"""
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
digest_short = image.latest_digest.split(":")[-1][:12]
report.resource_id = f"{repository.name}:{image.latest_tag}@{digest_short}"
report.resource_arn = f"{repository.arn}/image/{digest_short}"
return report
@@ -1,3 +1,4 @@
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
from datetime import datetime
from json import loads
from typing import Optional
@@ -8,10 +9,21 @@ from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
from prowler.providers.aws.lib.service.service import AWSService
from prowler.providers.aws.services.ecr.image_inspection import ImageInspector
# Concurrency for the image-scan pipeline (_get_image_scan_data). Kept smaller
# than the shared MAX_WORKERS metadata pool because each task can retain up to
# MAX_LAYER_DOWNLOAD_BYTES compressed plus MAX_TOTAL_BYTES_PER_IMAGE decompressed
# content (see image_inspection), so a high worker count would multiply peak
# memory into several GB.
IMAGE_SCAN_MAX_WORKERS = 4
class ECR(AWSService):
"""AWS Elastic Container Registry service."""
def __init__(self, provider):
"""Discover registries, repositories, policies, and image metadata."""
# Call AWSService's __init__
super().__init__(__class__.__name__, provider)
self.registry_id = self.audited_account
@@ -24,6 +36,7 @@ class ECR(AWSService):
self.__threading_call__(self._list_tags_for_resource)
def _describe_registries_and_repositories(self, regional_client):
"""Populate the registry and its repositories for one region."""
logger.info("ECR - Describing registries and repositories...")
regional_registry_repositories = []
try:
@@ -68,6 +81,7 @@ class ECR(AWSService):
)
def _describe_repository_policies(self, regional_client):
"""Fetch and attach each repository's resource policy, if any."""
logger.info("ECR - Describing repository policies...")
try:
if regional_client.region in self.registries:
@@ -96,6 +110,7 @@ class ECR(AWSService):
)
def _get_repository_lifecycle_policy(self, regional_client):
"""Fetch and attach each repository's lifecycle policy, if any."""
logger.info("ECR - Getting repository lifecycle policy...")
try:
if regional_client.region in self.registries:
@@ -124,6 +139,7 @@ class ECR(AWSService):
)
def _get_image_details(self, regional_client):
"""Populate each scan-on-push repository's scannable, tagged images."""
logger.info("ECR - Getting images details...")
try:
if regional_client.region in self.registries:
@@ -158,12 +174,7 @@ class ECR(AWSService):
image_scan_findings_field_name = (
"imageScanFindingsSummary"
)
if "docker" in artifact_media_type:
type = "Docker"
elif "oci" in artifact_media_type:
type = "OCI"
else:
type = ""
type = ECR._artifact_type(artifact_media_type)
# If imageScanStatus is not present or imageScanFindingsSummary is missing,
# we need to call DescribeImageScanFindings because AWS' new version of
@@ -252,6 +263,7 @@ class ECR(AWSService):
)
def _list_tags_for_resource(self, regional_client):
"""Fetch and attach each repository's resource tags."""
logger.info("ECR - List Tags...")
try:
if regional_client.region in self.registries:
@@ -280,6 +292,7 @@ class ECR(AWSService):
)
def _get_registry_scanning_configuration(self, regional_client):
"""Fetch and attach the registry's image-scanning configuration."""
logger.info("ECR - Getting Registry Scanning Configuration...")
try:
if regional_client.region in self.registries:
@@ -315,6 +328,155 @@ class ECR(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_image_scan_data(self):
"""Lazily fetch manifest, config, and layer file contents for the latest image.
Only the most recently pushed scannable image in each repository is
scanned (resolved via _get_scan_target_image, which also covers
scan-on-push-disabled repositories) to bound cost on repositories
with many tags.
Not called from __init__: this is only invoked by the
ecr_repository_image_no_secrets check, since it downloads and
decompresses image layers and is significantly more expensive than
the metadata gathered above. A dedicated, smaller thread pool bounds
the concurrency (and therefore the peak memory) of this heavy
pipeline independently of the shared metadata pool.
Yields:
Tuple of repository, optional image, and scan data. The third item
is an exception when the authoritative image lookup failed.
"""
logger.info("ECR - Fetching image manifests, configs, and layers...")
inspector = ImageInspector()
def images_to_fetch():
for registry in self.registries.values():
for repository in registry.repositories:
image = self._get_scan_target_image(repository)
if isinstance(image, Exception):
yield repository, None, image
elif image is not None:
yield repository, image, None
with ThreadPoolExecutor(max_workers=IMAGE_SCAN_MAX_WORKERS) as executor:
pending = {}
targets = iter(images_to_fetch())
def submit_next():
try:
repository, image, error = next(targets)
except StopIteration:
return False
if error:
future = Future()
future.set_result(error)
else:
client = self.regional_clients[repository.region]
registry_id = self.registries[repository.region].id
future = executor.submit(
inspector.fetch_image_scan_data,
client,
registry_id,
repository.name,
image.latest_digest,
)
pending[future] = (repository, image)
return True
for _ in range(IMAGE_SCAN_MAX_WORKERS):
if not submit_next():
break
while pending:
completed, _ = wait(pending, return_when=FIRST_COMPLETED)
for future in completed:
repository, image = pending.pop(future)
scan_data = None
try:
scan_data = future.result()
except Exception as error:
logger.error(
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
yield repository, image, scan_data
submit_next()
@staticmethod
def _artifact_type(artifact_media_type: Optional[str]) -> str:
"""Map an image's artifact media type to a short image type label.
Returns:
"Docker", "OCI", or "" for an unrecognized/absent media type.
"""
if artifact_media_type:
if "docker" in artifact_media_type:
return "Docker"
if "oci" in artifact_media_type:
return "OCI"
return ""
def _get_scan_target_image(self, repository) -> Optional["ImageDetails"]:
"""Resolve the latest scannable image to scan for secrets.
Secret scanning is independent of ECR's vulnerability scanning
configuration, but `_get_image_details` only populates
`images_details` for scan-on-push-enabled repositories. For a
repository with scan-on-push disabled (empty `images_details`), this
performs a dedicated `describe_images` lookup to find the most
recently pushed scannable image, so those repositories are not
silently skipped.
The synthesized ImageDetails is deliberately NOT appended to
`repository.images_details`: other checks (e.g.
ecr_repositories_scan_vulnerabilities_in_latest_image) treat any
entry there as a scanned image and would FAIL scan-on-push-disabled
repositories that currently produce no finding.
Returns:
The latest scannable ImageDetails, or None if the repository has
no scannable image; an exception if the lookup failed.
"""
latest = repository.images_details[-1] if repository.images_details else None
try:
client = self.regional_clients[repository.region]
describe_images_paginator = client.get_paginator("describe_images")
for page in describe_images_paginator.paginate(
registryId=self.registries[repository.region].id,
repositoryName=repository.name,
PaginationConfig={"PageSize": 1000},
):
for image in page["imageDetails"]:
if image is None:
continue
artifact_media_type = image.get("artifactMediaType", None)
tags = image.get("imageTags", [])
if not ECR._is_artifact_scannable(artifact_media_type, tags):
continue
image_pushed_at = image.get("imagePushedAt")
if image_pushed_at is None:
continue
# Match _get_image_details' "sort ascending, take last"
# selection: on equal push dates the later-listed image
# wins, so `<` (not `<=`) is used to replace on ties.
if latest is not None and image_pushed_at < latest.image_pushed_at:
continue
latest = ImageDetails(
latest_tag=image.get("imageTags", ["None"])[0],
image_pushed_at=image_pushed_at,
latest_digest=image.get("imageDigest"),
scan_findings_status=None,
scan_findings_severity_count=None,
artifact_media_type=artifact_media_type,
type=ECR._artifact_type(artifact_media_type),
)
return latest
except Exception as error:
logger.error(
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return error
@staticmethod
def _is_artifact_scannable(artifact_media_type: str, tags: list[str] = []) -> bool:
"""
@@ -355,12 +517,16 @@ class ECR(AWSService):
class FindingSeverityCounts(BaseModel):
"""Count of an image's vulnerability scan findings by severity."""
critical: int
high: int
medium: int
class ImageDetails(BaseModel):
"""A single scannable, tagged image within an ECR repository."""
latest_tag: str
latest_digest: str
image_pushed_at: datetime
@@ -371,6 +537,8 @@ class ImageDetails(BaseModel):
class Repository(BaseModel):
"""An ECR repository and its policies, images, and tags."""
name: str
arn: str
region: str
@@ -384,11 +552,15 @@ class Repository(BaseModel):
class ScanningRule(BaseModel):
"""A registry-level image-scanning rule and its repository filters."""
scan_frequency: str
scan_filters: list[dict]
class Registry(BaseModel):
"""An ECR registry: its repositories and scanning configuration."""
id: str
arn: str
region: str
@@ -0,0 +1,494 @@
import gzip
import tarfile
from contextlib import contextmanager
from json import loads
from typing import Optional
import requests
import zstandard
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
# Manifest media types that wrap several per-architecture manifests (a "fat
# manifest") rather than a single scannable image.
_MANIFEST_LIST_MEDIA_TYPES = {
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.index.v1+json",
}
# Compressed size of a single layer, checked against the manifest-declared
# size before downloading, and re-checked against actual bytes received.
MAX_LAYER_DOWNLOAD_BYTES = 100 * 1024 * 1024
# Size of a single extracted file considered for scanning.
MAX_FILE_BYTES = 1 * 1024 * 1024
# Hard cap on the number of files scanned per image, across all its layers.
MAX_FILES_PER_IMAGE = 5000
# Hard cap on total decompressed bytes read per image, across all its layers.
MAX_TOTAL_BYTES_PER_IMAGE = 500 * 1024 * 1024
LAYER_DOWNLOAD_TIMEOUT_SECONDS = 30
class _LayerTooLargeError(Exception):
"""Raised when a streamed layer exceeds MAX_LAYER_DOWNLOAD_BYTES."""
class _ImageTooLargeError(Exception):
"""Raised when decompressed image streams exceed their shared budget."""
class _CappedLayerReader:
"""A minimal read-only file object that caps the bytes it will yield.
Wraps a streaming HTTP body (urllib3's ``response.raw``) so ``tarfile`` can
read a gzip/uncompressed layer incrementally while enforcing an upper bound
on the compressed bytes consumed. A manifest that under-declares a layer's
size (the declared size is pre-checked separately) cannot make this buffer
an unbounded amount of untrusted data: once ``max_bytes`` is exceeded the
read raises ``_LayerTooLargeError`` instead of continuing.
"""
def __init__(self, raw, max_bytes: int):
"""Store the underlying raw stream and the remaining byte budget."""
self._raw = raw
self._remaining = max_bytes
def read(self, size: int = -1) -> bytes:
"""Read up to ``size`` bytes, never exceeding the remaining budget.
A negative/None ``size`` (``read all``) is treated as "read what's left
of the budget, plus one" so a lying stream can never pull an unbounded
amount into memory and an over-cap layer is still detected.
"""
if size is None or size < 0:
size = self._remaining + 1
to_read = min(size, self._remaining + 1)
chunk = self._raw.read(to_read)
self._remaining -= len(chunk)
if self._remaining < 0:
raise _LayerTooLargeError()
return chunk
class _DecompressedByteBudget:
"""Track every decompressed byte consumed across an image's tar streams."""
def __init__(self, max_bytes: int):
"""Set the shared decompressed-byte allowance."""
self.remaining = max_bytes
def wrap(self, raw):
"""Return a reader that charges bytes consumed from ``raw``."""
return _BudgetedReader(raw, self)
class _BudgetedReader:
"""Charge all stream reads against a shared decompressed-byte budget."""
def __init__(self, raw, budget: _DecompressedByteBudget):
self._raw = raw
self._budget = budget
def read(self, size: int = -1) -> bytes:
"""Read without allowing the shared budget to be exceeded."""
if size is None or size < 0:
size = self._budget.remaining + 1
chunk = self._raw.read(min(size, self._budget.remaining + 1))
self._budget.remaining -= len(chunk)
if self._budget.remaining < 0:
raise _ImageTooLargeError()
return chunk
class ImageScanFile(BaseModel):
"""A single file extracted from an image layer for secret scanning."""
path: str
layer_digest: str
content: str
class ImageScanData(BaseModel):
"""An image's scannable content: config env/history and layer files."""
env: list[str] = []
history: list[str] = []
files: list[ImageScanFile] = []
# True when part of the image was not scanned -- a layer/file exceeded a
# configured size or count limit, or the config blob could not be
# retrieved/parsed -- so a clean result can be reported as MANUAL
# (coverage incomplete) rather than a false PASS.
truncated: bool = False
class ImageInspector:
"""Bounded, opt-in extraction of an ECR image's scannable content.
Given a boto3 ECR client and an image digest, resolves the image's
manifest (handling multi-arch manifest lists and skipping attestation
manifests) and returns its configuration (environment variables, build
history) and every filesystem layer's file contents, subject to this
module's size and count limits.
This is deliberately isolated from the ECR service so a future check can
reuse the bounded extraction without the service downloading and
decompressing image layers by default: the service only pays this cost
when a check explicitly drives the inspector.
"""
def fetch_image_scan_data(
self, client, registry_id, repository_name, image_digest
) -> Optional[ImageScanData]:
"""Resolve one image's manifest and return its scannable content.
Downloads the config blob (environment variables, build history)
and every filesystem layer's file contents, bounded by the module's
size/count limits.
Returns:
An ImageScanData, or None if the manifest could not be resolved.
"""
manifest, truncated = self._resolve_image_manifest(
client, registry_id, repository_name, image_digest
)
if manifest is None:
return None
env = []
history = []
config_digest = (manifest.get("config") or {}).get("digest")
if config_digest:
config_bytes = self._download_layer(
client,
registry_id,
repository_name,
config_digest,
max_bytes=MAX_FILE_BYTES,
)
if config_bytes is None:
# The config blob (env vars, build history) could not be
# retrieved. Empty env/history would be indistinguishable
# from a clean config, so mark coverage incomplete instead
# of risking a false PASS.
truncated = True
else:
try:
config_json = loads(config_bytes)
env = config_json.get("config", {}).get("Env", []) or []
history = [
step.get("created_by", "")
for step in config_json.get("history", [])
if step.get("created_by")
]
except Exception as error:
logger.warning(
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
truncated = True
files = []
decompressed_budget = _DecompressedByteBudget(MAX_TOTAL_BYTES_PER_IMAGE)
for layer in manifest.get("layers", []):
if len(files) >= MAX_FILES_PER_IMAGE or decompressed_budget.remaining <= 0:
truncated = True
break
layer_digest = layer.get("digest")
layer_size = layer.get("size", 0)
if layer_size and layer_size > MAX_LAYER_DOWNLOAD_BYTES:
truncated = True
continue
try:
with self._open_layer_tar_stream(
client,
registry_id,
repository_name,
layer_digest,
layer.get("mediaType", ""),
decompressed_budget,
) as tar_stream:
if tar_stream is None:
truncated = True
continue
for member in tar_stream:
if len(files) >= MAX_FILES_PER_IMAGE:
truncated = True
break
if not member.isfile():
continue
base_name = member.name.rsplit("/", 1)[-1]
if base_name.startswith(".wh."):
# Whiteout marker: a deletion recorded by the union
# filesystem, not real file content.
continue
if member.size > MAX_FILE_BYTES:
truncated = True
continue
try:
content = (
tar_stream.extractfile(member).read().decode("latin-1")
)
except _LayerTooLargeError:
# Over-cap while reading this member: truncate the
# whole layer rather than silently skipping one file.
raise
except Exception:
continue
files.append(
ImageScanFile(
path=member.name,
layer_digest=layer_digest,
content=content,
)
)
except _LayerTooLargeError:
# The layer streamed more bytes than MAX_LAYER_DOWNLOAD_BYTES
# (a manifest under-declaring its size); skip it and disclose
# the partial coverage rather than buffer unbounded data.
truncated = True
continue
except _ImageTooLargeError:
truncated = True
break
return ImageScanData(env=env, history=history, files=files, truncated=truncated)
def _resolve_image_manifest(
self, client, registry_id, repository_name, image_digest
) -> tuple[Optional[dict], bool]:
"""Resolve an image digest to a single scannable image manifest.
Multi-arch images are stored as a manifest list/image index pointing
at one manifest per platform (plus, often, an attestation manifest
that isn't a real image). This picks one real platform manifest to
scan; the other architectures in the same list are not scanned.
"""
try:
manifest, media_type = self._batch_get_manifest(
client, registry_id, repository_name, image_digest
)
if manifest is None:
return None, False
truncated = False
if media_type in _MANIFEST_LIST_MEDIA_TYPES:
truncated = True
child_digest = self._select_child_manifest_digest(manifest)
if not child_digest:
return None, truncated
manifest, _ = self._batch_get_manifest(
client, registry_id, repository_name, child_digest
)
if manifest is not None and not (
manifest.get("config") or manifest.get("layers")
):
# A resolved manifest with neither a config nor layers has
# nothing to scan (e.g. a nested manifest list, or an
# unsupported manifest shape) -- treat it as unresolvable so
# the caller reports MANUAL instead of a false PASS.
return None, truncated
return manifest, truncated
except Exception as error:
logger.error(
f"{client.meta.region_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None, False
@staticmethod
def _batch_get_manifest(client, registry_id, repository_name, image_digest):
"""Fetch and parse the raw manifest JSON for a single image digest.
Returns:
A (manifest, media_type) tuple, or (None, None) if not found.
"""
response = client.batch_get_image(
registryId=registry_id,
repositoryName=repository_name,
imageIds=[{"imageDigest": image_digest}],
)
images = response.get("images", [])
if not images:
return None, None
manifest = loads(images[0]["imageManifest"])
media_type = manifest.get("mediaType") or images[0].get(
"imageManifestMediaType"
)
return manifest, media_type
@staticmethod
def _select_child_manifest_digest(manifest_list: dict) -> Optional[str]:
"""Pick one real platform manifest's digest from a manifest list.
Prefers linux/amd64, falling back to the first remaining candidate
once attestation manifests (platform "unknown/unknown", or
annotated as an attestation manifest) are excluded.
Returns:
The chosen manifest's digest, or None if no candidate remains.
"""
candidates = []
for entry in manifest_list.get("manifests", []):
platform = entry.get("platform", {}) or {}
annotations = entry.get("annotations", {}) or {}
if (
platform.get("architecture") == "unknown"
or platform.get("os") == "unknown"
):
# Attestation manifests (SBOMs, provenance, signatures) are
# attached to the index as "unknown/unknown" platform entries.
continue
if annotations.get("vnd.docker.reference.type") == "attestation-manifest":
continue
candidates.append(entry)
for entry in candidates:
platform = entry.get("platform", {}) or {}
if (
platform.get("architecture") == "amd64"
and platform.get("os") == "linux"
):
return entry.get("digest")
return candidates[0].get("digest") if candidates else None
@staticmethod
def _get_layer_download_url(
client, registry_id, repository_name, layer_digest
) -> Optional[str]:
"""Resolve the presigned download URL for one layer or config blob.
Returns:
The presigned URL, or None if ECR did not return one.
"""
response = client.get_download_url_for_layer(
registryId=registry_id,
repositoryName=repository_name,
layerDigest=layer_digest,
)
return response.get("downloadUrl")
@staticmethod
def _download_layer(
client, registry_id, repository_name, layer_digest, max_bytes=None
) -> Optional[bytes]:
"""Download one layer or config blob via its presigned URL.
Streams the response, aborting once `max_bytes` is exceeded, so a
lying or oversized blob is never buffered in full. Used for the config
blob and for zstd layers (which cannot be streamed into tarfile);
gzip/uncompressed layers are streamed by `_open_layer_tar_stream`.
Returns:
The blob's bytes, or None if it could not be downloaded or
exceeded `max_bytes`.
"""
try:
download_url = ImageInspector._get_layer_download_url(
client, registry_id, repository_name, layer_digest
)
if not download_url:
return None
downloaded = bytearray()
with requests.get(
download_url,
stream=True,
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
allow_redirects=False,
) as http_response:
http_response.raise_for_status()
for chunk in http_response.iter_content(chunk_size=1024 * 1024):
downloaded.extend(chunk)
if max_bytes and len(downloaded) > max_bytes:
return None
return bytes(downloaded)
except Exception as error:
logger.warning(
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
@contextmanager
def _open_layer_tar_stream(
self,
client,
registry_id,
repository_name,
layer_digest,
media_type: str,
decompressed_budget: _DecompressedByteBudget,
):
"""Yield an open TarFile for one layer, streamed from the download.
gzip, zstd, and uncompressed tar layers are all streamed straight from
the download into `tarfile` (streaming mode reads a file-like object
sequentially), so neither the compressed blob nor a decompressed copy is
ever buffered in full. A `_CappedLayerReader` enforces
`MAX_LAYER_DOWNLOAD_BYTES` on the compressed bytes (guarding a manifest
that under-declares the layer size); zstd is decompressed incrementally
via `zstandard`'s streaming reader, so a crafted frame can no longer
expand unbounded in memory, and the decompressed side is bounded by the
caller's per-image budget as it iterates members.
Yields:
An open TarFile, or None for an unrecognized media type or a
download/decompression failure. Raises `_LayerTooLargeError` if a
streamed layer's compressed bytes exceed `MAX_LAYER_DOWNLOAD_BYTES`.
"""
if media_type.endswith("gzip"):
decompress = "gzip"
elif media_type.endswith("zstd"):
decompress = "zstd"
elif media_type.endswith("tar"):
decompress = None
else:
yield None
return
# Only the setup (URL resolution, connection, tar-header parse) is
# guarded here; a failure yields None. The `yield tar_stream` below is
# kept out of this try so exceptions raised while the caller iterates
# members (e.g. _LayerTooLargeError) propagate instead of triggering a
# forbidden second yield.
try:
download_url = ImageInspector._get_layer_download_url(
client, registry_id, repository_name, layer_digest
)
if not download_url:
yield None
return
http_response = requests.get(
download_url,
stream=True,
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
allow_redirects=False,
)
try:
http_response.raise_for_status()
# Cap the compressed bytes read from the network; for zstd,
# decompress that capped stream incrementally so the decompressed
# data is never materialized in full.
source = _CappedLayerReader(http_response.raw, MAX_LAYER_DOWNLOAD_BYTES)
if decompress == "gzip":
source = gzip.GzipFile(fileobj=source)
elif decompress == "zstd":
source = zstandard.ZstdDecompressor().stream_reader(source)
source = decompressed_budget.wrap(source)
tar_stream = tarfile.open(fileobj=source, mode="r|")
except (_LayerTooLargeError, _ImageTooLargeError):
http_response.close()
raise
except Exception:
http_response.close()
raise
except Exception as error:
logger.warning(
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
yield None
return
with http_response, tar_stream:
yield tar_stream
@@ -149,8 +149,8 @@ class ECS(AWSService):
"TAGS",
],
)
container_definitions = response["taskDefinition"]["containerDefinitions"]
for container in container_definitions:
container_definitions = []
for container in response["taskDefinition"]["containerDefinitions"]:
environment = []
if "environment" in container:
for env_var in container["environment"]:
@@ -159,7 +159,7 @@ class ECS(AWSService):
name=env_var["name"], value=env_var["value"]
)
)
task_definition.container_definitions.append(
container_definitions.append(
ContainerDefinition(
name=container["name"],
privileged=container.get("privileged", False),
@@ -176,14 +176,16 @@ class ECS(AWSService):
.get("mode", ""),
)
)
task_definition.pid_mode = response["taskDefinition"].get("pidMode", "")
task_definition.registered_at = response["taskDefinition"].get(
"registeredAt"
)
task_definition.tags = response.get("tags")
task_definition.network_mode = response["taskDefinition"].get(
"networkMode", "bridge"
)
pid_mode = response["taskDefinition"].get("pidMode", "")
registered_at = response["taskDefinition"].get("registeredAt")
tags = response.get("tags")
network_mode = response["taskDefinition"].get("networkMode", "bridge")
task_definition.container_definitions = container_definitions
task_definition.pid_mode = pid_mode
task_definition.registered_at = registered_at
task_definition.tags = tags
task_definition.network_mode = network_mode
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -302,7 +304,7 @@ class TaskDefinition(BaseModel):
arn: str
revision: str
region: str
container_definitions: list[ContainerDefinition] = []
container_definitions: Optional[list[ContainerDefinition]] = None
pid_mode: Optional[str]
registered_at: Optional[datetime] = None
tags: Optional[list] = []
@@ -6,6 +6,8 @@ class ecs_task_definitions_containers_readonly_access(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_namespace_not_shared(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_networking_mode_users(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_block_mode(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_enabled(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -16,7 +16,11 @@ class ecs_task_definitions_no_environment_secrets(Check):
"secrets_ignore_patterns", []
)
validate = ecs_client.audit_config.get("secrets_validate", False)
task_definitions = list(ecs_client.task_definitions.values())
task_definitions = [
task_definition
for task_definition in ecs_client.task_definitions.values()
if task_definition.container_definitions is not None
]
# Scan every (task definition, container) environment in batched
# Kingfisher invocations instead of one subprocess per container.
@@ -6,6 +6,8 @@ class ecs_task_definitions_no_privileged_containers(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "rolesanywhere_profile_restricts_session_permissions",
"CheckTitle": "IAM Roles Anywhere profiles scope down the vended session permissions",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "rolesanywhere",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsRolesAnywhereProfile",
"ResourceGroup": "security",
"Description": "**IAM Roles Anywhere profiles** that reference an administrative role are assessed for **session scoping**. A profile defining neither an inline `sessionPolicy` nor `managedPolicyArns` vends credentials with the full permissions of its roles. It is flagged only when a referenced role is administrative, since an unscoped session on a least-privilege role is already constrained.",
"Risk": "Roles Anywhere profiles bind X.509 certificates to IAM roles. When a profile references an administrative role and does not scope the session, vended credentials carry full administrative permissions. An attacker presenting a valid certificate - or planting a rogue trust anchor and profile - gains durable privileged access that rotating IAM keys does not revoke.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html",
"https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html",
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/security-best-practices.html"
],
"Remediation": {
"Code": {
"CLI": "aws rolesanywhere update-profile --profile-id <profile_id> --session-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}'",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::RolesAnywhere::Profile\n Properties:\n Name: scoped-profile\n Enabled: true\n RoleArns:\n - <role_arn>\n SessionPolicy: '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}' # FIX: scope down the vended session\n```",
"Other": "1. Identify the least-privilege actions the workload actually needs\n2. Attach a sessionPolicy or managedPolicyArns to the Roles Anywhere profile that grants only those actions\n3. Prefer purpose-built roles per workload over broad roles referenced by many profiles\n4. Review trust anchors and profiles regularly for entries you did not create",
"Terraform": "```hcl\nresource \"aws_rolesanywhere_profile\" \"<example_resource_name>\" {\n name = \"scoped-profile\"\n enabled = true\n role_arns = [<role_arn>]\n session_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [<least_privilege_actions>]\n Resource = [<scoped_resources>]\n }]\n }) # FIX: scope down the vended session\n}\n```"
},
"Recommendation": {
"Text": "Attach a session policy or managed policies to every enabled IAM Roles Anywhere profile so the vended credentials are scoped below the referenced role's permissions. Pair each profile with a purpose-built least-privilege role and audit trust anchors and profiles regularly for unexpected entries.",
"Url": "https://hub.prowler.com/check/rolesanywhere_profile_restricts_session_permissions"
}
},
"Categories": [
"identity-access",
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "A profile is failed only when it is enabled, its session is unscoped, and a referenced role effectively grants administrative (*:*) access. The session-policy set (inline sessionPolicy plus every managedPolicyArns entry, resolved to its policy document) is evaluated as a union: any member granting *:* leaves the session unrestricted. Role classification merges all attached and inline identity-policy documents so explicit denies negate allows across policies, excludes condition-guarded statements (not statically provable), and intersects the result with the role's permissions boundary: a role whose boundary does not grant *:* - or whose boundary document cannot be resolved - is not classified as administrative. Disabled profiles, scoped profiles, and profiles with no role identified as administrative are reported as PASS. Referenced roles absent from the IAM inventory (for example cross-account roles or denied ListRoles) and policy documents that could not be collected are treated as non-administrative to avoid false positives."
}
@@ -0,0 +1,272 @@
import json
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.logger import logger
from prowler.providers.aws.services.iam.iam_client import iam_client
from prowler.providers.aws.services.iam.lib.policy import check_full_service_access
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import (
rolesanywhere_client,
)
# AWS-managed AdministratorAccess ARN suffix, partition-agnostic
# (arn:aws:..., arn:aws-cn:..., arn:aws-us-gov:...).
ADMIN_POLICY_ARN_SUFFIX = ":iam::aws:policy/AdministratorAccess"
# Synthetic statement equivalent to the AWS-managed AdministratorAccess
# document, used when a policy is identified by that ARN but its document was
# not collected by the IAM service.
_ADMIN_STATEMENT = {"Effect": "Allow", "Action": "*", "Resource": "*"}
def _grants_full_access(document) -> bool:
"""Return True when a policy document grants administrative (``*:*``) access.
Args:
document: Decoded IAM policy document, or None when unavailable.
"""
if not document:
return False
try:
return check_full_service_access("*", document)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return False
def _full_access_status(documents) -> bool | None:
"""Return whether a policy-document set grants ``*:*``, or None when unknown.
Merges every condition-free statement across the given documents into a
single evaluation so an explicit deny in one document negates an allow in
another (Deny > Allow via the shared policy-evaluation helpers).
Unresolved or malformed documents and context-dependent semantics cannot
produce a definitive classification and propagate as None:
- a document that is missing or not a well-formed statement container;
- a ``Condition``-guarded Deny that could negate an otherwise proven
full-access grant;
- a ``Condition``-guarded Allow that could grant full access not proven
by the unconditional statements.
Args:
documents: Iterable of decoded IAM policy documents (None members mark
documents that could not be resolved).
"""
statements = []
conditional_effects = set()
for document in documents:
if not isinstance(document, dict) or "Statement" not in document:
return None
document_statements = document.get("Statement", [])
if not isinstance(document_statements, list):
document_statements = [document_statements]
for statement in document_statements:
if not isinstance(statement, dict):
return None
effect = str(statement.get("Effect", "")).lower()
if (
effect not in {"allow", "deny"}
or not ("Action" in statement or "NotAction" in statement)
or not ("Resource" in statement or "NotResource" in statement)
):
return None
if statement.get("Condition"):
conditional_effects.add(effect)
else:
statements.append(statement)
grants_full_access = _grants_full_access({"Statement": statements})
if (grants_full_access and "deny" in conditional_effects) or (
not grants_full_access and "allow" in conditional_effects
):
return None
return grants_full_access
def _role_is_privileged(role, policies) -> bool | None:
"""Return whether an IAM role is administrative, or None when unknown.
Effective permissions are the intersection of the role's identity policies
(attached and inline, evaluated together) and its permissions boundary.
Unresolved policy documents, malformed policies, and condition-guarded
statements that could change the outcome propagate as None instead of
being collapsed into a definitive classification.
Args:
role: An ``iam_service.Role`` referenced by a Roles Anywhere profile.
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
"""
documents = []
for attached in role.attached_policies:
policy_arn = attached.get("PolicyArn", "")
document = getattr(policies.get(policy_arn), "document", None)
if policy_arn.endswith(ADMIN_POLICY_ARN_SUFFIX) and not document:
documents.append({"Statement": [_ADMIN_STATEMENT]})
else:
documents.append(document)
for inline_name in role.inline_policies:
policy = policies.get(f"{role.arn}:policy/{inline_name}")
documents.append(getattr(policy, "document", None))
identity_status = _full_access_status(documents)
if identity_status is False:
# Identity policies provably do not grant *:*; no boundary can widen them.
return False
boundary = getattr(role, "permissions_boundary", None)
if not boundary:
return identity_status
boundary_arn = (
boundary.get("PermissionsBoundaryArn", "") if isinstance(boundary, dict) else ""
)
if boundary_arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
# An AdministratorAccess boundary restricts nothing.
return identity_status
boundary_status = _full_access_status(
[getattr(policies.get(boundary_arn), "document", None)]
)
if boundary_status is False:
# The boundary provably does not grant *:*: the intersection cannot be
# administrative regardless of the identity policies.
return False
if boundary_status is None:
return None
return identity_status
def _session_is_scoped(profile, policies) -> bool | None:
"""Return whether session policies restrict permissions, or None when unknown.
AWS evaluates the inline ``sessionPolicy`` and every ``managedPolicyArns``
entry together as a single session-policy category, so the complete set is
merged into one evaluation: the session is scoped only when at least one
session policy exists and the set does not grant ``*:*``. Managed entries
are resolved through the collected IAM policies. An invalid inline policy
or an unresolved managed policy does not prove that the session is
restricted and propagates as None.
Args:
profile: A ``rolesanywhere_service.Profile``.
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
"""
if not profile.session_policy and not profile.managed_policy_arns:
return False
documents = []
if profile.session_policy:
try:
documents.append(json.loads(profile.session_policy))
except (ValueError, TypeError):
return None
for arn in profile.managed_policy_arns or []:
if arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
documents.append({"Statement": [_ADMIN_STATEMENT]})
else:
documents.append(getattr(policies.get(arn), "document", None))
grants_full_access = _full_access_status(documents)
return None if grants_full_access is None else not grants_full_access
class rolesanywhere_profile_restricts_session_permissions(Check):
"""Flag Roles Anywhere profiles that vend an unscoped session on a privileged role.
A Roles Anywhere profile that does not restrict the session with an inline
``sessionPolicy`` or ``managedPolicyArns`` vends temporary credentials
carrying the full permissions of every role it references. This is only a
real risk when a referenced role is itself administrative: any certificate
accepted by the trust anchor then wields administrative permissions, turning
the profile into a durable privileged-access path that surviving key rotation
does not remove. Profiles that scope the session, whose referenced roles were
proven not administrative, or that are disabled are reported as PASS. When
session scoping or role permissions cannot be evaluated (unresolved or
invalid policy documents, condition-guarded grants, unknown roles), the
report is MANUAL rather than a proven outcome.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate session-permission scoping for Roles Anywhere profiles.
Returns:
list[Check_Report_AWS]: One report per Roles Anywhere profile. FAIL
for enabled, unscoped profiles that reference a proven administrative
role; MANUAL when session scoping or role permissions could not be
evaluated; PASS for scoped profiles, profiles whose roles were proven
not administrative, and disabled profiles.
"""
findings = []
roles_by_arn = {role.arn: role for role in iam_client.roles}
for profile in rolesanywhere_client.profiles.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=profile)
role_statuses = {
arn: (
_role_is_privileged(roles_by_arn[arn], iam_client.policies)
if arn in roles_by_arn
else None
)
for arn in profile.role_arns
}
privileged_role_arns = [
arn for arn, status in role_statuses.items() if status is True
]
unknown_role_arns = [
arn for arn, status in role_statuses.items() if status is None
]
session_scoped = _session_is_scoped(profile, iam_client.policies)
if not profile.enabled:
report.status = "PASS"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} is disabled and "
"cannot vend session credentials."
)
elif session_scoped is True:
report.status = "PASS"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} restricts vended "
"session permissions with a session policy or managed policies."
)
elif session_scoped is None:
report.status = "MANUAL"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} session scoping "
"could not be evaluated because an inline or managed session "
"policy was invalid or unresolved."
)
elif privileged_role_arns:
report.status = "FAIL"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} does not scope down "
"sessions and references administrative role(s) "
f"{', '.join(privileged_role_arns)}; certificates authenticated "
"through it inherit administrative permissions, enabling durable "
"privileged access."
)
elif unknown_role_arns:
report.status = "MANUAL"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} does not scope down "
"sessions, and the effective permissions of referenced role(s) "
f"{', '.join(unknown_role_arns)} could not be evaluated."
)
else:
report.status = "PASS"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} does not scope down "
"sessions, but no referenced role was identified as "
"administrative; scoping the session is recommended as "
"defense-in-depth."
)
findings.append(report)
return findings
@@ -11,7 +11,9 @@ class RolesAnywhere(AWSService):
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.trust_anchors = {}
self.profiles = {}
self.__threading_call__(self._list_trust_anchors)
self.__threading_call__(self._list_profiles)
def _list_trust_anchors(self, regional_client):
logger.info("RolesAnywhere - Listing Trust Anchors...")
@@ -52,6 +54,53 @@ class RolesAnywhere(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_profiles(self, regional_client):
"""List and cache IAM Roles Anywhere profiles for one AWS Region.
Args:
regional_client: Roles Anywhere client for the audited Region.
"""
logger.info("RolesAnywhere - Listing Profiles...")
try:
paginator = regional_client.get_paginator("list_profiles")
for page in paginator.paginate():
for profile in page.get("profiles", []):
arn = profile.get("profileArn", "")
if not arn:
continue
if self.audit_resources and not is_resource_filtered(
arn, self.audit_resources
):
continue
tags = []
try:
tags = regional_client.list_tags_for_resource(
resourceArn=arn
).get("tags", [])
except Exception as error:
logger.warning(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.profiles[arn] = Profile(
arn=arn,
id=profile.get("profileId", ""),
name=profile.get("name", ""),
region=regional_client.region,
enabled=profile.get("enabled", False),
role_arns=profile.get("roleArns", []) or [],
session_policy=profile.get("sessionPolicy", "") or "",
managed_policy_arns=profile.get("managedPolicyArns", []) or [],
duration_seconds=profile.get("durationSeconds", 0) or 0,
accept_role_session_name=profile.get(
"acceptRoleSessionName", False
),
tags=tags,
)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class TrustAnchor(BaseModel):
arn: str
@@ -62,3 +111,19 @@ class TrustAnchor(BaseModel):
source_type: str = ""
acm_pca_arn: str = ""
tags: List[Dict[str, str]] = Field(default_factory=list)
class Profile(BaseModel):
"""Represent an IAM Roles Anywhere profile."""
arn: str
id: str
name: str
region: str
enabled: bool = False
role_arns: List[str] = Field(default_factory=list)
session_policy: str = ""
managed_policy_arns: List[str] = Field(default_factory=list)
duration_seconds: int = 0
accept_role_session_name: bool = False
tags: List[Dict[str, str]] = Field(default_factory=list)
@@ -1,25 +1,58 @@
from copy import deepcopy
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.iam.lib.policy import is_policy_public
from prowler.providers.aws.services.ses.ses_client import ses_client
def _normalize_policy_statements(policy: dict) -> dict:
statements = policy.get("Statement", [])
if isinstance(statements, dict):
return {**policy, "Statement": [statements]}
return policy
def _has_explicit_deny(policy: dict) -> bool:
return any(
isinstance(statement, dict) and statement.get("Effect") == "Deny"
for statement in _normalize_policy_statements(policy).get("Statement", [])
)
class ses_identity_not_publicly_accessible(Check):
def execute(self):
"""Ensure SES identities are not publicly accessible through authorization policies."""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate every authorization policy attached to each SES identity.
Returns:
A list of reports containing the public-access result for each identity.
"""
findings = []
for identity in ses_client.email_identities.values():
if identity.policy is None:
if not identity.policies:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=identity)
report.status = "PASS"
report.status_extended = (
f"SES identity {identity.name} is not publicly accessible."
)
if is_policy_public(
identity.policy,
ses_client.audited_account,
):
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policy."
has_public_allow = any(
is_policy_public(
_normalize_policy_statements(deepcopy(policy)),
ses_client.audited_account,
)
for policy in identity.policies.values()
)
if has_public_allow:
if any(
_has_explicit_deny(policy) for policy in identity.policies.values()
):
report.status = "MANUAL"
report.status_extended = f"SES identity {identity.name} has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
else:
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policies."
findings.append(report)
@@ -1,7 +1,7 @@
from json import loads
from typing import Optional
from pydantic.v1 import BaseModel
from pydantic.v1 import BaseModel, Field
from prowler.lib.logger import logger
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
@@ -46,8 +46,11 @@ class SES(AWSService):
identity_attributes = regional_client.get_email_identity(
EmailIdentity=identity.name
)
for _, content in identity_attributes.get("Policies", {}).items():
identity.policy = loads(content)
identity.policies = {
name: loads(content)
for name, content in identity_attributes.get("Policies", {}).items()
}
identity.policy = next(reversed(identity.policies.values()), None)
identity.tags = identity_attributes.get("Tags", [])
dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {}
identity.dkim_status = dkim_attrs.get("Status")
@@ -72,6 +75,7 @@ class Identity(BaseModel):
region: str
type: Optional[str]
policy: Optional[dict] = None
policies: dict[str, dict] = Field(default_factory=dict)
tags: Optional[list] = []
dkim_status: Optional[str] = None
dkim_signing_attributes_origin: Optional[str] = None
@@ -17,6 +17,8 @@ class IAM(GCPService):
self.service_accounts = []
self._get_service_accounts()
self._get_service_accounts_keys()
self.workload_identity_pool_providers = []
self._get_workload_identity_pool_providers()
def _get_service_accounts(self):
for project_id in self.project_ids:
@@ -87,6 +89,94 @@ class IAM(GCPService):
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_workload_identity_pool_providers(self):
for project_id in self.project_ids:
try:
pools_request = (
self.client.projects()
.locations()
.workloadIdentityPools()
.list(parent=f"projects/{project_id}/locations/global")
)
while pools_request is not None:
pools_response = pools_request.execute(
num_retries=DEFAULT_RETRY_ATTEMPTS
)
for pool in pools_response.get("workloadIdentityPools", []):
self._get_providers_for_pool(project_id, pool)
pools_request = (
self.client.projects()
.locations()
.workloadIdentityPools()
.list_next(
previous_request=pools_request,
previous_response=pools_response,
)
)
except Exception as error:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_providers_for_pool(self, project_id, pool):
try:
pool_name = pool.get("name", "")
pool_id = pool_name.split("/")[-1]
# A provider can remain ACTIVE while its parent pool is disabled or
# soft-deleted; a disabled pool cannot vend credentials, so the
# pool's effective availability must travel with the provider.
pool_disabled = (
pool.get("disabled", False) or pool.get("state", "ACTIVE") != "ACTIVE"
)
request = (
self.client.projects()
.locations()
.workloadIdentityPools()
.providers()
.list(parent=pool_name)
)
while request is not None:
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
for provider in response.get("workloadIdentityPoolProviders", []):
provider_type = next(
(
key
for key in ("oidc", "aws", "saml", "x509")
if key in provider
),
"",
)
self.workload_identity_pool_providers.append(
WorkloadIdentityPoolProvider(
name=provider.get("name", ""),
id=provider.get("name", "").split("/")[-1],
pool_id=pool_id,
pool_disabled=pool_disabled,
project_id=project_id,
state=provider.get("state", ""),
disabled=provider.get("disabled", False),
attribute_condition=provider.get("attributeCondition", ""),
attribute_mapping=provider.get("attributeMapping", {})
or {},
provider_type=provider_type,
issuer_uri=(provider.get("oidc", {}) or {}).get(
"issuerUri", ""
),
display_name=provider.get("displayName", ""),
)
)
request = (
self.client.projects()
.locations()
.workloadIdentityPools()
.providers()
.list_next(previous_request=request, previous_response=response)
)
except Exception as error:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class Key(BaseModel):
name: str
@@ -106,6 +196,25 @@ class ServiceAccount(BaseModel):
disabled: bool = False
class WorkloadIdentityPoolProvider(BaseModel):
"""Represent a GCP Workload Identity Federation pool provider."""
name: str
id: str
pool_id: str
# True when the parent pool is disabled or not ACTIVE; such a pool cannot
# vend credentials regardless of the provider's own state.
pool_disabled: bool = False
project_id: str
state: str = ""
disabled: bool = False
attribute_condition: str = ""
attribute_mapping: dict = {}
provider_type: str = ""
issuer_uri: str = ""
display_name: str = ""
class AccessApproval(GCPService):
def __init__(self, provider: GcpProvider):
super().__init__(__class__.__name__, provider)
@@ -0,0 +1,38 @@
{
"Provider": "gcp",
"CheckID": "iam_workload_identity_pool_provider_attribute_condition",
"CheckTitle": "Workload Identity Federation providers trusting a multi-tenant issuer enforce an attribute condition",
"CheckType": [],
"ServiceName": "iam",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "iam.googleapis.com/WorkloadIdentityPoolProvider",
"Description": "**Workload Identity Federation providers** define an `attributeCondition` (CEL) restricting which external identities may impersonate Google Cloud principals. When a provider trusts a **multi-tenant issuer** (GitHub Actions, GitLab.com and other shared issuers), omitting the condition trusts every identity that issuer can mint. Providers trusting a dedicated single-tenant issuer are not flagged.",
"Risk": "A provider trusting a multi-tenant issuer without an attribute condition accepts any external identity from that issuer - for example any GitHub repository when the issuer is GitHub Actions. An attacker controlling any tenant on that platform can authenticate through the provider and exchange tokens for federated credentials. Because no key is stored, this access survives credential rotation.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://cloud.google.com/iam/docs/workload-identity-federation",
"https://cloud.google.com/iam/docs/workload-identity-federation#mapping",
"https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation"
],
"Remediation": {
"Code": {
"CLI": "gcloud iam workload-identity-pools providers update-<oidc|aws|saml|x509> <PROVIDER_ID> --location=global --workload-identity-pool=<POOL_ID> --attribute-condition=\"<CEL_condition>\" # use the update subcommand matching the provider type",
"NativeIaC": "",
"Other": "1. In the Google Cloud console, go to IAM & Admin > Workload Identity Federation\n2. Open the affected pool and provider\n3. Set an attribute condition (CEL) restricting the allowed external identities, using an assertion that fits the provider type: OIDC by subject or claim (assertion.sub), SAML by NameID or attribute (assertion.subject or assertion.attributes[...]), AWS by account/role (assertion.account or assertion.arn), X.509 by certificate subject\n4. For OIDC providers, also restrict allowedAudiences to your own audience\n5. Review providers regularly for entries you did not create",
"Terraform": "```hcl\nresource \"google_iam_workload_identity_pool_provider\" \"example\" {\n workload_identity_pool_id = \"my-pool\"\n workload_identity_pool_provider_id = \"my-provider\"\n attribute_condition = \"assertion.repository_owner == 'my-org'\" # FIX: restrict trusted identities\n\n # Declare exactly one of oidc {}, aws {}, saml {}, or x509 {} to match the provider type, e.g.:\n oidc {\n issuer_uri = \"https://token.actions.githubusercontent.com\"\n allowed_audiences = [\"https://my-audience.example.com\"]\n }\n}\n```"
},
"Recommendation": {
"Text": "Set an attribute condition on any active Workload Identity Federation provider that trusts a multi-tenant issuer so only the intended external identities can impersonate Google Cloud principals, and pair OIDC providers with a restricted audience. Providers trusting a dedicated single-tenant issuer should still add a condition as defense-in-depth. Audit pools and providers regularly for unexpected entries.",
"Url": "https://hub.prowler.com/check/iam_workload_identity_pool_provider_attribute_condition"
}
},
"Categories": [
"identity-access",
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Only active providers that trust a known multi-tenant issuer (GitHub Actions, GitLab.com, Google, HCP Terraform) are failed when they omit an attribute condition; providers trusting a dedicated single-tenant issuer, AWS/SAML/X.509 providers, and disabled or non-ACTIVE providers are reported as PASS. This check verifies that an attribute condition is present; it does not evaluate whether the condition's expression is sufficiently restrictive."
}
@@ -0,0 +1,107 @@
from urllib.parse import urlparse
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.iam.iam_client import iam_client
# OIDC issuers whose tokens are minted for many independent tenants (any GitHub
# repository, any GitLab project, any Google account, ...). A provider that
# trusts one of these without an ``attributeCondition`` accepts identities
# outside the operator's control, so omitting the condition genuinely expands
# trust. A dedicated, single-tenant issuer only vends tokens to the operator's
# own workloads, so an attribute condition there is defense-in-depth rather than
# a requirement (see Google's guidance for GitHub and other shared issuers).
MULTI_TENANT_OIDC_ISSUER_HOSTS = {
"token.actions.githubusercontent.com", # GitHub Actions (any repository)
"gitlab.com", # GitLab.com SaaS (any project)
"accounts.google.com", # any Google account
"app.terraform.io", # HCP Terraform (any organization)
}
def _is_multi_tenant_issuer(issuer_uri: str) -> bool:
"""Return True when the OIDC issuer is a known multi-tenant/shared issuer."""
if not issuer_uri:
return False
# hostname lowercases and strips port/userinfo (gitlab.com:443, user@host);
# fall back to the raw string for bare hosts without a scheme.
host = urlparse(issuer_uri).hostname or issuer_uri.lower()
return host in MULTI_TENANT_OIDC_ISSUER_HOSTS
class iam_workload_identity_pool_provider_attribute_condition(Check):
"""Ensure WIF providers trusting a multi-tenant issuer enforce an attribute condition.
A workload identity pool provider that trusts a multi-tenant issuer (GitHub
Actions, GitLab.com, ...) without an ``attributeCondition`` accepts every
external identity that issuer can mint. An attacker controlling any tenant on
that platform can then authenticate through the provider and exchange tokens
for federated credentials, surviving credential rotation. Providers that
enforce an attribute condition, that trust a dedicated single-tenant issuer,
that are not OIDC-based, that are disabled/inactive, or whose parent pool is
disabled are reported as PASS.
"""
def execute(self) -> list[Check_Report_GCP]:
"""Evaluate the attribute condition of each Workload Identity provider.
Returns:
list[Check_Report_GCP]: One report per workload identity pool
provider. FAIL for active providers that trust a multi-tenant issuer
without an attribute condition; PASS for providers that enforce one,
trust a dedicated issuer, are not OIDC-based, or are
disabled/inactive.
"""
findings = []
for provider in iam_client.workload_identity_pool_providers:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=provider,
resource_id=provider.name,
resource_name=provider.display_name or provider.id,
location="global",
)
if provider.pool_disabled:
report.status = "PASS"
report.status_extended = (
f"Workload Identity Federation provider {provider.id} belongs "
f"to the disabled pool {provider.pool_id}, which cannot vend "
"credentials."
)
elif provider.disabled or provider.state != "ACTIVE":
report.status = "PASS"
report.status_extended = (
f"Workload Identity Federation provider {provider.id} in pool "
f"{provider.pool_id} is not active and cannot vend credentials."
)
elif provider.attribute_condition:
report.status = "PASS"
report.status_extended = (
f"Workload Identity Federation provider {provider.id} in pool "
f"{provider.pool_id} enforces an attribute condition."
)
elif _is_multi_tenant_issuer(provider.issuer_uri):
report.status = "FAIL"
report.status_extended = (
f"Workload Identity Federation provider {provider.id} in pool "
f"{provider.pool_id} trusts the multi-tenant issuer "
f"{provider.issuer_uri} without an attribute condition, so any "
"identity from that issuer can authenticate through this provider."
)
elif provider.provider_type != "oidc":
report.status = "PASS"
report.status_extended = (
f"Workload Identity Federation provider {provider.id} in pool "
f"{provider.pool_id} is not an OIDC provider trusting a "
"multi-tenant issuer; an attribute condition is recommended as "
"defense-in-depth but not required."
)
else:
report.status = "PASS"
report.status_extended = (
f"Workload Identity Federation provider {provider.id} in pool "
f"{provider.pool_id} trusts a dedicated issuer; an attribute "
"condition is recommended as defense-in-depth but not required."
)
findings.append(report)
return findings
+15 -14
View File
@@ -1,6 +1,6 @@
[build-system]
build-backend = "hatchling.build"
requires = ["hatchling"]
requires = ["hatchling==1.32.0"]
[dependency-groups]
dev = [
@@ -68,6 +68,10 @@ dependencies = [
"boto3==1.40.61",
"botocore==1.40.61",
"colorama==0.4.6",
# cryptography 50 needs alibabacloud-tea-openapi>=0.4.6, oci>=2.184.1 and, in the
# [tool.uv] pins, msal>=1.37.0 and pyopenssl>=26.4.0: earlier releases cap it below 49
# or 50. Keep the five in step. Never widen a cap with [tool.uv] override-dependencies:
# overrides do not ship in the wheel, and 5.38.0 was uninstallable with pip because of one.
"cryptography==50.0.0",
"dash==3.1.1",
"dash-bootstrap-components==2.0.3",
@@ -103,10 +107,10 @@ dependencies = [
"uuid6==2024.7.10",
"py-iam-expand==0.3.0",
"h2==4.3.0",
"oci==2.183.0",
"oci==2.184.1",
"alibabacloud_credentials==1.0.3",
"alibabacloud_ram20150501==1.2.0",
"alibabacloud_tea_openapi==0.4.5",
"alibabacloud_tea_openapi==0.4.6",
"alibabacloud_sts20150401==1.1.6",
"alibabacloud_vpc20160428==6.13.0",
"alibabacloud_ecs20140526==7.2.5",
@@ -128,7 +132,8 @@ dependencies = [
"huaweicloudsdkobs==3.1.204",
"huaweicloudsdkrds==3.1.204",
"huaweicloudsdkvpc==3.1.204",
"huaweicloudsdkwaf==3.1.204"
"huaweicloudsdkwaf==3.1.204",
"zstandard==0.25.0"
]
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
license = "Apache-2.0"
@@ -136,7 +141,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.39.0"
version = "5.40.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -199,7 +204,7 @@ constraint-dependencies = [
"alibabacloud-sas20181203==6.1.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.5",
"alibabacloud-tea-openapi==0.4.6",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
@@ -300,7 +305,7 @@ constraint-dependencies = [
"mock==5.2.0",
"moto==5.1.11",
"mpmath==1.3.0",
"msal==1.36.0",
"msal==1.37.0",
"msal-extensions==1.3.1",
"msgraph-core==1.3.8",
"msrest==0.7.1",
@@ -343,7 +348,7 @@ constraint-dependencies = [
"pyjwt==2.13.0",
"pylint==3.3.4",
"pynacl==1.6.2",
"pyopenssl==26.2.0",
"pyopenssl==26.4.0",
"pyparsing==3.3.2",
"pytest==9.0.3",
"pytest-cov==6.0.0",
@@ -387,13 +392,9 @@ constraint-dependencies = [
"xmltodict==1.0.4",
"yarl==1.23.0",
"zipp==3.23.1",
"zstd==1.5.7.3"
]
override-dependencies = [
"okta==3.4.2",
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
"cryptography==50.0.0",
"zstd==1.5.7.2"
]
override-dependencies = ["okta==3.4.2"]
[tool.vulture]
# Suppress known false positives. The CI command only passes --exclude and
+285
View File
@@ -1,6 +1,8 @@
import re
import sys
from io import StringIO
import pytest
from mock import MagicMock, patch
from prowler.config.config import prowler_version, timestamp
@@ -676,6 +678,192 @@ html_footer = """
"""
def _setup_aws_xss(provider, payload):
provider.identity.account = payload
provider.identity.profile = payload
provider.identity.audited_regions = [payload]
provider.identity.user_id = payload
provider.identity.identity_arn = payload
def _setup_azure_xss(provider, payload):
provider.identity.tenant_ids = [payload]
provider.identity.tenant_domain = payload
provider.identity.subscriptions = {payload: payload}
provider.identity.identity_type = payload
provider.identity.identity_id = payload
def _setup_gcp_xss(provider, payload):
provider.project_ids = [payload]
provider.session._service_account_email = payload
def _setup_kubernetes_xss(provider, payload):
provider.identity.cluster = payload
provider.identity.context = payload
def _setup_github_xss(provider, payload):
provider.identity = MagicMock(spec=["account_name", "account_email"])
provider.identity.account_name = payload
provider.identity.account_email = payload
provider.auth_method = payload
def _setup_m365_xss(provider, payload):
provider.identity.tenant_domain = payload
provider.identity.identity_type = payload
provider.identity.identity_id = payload
provider.identity.user = payload
def _setup_nhn_xss(provider, payload):
provider.identity.tenant_domain = payload
provider.identity.identity_type = payload
provider.identity.identity_id = payload
def _setup_mongodbatlas_xss(provider, payload):
provider.identity.organization_name = payload
def _setup_iac_xss(provider, payload):
provider.scan_repository_url = payload
provider.scan_path = None
provider.auth_method = payload
def _setup_image_xss(provider, payload):
provider.registry = payload
provider.images = [payload]
provider.auth_method = payload
def _setup_llm_xss(provider, payload):
provider.model = payload
provider.plugins = [payload]
provider.max_concurrency = payload
provider.config_path = payload
def _setup_oraclecloud_xss(provider, payload):
provider.session.profile = payload
provider.identity.tenancy_name = payload
provider.identity.tenancy_id = payload
def _setup_stackit_xss(provider, payload):
provider.identity.project_id = payload
provider.identity.project_name = payload
provider.identity.audited_regions = {payload}
def _setup_cloudflare_xss(provider, payload):
account = MagicMock()
account.id = payload
provider.accounts = [account]
provider.session.api_token = "token"
provider.session.api_key = None
provider.session.api_email = None
provider.identity.email = payload
def _setup_alibabacloud_xss(provider, payload):
provider.identity.account_id = payload
provider.identity.account_name = payload
provider.identity.audited_regions = payload
provider.identity.identity_arn = payload
provider.identity.user_name = payload
def _setup_openstack_xss(provider, payload):
provider.identity.project_id = payload
provider.identity.project_name = payload
provider.identity.region_name = payload
provider.identity.username = payload
provider.identity.user_id = payload
def _setup_googleworkspace_xss(provider, payload):
provider.identity.domain = payload
provider.identity.customer_id = payload
provider.identity.delegated_user = payload
def _setup_e2enetworks_xss(provider, payload):
provider.identity.project_id = payload
provider.identity.locations = [payload]
def _setup_vercel_xss(provider, payload):
team = MagicMock()
team.name = payload
team.id = payload
provider.identity.team = team
provider.identity.email = payload
provider.identity.username = payload
def _setup_okta_xss(provider, payload):
provider.identity.org_domain = payload
provider.auth_method = payload
provider.identity.client_id = payload
def _setup_scaleway_xss(provider, payload):
provider.identity.organization_id = payload
provider.session.access_key = payload
provider.identity.bearer_type = payload
provider.identity.bearer_email = payload
provider.identity.bearer_id = payload
provider.session.default_region = payload
def _setup_linode_xss(provider, payload):
provider.identity.username = payload
provider.identity.email = payload
provider.identity.account_id = payload
def _setup_huaweicloud_xss(provider, payload):
provider.identity.account_id = payload
provider.identity.account_name = payload
provider.identity.profile = payload
provider.identity.regions = {payload}
provider.identity.domain_id = payload
provider.identity.user_id = payload
provider.identity.user_name = payload
provider.identity.identity_type = payload
PROVIDER_XSS_SETUPS = [
("aws", _setup_aws_xss),
("azure", _setup_azure_xss),
("gcp", _setup_gcp_xss),
("kubernetes", _setup_kubernetes_xss),
("github", _setup_github_xss),
("m365", _setup_m365_xss),
("nhn", _setup_nhn_xss),
("mongodbatlas", _setup_mongodbatlas_xss),
("iac", _setup_iac_xss),
("image", _setup_image_xss),
("llm", _setup_llm_xss),
("oraclecloud", _setup_oraclecloud_xss),
("stackit", _setup_stackit_xss),
("cloudflare", _setup_cloudflare_xss),
("alibabacloud", _setup_alibabacloud_xss),
("openstack", _setup_openstack_xss),
("googleworkspace", _setup_googleworkspace_xss),
("e2enetworks", _setup_e2enetworks_xss),
("vercel", _setup_vercel_xss),
("okta", _setup_okta_xss),
("scaleway", _setup_scaleway_xss),
("linode", _setup_linode_xss),
("huaweicloud", _setup_huaweicloud_xss),
]
class TestHTML:
def test_transform_fail_finding(self):
findings = [
@@ -1100,6 +1288,103 @@ class TestHTML:
assert "<script>alert(1)</script>" not in summary
assert "Delhi&#34;&gt;&lt;script&gt;alert(1)&lt;/script&gt;" in summary
@pytest.mark.parametrize(
"provider_type,setup_fn",
PROVIDER_XSS_SETUPS,
ids=[t for t, _ in PROVIDER_XSS_SETUPS],
)
def test_get_assessment_summary_escapes_provider_identity(
self, provider_type, setup_fn
):
"""Every provider header must HTML-escape tenant-controlled identity fields."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = provider_type
setup_fn(provider, payload)
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_provider_xss_setups_covers_every_assessment_summary_method(self):
"""Adding a new get_<provider>_assessment_summary without a PROVIDER_XSS_SETUPS
entry must fail this test, so the escape guarantee cannot silently regress."""
pattern = re.compile(r"^get_(.+)_assessment_summary$")
discovered = set()
for name in dir(HTML):
match = pattern.match(name)
if match:
discovered.add(match.group(1))
covered = {ptype for ptype, _ in PROVIDER_XSS_SETUPS}
missing = discovered - covered
extra = covered - discovered
assert (
not missing
), f"providers without XSS coverage in PROVIDER_XSS_SETUPS: {sorted(missing)}"
assert (
not extra
), f"PROVIDER_XSS_SETUPS entries with no matching HTML method: {sorted(extra)}"
def test_github_app_get_assessment_summary_escapes_app_identity(self):
"""The GitHub App branch (elif hasattr app_id) must escape app_name/app_id/installations,
which the PAT setup does not exercise."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = "github"
provider.identity = MagicMock(spec=["app_id", "app_name", "installations"])
provider.identity.app_id = payload
provider.identity.app_name = payload
provider.identity.installations = [payload]
provider.auth_method = payload
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_iac_get_assessment_summary_escapes_scan_path(self):
"""The IAC scan_path branch (else of `if scan_repository_url`) must escape it."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = "iac"
provider.scan_repository_url = None
provider.scan_path = payload
provider.auth_method = payload
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_image_get_assessment_summary_escapes_images_list(self):
"""The Image `else` branch (no registry, images list) must escape each image."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = "image"
provider.registry = None
provider.images = [payload]
provider.auth_method = payload
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_process_markdown_bold_text(self):
"""Test that **text** is converted to <strong>text</strong>"""
test_text = "This is **bold text** and this is **also bold**"
@@ -0,0 +1,837 @@
from datetime import datetime
from unittest import mock
from prowler.providers.aws.services.ecr.ecr_service import (
ImageDetails,
Registry,
Repository,
)
from prowler.providers.aws.services.ecr.image_inspection import (
ImageScanData,
ImageScanFile,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
# A real JWT: Kingfisher detects this regardless of the surrounding key name
# or format (env-style KEY=value, Dockerfile RUN step, or source file).
SECRET_VALUE = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
".eyJzdWIiOiIxMjM0NTY3ODkwIn0"
".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
)
def create_repository(name="test-repo", region=AWS_REGION_US_EAST_1) -> Repository:
"""Build a minimal ECR Repository fixture."""
return Repository(
name=name,
arn=f"arn:aws:ecr:{region}:{AWS_ACCOUNT_NUMBER}:repository/{name}",
region=region,
scan_on_push=True,
images_details=[],
)
def create_image(tag="latest", digest=None) -> ImageDetails:
"""Build a minimal ImageDetails fixture."""
return ImageDetails(
latest_tag=tag,
latest_digest=digest or f"sha256:{'0' * 64}",
image_pushed_at=datetime.now(),
scan_findings_status=None,
scan_findings_severity_count=None,
artifact_media_type="application/vnd.docker.container.image.v1+json",
type="Docker",
)
def mock_image_scan_data(pairs):
"""Build a fake _get_image_scan_data generator yielding the given pairs."""
def _generator():
"""Yield each (repository, image, scan_data) pair once."""
for entry in pairs:
yield entry
return _generator
class Test_ecr_repository_image_no_secrets:
"""Tests for the ecr_repository_image_no_secrets check."""
def test_no_repositories(self):
"""No repositories yields no findings."""
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data([])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 0
def test_clean_image(self):
"""An image with no secrets passes."""
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=["PATH=/usr/bin"],
history=["RUN echo hello"],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
digest_short = image.latest_digest.split(":")[-1][:12]
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
f"No secrets found in the image '{image.latest_tag}' "
f"({image.latest_digest}) of ECR repository {repository.name}."
)
assert result[0].region == AWS_REGION_US_EAST_1
assert (
result[0].resource_id
== f"{repository.name}:{image.latest_tag}@{digest_short}"
)
assert result[0].resource_arn == f"{repository.arn}/image/{digest_short}"
def test_truncated_image_reports_manual(self):
"""A clean but truncated image is MANUAL, since part was not scanned."""
repository = create_repository()
image = create_image()
scan_data = ImageScanData(env=[], history=[], files=[], truncated=True)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"part of it could not be retrieved or exceeded configured size "
"limits and was not scanned" in result[0].status_extended
)
def test_secret_in_environment_variable(self):
"""A secret in an environment variable fails, naming the variable."""
from prowler.lib.check.models import Severity
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
history=[],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "environment variable DB_PASSWORD" in result[0].status_extended
assert SECRET_VALUE not in result[0].status_extended
assert result[0].check_metadata.Severity == Severity.high
def test_secret_in_malformed_env_entry_is_redacted(self):
"""An env entry without '=' is reported generically, never echoed."""
repository = create_repository()
image = create_image()
# The entry has no "=" so no variable name can be split out; the entry
# itself is the secret and must not appear in the finding.
scan_data = ImageScanData(
env=[SECRET_VALUE],
history=[],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "in image environment variables" in result[0].status_extended
assert SECRET_VALUE not in result[0].status_extended
def test_secret_in_unsafe_environment_name_is_redacted(self):
"""An unsafe name before '=' is never included in report text."""
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=[f"{SECRET_VALUE}=safe-value"],
history=[],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
result = ecr_repository_image_no_secrets().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "in image environment variables" in result[0].status_extended
assert SECRET_VALUE not in str(vars(result[0]))
def test_scanned_file_content_is_freed_after_execute(self):
"""File contents are released after scanning so memory stays flat."""
repository = create_repository()
image = create_image()
scanned_file = ImageScanFile(
path="app/config.py",
layer_digest=f"sha256:{'a' * 64}",
content="nothing secret here",
)
scan_data = ImageScanData(
env=[], history=[], files=[scanned_file], truncated=False
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
# The check empties each file's content once it is handed to the
# scanner; only path and layer digest are needed thereafter.
assert scanned_file.content == ""
def test_secrets_ignore_patterns_suppresses_finding(self):
"""A secret matching an ignore pattern is suppressed."""
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
history=[],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [SECRET_VALUE],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_secret_in_build_history(self):
"""A secret in a build history step fails, naming the step."""
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=[],
history=["RUN apt-get update", f'RUN export TOKEN="{SECRET_VALUE}"'],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "image history step 2" in result[0].status_extended
assert SECRET_VALUE not in result[0].status_extended
def test_multiline_environment_secret_keeps_entry_attribution(self):
"""Embedded newlines do not shift an env finding to another entry."""
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=[f"MULTILINE=prefix\r\n{SECRET_VALUE}", "WRONG=value"],
history=[],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
result = ecr_repository_image_no_secrets().execute()
assert "environment variable MULTILINE" in result[0].status_extended
assert "environment variable WRONG" not in result[0].status_extended
def test_multiline_history_secret_keeps_step_attribution(self):
"""Embedded newlines do not shift a history finding to another step."""
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=[],
history=[f"RUN first\nexport TOKEN={SECRET_VALUE}", "RUN second"],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
result = ecr_repository_image_no_secrets().execute()
assert "image history step 1" in result[0].status_extended
assert "image history step 2" not in result[0].status_extended
def test_secret_in_layer_file(self):
"""A secret in a layer file fails, naming the file and layer."""
repository = create_repository()
image = create_image()
layer_digest = f"sha256:{'a' * 64}"
scan_data = ImageScanData(
env=[],
history=[],
files=[
ImageScanFile(
path="app/config.py",
layer_digest=layer_digest,
content=f'TOKEN = "{SECRET_VALUE}"',
)
],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "file app/config.py" in result[0].status_extended
assert layer_digest in result[0].status_extended
assert SECRET_VALUE not in result[0].status_extended
def test_manifest_unresolvable(self):
"""An unresolvable manifest is reported as MANUAL."""
repository = create_repository()
image = create_image()
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, None)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"Could not resolve or retrieve the manifest"
in result[0].status_extended
)
def test_latest_image_lookup_error_reports_repository_manual(self):
"""A failed authoritative image lookup is reported for the repository."""
repository = create_repository()
lookup_error = RuntimeError("authoritative lookup failed")
ecr_client = mock.MagicMock()
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, None, lookup_error)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
result = ecr_repository_image_no_secrets().execute()
assert result[0].status == "MANUAL"
assert "Could not determine the latest image" in result[0].status_extended
def test_scan_error_reports_manual_for_latest_image_per_repository(self):
"""A scanner failure reports MANUAL once per repository's latest image."""
from prowler.lib.utils.utils import SecretsScanError
# Each repository has multiple images; the scan-error fallback must
# scope to the latest image per repository only, mirroring the
# success-path scope, not emit one MANUAL per image.
repo1 = create_repository(name="repo-1")
repo1.images_details = [
create_image(tag="v1", digest=f"sha256:{'1' * 64}"),
create_image(tag="v2", digest=f"sha256:{'2' * 64}"),
]
repo2 = create_repository(name="repo-2")
repo2.images_details = [
create_image(tag="v1", digest=f"sha256:{'3' * 64}"),
create_image(tag="v2", digest=f"sha256:{'4' * 64}"),
]
registry = Registry(
id=AWS_ACCOUNT_NUMBER,
arn=f"arn:aws:ecr:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}",
region=AWS_REGION_US_EAST_1,
repositories=[repo1, repo2],
)
ecr_client = mock.MagicMock()
ecr_client.registries = {AWS_REGION_US_EAST_1: registry}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
# Not consumed on this path, but must be a real generator to iterate.
ecr_client._get_image_scan_data = mock_image_scan_data([])
# The error fallback resolves each repository's scan target via
# _get_scan_target_image; mirror the real method's latest-image scope.
ecr_client._get_scan_target_image.side_effect = lambda repository: (
repository.images_details[-1] if repository.images_details else None
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
side_effect=SecretsScanError("Scanner failure"),
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
# One MANUAL per repository (its latest image), not one per image.
assert len(result) == 2
for report in result:
assert report.status == "MANUAL"
assert "Could not scan image" in report.status_extended
assert "Scanner failure" in report.status_extended
digests_reported = {report.resource_id.split("@")[-1] for report in result}
latest_digest_repo1 = repo1.images_details[-1].latest_digest.split(":")[-1][
:12
]
latest_digest_repo2 = repo2.images_details[-1].latest_digest.split(":")[-1][
:12
]
assert digests_reported == {latest_digest_repo1, latest_digest_repo2}
assert "Scanner failure" in result[0].status_extended
def test_verified_secret_escalates_to_critical(self):
"""A verified secret escalates severity to critical."""
from prowler.lib.check.models import Severity
repository = create_repository()
image = create_image()
scan_data = ImageScanData(
env=[f"TOKEN={SECRET_VALUE}"], history=[], files=[], truncated=False
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": True,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repository, image, scan_data)]
)
def fake_scan_batch(payloads, **kwargs):
# The real detect_secrets_scan_batch consumes the lazily-yielded
# payloads generator as a side effect (that's what populates the
# check's `scanned` list); replicate that here while returning
# a controlled, pre-verified finding.
"""Drain the payload generator like the real scanner, then return canned findings."""
list(payloads)
return {
(0, "environment:0"): [
{
"type": "JSON Web Token (base64url-encoded)",
"line_number": 1,
"is_verified": True,
}
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
side_effect=fake_scan_batch,
) as mock_scan,
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert mock_scan.call_args.kwargs.get("validate") is True
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].check_metadata.Severity == Severity.critical
assert "confirmed to be live" in result[0].status_extended
def test_multiple_repositories_and_images(self):
"""Mixed pass/fail results are reported across multiple repositories."""
repo1 = create_repository(name="repo-1")
repo2 = create_repository(name="repo-2")
image1 = create_image(tag="v1", digest=f"sha256:{'1' * 64}")
image2 = create_image(tag="v2", digest=f"sha256:{'2' * 64}")
clean_scan = ImageScanData(env=[], history=[], files=[], truncated=False)
fail_scan = ImageScanData(
env=[f"DB_PASSWORD={SECRET_VALUE}"],
history=[],
files=[],
truncated=False,
)
ecr_client = mock.MagicMock()
ecr_client.registries = {}
ecr_client.audit_config = {
"secrets_ignore_patterns": [],
"secrets_validate": False,
}
ecr_client._get_image_scan_data = mock_image_scan_data(
[(repo1, image1, clean_scan), (repo2, image2, fail_scan)]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider(),
),
mock.patch(
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
new=ecr_client,
),
):
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
ecr_repository_image_no_secrets,
)
check = ecr_repository_image_no_secrets()
result = check.execute()
assert len(result) == 2
statuses_by_repo = {r.resource_id.split(":")[0]: r.status for r in result}
assert statuses_by_repo["repo-1"] == "PASS"
assert statuses_by_repo["repo-2"] == "FAIL"
report_by_repo = {r.resource_id.split(":")[0]: r for r in result}
assert SECRET_VALUE not in report_by_repo["repo-2"].status_extended
@@ -1,11 +1,21 @@
import json
from concurrent.futures import Future
from datetime import datetime
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import botocore
import pytest
from boto3 import client
from moto import mock_aws
from prowler.providers.aws.services.ecr.ecr_service import ECR, ScanningRule
from prowler.providers.aws.services.ecr.ecr_service import (
ECR,
ScanningRule,
)
from tests.providers.aws.services.ecr.image_scan_fixtures import (
MANIFESTS_BY_DIGEST,
reset_image_fixtures,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
@@ -18,8 +28,20 @@ repo_name = "test-repo"
# Mocking Access Analyzer Calls
make_api_call = botocore.client.BaseClient._make_api_call
# BatchGetImage / GetDownloadUrlForLayer fixtures (which moto does not
# implement) live in image_scan_fixtures and are served by mock_make_api_call.
@pytest.fixture(autouse=True)
def _reset_image_fixtures():
"""Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test."""
reset_image_fixtures()
yield
reset_image_fixtures()
def mock_make_api_call(self, operation_name, kwarg):
"""Fake botocore responses for the ECR operations this suite exercises."""
if operation_name == "DescribeImages":
return {
"imageDetails": [
@@ -150,10 +172,37 @@ def mock_make_api_call(self, operation_name, kwarg):
},
}
if operation_name == "BatchGetImage":
digest = kwarg["imageIds"][0]["imageDigest"]
manifest = MANIFESTS_BY_DIGEST.get(digest)
if manifest is None:
return {
"images": [],
"failures": [
{
"imageId": {"imageDigest": digest},
"failureCode": "ImageNotFound",
}
],
}
return {
"images": [
{
"imageManifest": json.dumps(manifest),
"imageManifestMediaType": manifest.get("mediaType", ""),
}
]
}
if operation_name == "GetDownloadUrlForLayer":
digest = kwarg["layerDigest"]
return {"downloadUrl": f"https://layers.example.com/{digest}"}
return make_api_call(self, operation_name, kwarg)
def mock_generate_regional_clients(provider, service):
"""Return a single regional client for every requested region."""
regional_client = provider._session.current_session.client(
service, region_name=AWS_REGION_EU_WEST_1
)
@@ -169,13 +218,17 @@ def mock_generate_regional_clients(provider, service):
)
class Test_ECR_Service:
# Test ECR Service
"""Tests for the ECR service."""
def test_service(self):
"""The service name is set correctly."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
assert ecr.service == "ecr"
# Test ECR client
def test_client(self):
"""Each regional client is an ECR client."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
for regional_client in ecr.regional_clients.values():
@@ -183,6 +236,7 @@ class Test_ECR_Service:
# Test ECR session
def test_get_session(self):
"""The session is set correctly."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
assert ecr.session.__class__.__name__ == "Session"
@@ -190,6 +244,7 @@ class Test_ECR_Service:
# Test describe ECR repositories
@mock_aws
def test_describe_registries_and_repositories(self):
"""Registries and repositories are discovered."""
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client.create_repository(
repositoryName=repo_name,
@@ -220,6 +275,7 @@ class Test_ECR_Service:
# Test describe ECR repository policies
@mock_aws
def test_describe_repository_policies(self):
"""Repository policies are fetched and parsed."""
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client.create_repository(
repositoryName=repo_name,
@@ -249,6 +305,7 @@ class Test_ECR_Service:
# Test describe ECR repository lifecycle policies
@mock_aws
def test_get_lifecycle_policies(self):
"""Repository lifecycle policies are fetched."""
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client.create_repository(
repositoryName=repo_name,
@@ -268,6 +325,7 @@ class Test_ECR_Service:
# Test get image details
@mock_aws
def test_get_image_details(self):
"""Scannable, tagged images are collected and sorted by push date."""
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client.create_repository(
repositoryName=repo_name,
@@ -366,6 +424,7 @@ class Test_ECR_Service:
# Test get ECR Registries Scanning Configuration
@mock_aws
def test_get_registry_scanning_configuration(self):
"""The registry's scanning configuration is fetched."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
assert len(ecr.registries) == 1
@@ -379,39 +438,188 @@ class Test_ECR_Service:
]
def test_is_artifact_scannable_docker(self):
"""A Docker image config is scannable."""
assert ECR._is_artifact_scannable(
"application/vnd.docker.container.image.v1+json"
)
def test_is_artifact_scannable_layer_tar(self):
"""An uncompressed Docker layer is scannable."""
assert ECR._is_artifact_scannable(
"application/vnd.docker.image.rootfs.diff.tar"
)
def test_is_artifact_scannable_layer_gzip(self):
"""A gzip-compressed Docker layer is scannable."""
assert ECR._is_artifact_scannable(
"application/vnd.docker.image.rootfs.diff.tar.gzip"
)
def test_is_artifact_scannable_oci(self):
"""An OCI image config is scannable."""
assert ECR._is_artifact_scannable("application/vnd.oci.image.config.v1+json")
def test_is_artifact_scannable_oci_tar(self):
"""An uncompressed OCI layer is scannable."""
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar")
def test_is_artifact_scannable_oci_compressed(self):
"""A gzip-compressed OCI layer is scannable."""
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar+gzip")
def test_is_artifact_scannable_none(self):
"""A missing media type is not scannable."""
assert not ECR._is_artifact_scannable(None)
def test_is_artifact_scannable_empty(self):
"""An empty media type is not scannable."""
assert not ECR._is_artifact_scannable("")
def test_is_artifact_scannable_non_scannable_tags(self):
"""A signature-tagged artifact is not scannable."""
assert not ECR._is_artifact_scannable("", ["sha256-abcdefg123456.sig"])
def test_is_artifact_scannable_scannable_tags(self):
"""A normally-tagged artifact is scannable."""
assert ECR._is_artifact_scannable(
"application/vnd.docker.container.image.v1+json", ["abcdefg123456"]
)
@mock_aws
def test_get_image_scan_data_selects_only_latest_image_per_repository(self):
"""Only the latest image per repository is selected for scanning."""
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client_boto.create_repository(
repositoryName=repo_name,
imageScanningConfiguration={"scanOnPush": True},
)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
# Sanity check: this repository has several scannable tagged images.
assert len(repository.images_details) == 4
results = list(ecr._get_image_scan_data())
# Only the most recently pushed image is selected, not all four.
assert len(results) == 1
fetched_repository, fetched_image, _ = results[0]
assert fetched_repository.name == repo_name
assert fetched_image.latest_tag == "test-tag4"
assert (
fetched_image.latest_digest
== "sha256:43251ac64627fc331584f6c498b3aba5badc01574e2c70b2499af3af16630eed"
)
@mock_aws
def test_get_image_scan_data_covers_scan_on_push_disabled_repository(self):
"""A scan-on-push-disabled repo (empty images_details) is still scanned."""
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client_boto.create_repository(
repositoryName=repo_name,
imageScanningConfiguration={"scanOnPush": False},
)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
# Scan-on-push disabled: the metadata pass leaves images_details empty...
assert repository.scan_on_push is False
assert repository.images_details == []
# ...yet the secret-scan path resolves the latest image via a dedicated
# describe_images lookup, so the repository is not silently skipped.
results = list(ecr._get_image_scan_data())
assert len(results) == 1
fetched_repository, fetched_image, _ = results[0]
assert fetched_repository.name == repo_name
assert fetched_image.latest_tag == "test-tag4"
# The dedicated lookup must NOT mutate the shared images_details, or
# other checks would treat this repo as having a scanned image.
assert repository.images_details == []
@mock_aws
def test_get_image_scan_data_bounds_submitted_futures(self):
"""Image fetches are submitted only as earlier results are consumed."""
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
for index in range(10):
ecr_client_boto.create_repository(
repositoryName=f"{repo_name}-{index}",
imageScanningConfiguration={"scanOnPush": True},
)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
repositories = ecr.registries[AWS_REGION_EU_WEST_1].repositories
executor = MagicMock()
executor.__enter__.return_value = executor
futures = []
def submit(*_args):
future = Future()
futures.append(future)
if len(futures) == 1:
future.set_result(None)
return future
executor.submit.side_effect = submit
with patch(
"prowler.providers.aws.services.ecr.ecr_service.ThreadPoolExecutor",
return_value=executor,
):
results = ecr._get_image_scan_data()
first_result = next(results)
assert first_result[0] == repositories[0]
assert (
first_result[1].latest_digest
== repositories[0].images_details[-1].latest_digest
)
assert executor.submit.call_count == 4
@mock_aws
def test_get_scan_target_image_ignores_stale_scanned_image(self):
"""Secret scanning selects a newer image absent from scan findings."""
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client_boto.create_repository(
repositoryName=repo_name,
imageScanningConfiguration={"scanOnPush": True},
)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
older_scanned_image = repository.images_details[0]
repository.images_details = [older_scanned_image]
target = ecr._get_scan_target_image(repository)
assert target.latest_tag == "test-tag4"
assert target.image_pushed_at > older_scanned_image.image_pushed_at
@mock_aws
def test_get_scan_target_image_lookup_failure_rejects_stale_image(self):
"""A failed authoritative lookup does not select cached scan metadata."""
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
ecr_client_boto.create_repository(
repositoryName=repo_name,
imageScanningConfiguration={"scanOnPush": True},
)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ecr = ECR(aws_provider)
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
repository.images_details = [repository.images_details[0]]
with patch.object(
ecr.regional_clients[AWS_REGION_EU_WEST_1],
"get_paginator",
side_effect=RuntimeError("authoritative lookup failed"),
):
target = ecr._get_scan_target_image(repository)
scan_results = list(ecr._get_image_scan_data())
assert isinstance(target, RuntimeError)
assert len(scan_results) == 1
_, result_image, result_error = scan_results[0]
assert result_image is None and isinstance(result_error, RuntimeError)
@@ -0,0 +1,512 @@
import gzip
import json
import random
import tarfile
from io import BytesIO
from unittest.mock import patch
import botocore
import pytest
import zstandard
from boto3 import client
from moto import mock_aws
from prowler.providers.aws.services.ecr.image_inspection import (
MAX_FILE_BYTES,
MAX_LAYER_DOWNLOAD_BYTES,
ImageInspector,
_CappedLayerReader,
_LayerTooLargeError,
)
from tests.providers.aws.services.ecr.image_scan_fixtures import (
BLOBS_BY_DIGEST,
CHILD_AMD64_DIGEST,
CHILD_ARM64_DIGEST,
CONFIG_DIGEST,
CONFIG_JSON,
IMAGE_DIGEST,
LAYER_DIGEST,
MANIFESTS_BY_DIGEST,
MULTI_ARCH_INDEX_DIGEST,
MULTI_ARCH_MANIFEST_LIST,
SIMPLE_MANIFEST,
build_gzip_tar,
build_tar,
mock_requests_get,
reset_image_fixtures,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
)
REPO_NAME = "test-repo"
_original_make_api_call = botocore.client.BaseClient._make_api_call
_REQUESTS_GET = "prowler.providers.aws.services.ecr.image_inspection.requests.get"
def mock_make_api_call(self, operation_name, kwarg):
"""Serve BatchGetImage/GetDownloadUrlForLayer from fixtures; delegate the rest.
moto implements neither operation, so they are answered from the per-test
``MANIFESTS_BY_DIGEST`` fixtures; every other call falls through to the real
(moto-backed) implementation.
"""
if operation_name == "BatchGetImage":
digest = kwarg["imageIds"][0]["imageDigest"]
manifest = MANIFESTS_BY_DIGEST.get(digest)
if manifest is None:
return {
"images": [],
"failures": [
{
"imageId": {"imageDigest": digest},
"failureCode": "ImageNotFound",
}
],
}
return {
"images": [
{
"imageManifest": json.dumps(manifest),
"imageManifestMediaType": manifest.get("mediaType", ""),
}
]
}
if operation_name == "GetDownloadUrlForLayer":
digest = kwarg["layerDigest"]
return {"downloadUrl": f"https://layers.example.com/{digest}"}
return _original_make_api_call(self, operation_name, kwarg)
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
class Test_ImageInspector:
"""Tests for the bounded image-content extraction in image_inspection."""
@pytest.fixture(autouse=True)
def _reset_image_fixtures(self):
"""Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test."""
reset_image_fixtures()
yield
reset_image_fixtures()
@staticmethod
def _fetch(digest=IMAGE_DIGEST):
"""Fetch scan data for one image, with the layer download stubbed."""
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
with patch(_REQUESTS_GET, new=mock_requests_get):
return ImageInspector().fetch_image_scan_data(
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, digest
)
@mock_aws
def test_fetch_image_scan_data_simple_image(self):
"""A single-manifest image's config and layer file are scanned."""
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"})
scan_data = self._fetch()
assert scan_data is not None
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
assert scan_data.history == ["/bin/sh -c #(nop) ADD file", "RUN echo hi"]
assert len(scan_data.files) == 1
assert scan_data.files[0].path == "app/config.py"
assert scan_data.files[0].layer_digest == LAYER_DIGEST
assert scan_data.files[0].content == "TOKEN = 'x'"
assert scan_data.truncated is False
@mock_aws
def test_fetch_image_scan_data_resolves_multi_arch_manifest(self):
"""A multi-arch scan is incomplete when only one child is inspected."""
MANIFESTS_BY_DIGEST[MULTI_ARCH_INDEX_DIGEST] = MULTI_ARCH_MANIFEST_LIST
MANIFESTS_BY_DIGEST[CHILD_AMD64_DIGEST] = SIMPLE_MANIFEST
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"})
scan_data = self._fetch(digest=MULTI_ARCH_INDEX_DIGEST)
# Only the amd64/linux child manifest is resolved and scanned; the
# arm64 and attestation entries in the manifest list are ignored.
assert scan_data is not None
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
assert len(scan_data.files) == 1
assert scan_data.files[0].path == "app/config.py"
assert scan_data.truncated is True
@mock_aws
def test_fetch_image_scan_data_skips_oversized_layer(self):
"""A layer over the size cap is skipped, not downloaded."""
oversized_manifest = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": CONFIG_DIGEST, "size": 10},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": LAYER_DIGEST,
"size": MAX_LAYER_DOWNLOAD_BYTES + 1,
}
],
}
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = oversized_manifest
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
scan_data = self._fetch()
# The oversized layer is never downloaded (only the config blob is in
# BLOBS_BY_DIGEST), yet the fetch completes without raising.
assert scan_data is not None
assert scan_data.files == []
assert scan_data.truncated is True
@mock_aws
def test_fetch_image_scan_data_manifest_not_found_returns_none(self):
"""An unknown digest resolves to no scan data."""
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
scan_data = ImageInspector().fetch_image_scan_data(
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, f"sha256:{'f' * 64}"
)
assert scan_data is None
@mock_aws
def test_fetch_image_scan_data_config_download_failure_marks_truncated(self):
"""A config blob that cannot be retrieved marks coverage incomplete.
Empty env/history would otherwise be indistinguishable from a clean
config, so the fetch flags the result as truncated rather than risking
a false PASS at the check level.
"""
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
# Register the layer but NOT the config blob, so the config download
# fails while the layer is still scanned cleanly.
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "clean"})
scan_data = self._fetch()
assert scan_data is not None
assert scan_data.env == []
assert scan_data.history == []
assert scan_data.truncated is True
assert [f.path for f in scan_data.files] == ["app/config.py"]
@mock_aws
def test_fetch_image_scan_data_member_over_remaining_budget_is_truncated(self):
"""A layer exceeding the remaining stream budget is truncated.
Tar headers and padding consume the authoritative decompressed-byte
budget before member payloads are exposed for scanning.
"""
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(
{"app/first.txt": "a" * 100, "app/second.txt": "b" * 5000}
)
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
with (
patch(_REQUESTS_GET, new=mock_requests_get),
patch(
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
1000,
),
):
scan_data = ImageInspector().fetch_image_scan_data(
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
)
assert scan_data is not None
assert scan_data.files == []
assert scan_data.truncated is True
@mock_aws
def test_fetch_image_scan_data_oversized_members_count_toward_budget(self):
"""Members skipped for size still count toward the per-image budget.
A streaming reader must decompress each member to advance past it, so
oversized-and-skipped members must still consume budget; otherwise a
layer of many just-over-limit files would decompress unbounded. The
loop must stop before reaching a later scannable member.
"""
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
# Ten 300-byte members (each over the patched 100-byte MAX_FILE_BYTES,
# so each is skipped for content) followed by a small, scannable file.
layer = {f"app/big{i}.bin": "x" * 300 for i in range(10)}
layer["app/reachable.txt"] = "hello"
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(layer)
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
with (
patch(_REQUESTS_GET, new=mock_requests_get),
patch(
"prowler.providers.aws.services.ecr.image_inspection.MAX_FILE_BYTES",
100,
),
patch(
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
1000,
),
):
scan_data = ImageInspector().fetch_image_scan_data(
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
)
assert scan_data is not None
# The oversized members exhaust the 1000-byte budget after ~3 of them,
# so the loop stops before ever reaching app/reachable.txt. If skipped
# members were not counted, reachable.txt would be scanned.
assert scan_data.files == []
assert scan_data.truncated is True
@mock_aws
def test_fetch_image_scan_data_tar_over_stream_budget_is_truncated(self):
"""Tar headers, padding, and non-files consume the image byte budget."""
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
layer = BytesIO()
with tarfile.open(fileobj=layer, mode="w") as archive:
for index in range(20):
directory = tarfile.TarInfo(f"metadata-{index}/")
directory.type = tarfile.DIRTYPE
archive.addfile(directory)
content = b"x"
member = tarfile.TarInfo("app/reachable.txt")
member.size = len(content)
archive.addfile(member, BytesIO(content))
BLOBS_BY_DIGEST[LAYER_DIGEST] = gzip.compress(layer.getvalue())
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
with (
patch(_REQUESTS_GET, new=mock_requests_get),
patch(
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
10 * 1024 - 1,
),
):
scan_data = ImageInspector().fetch_image_scan_data(
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
)
assert scan_data is not None
assert scan_data.files == []
assert scan_data.truncated is True
def test_select_child_manifest_digest_falls_back_to_non_amd64(self):
"""With no amd64/linux entry, the first non-attestation candidate is picked."""
manifest_list = {
"manifests": [
{
"digest": CHILD_ARM64_DIGEST,
"platform": {"architecture": "arm64", "os": "linux"},
},
{
"digest": f"sha256:{'5' * 64}",
"platform": {"architecture": "unknown", "os": "unknown"},
"annotations": {
"vnd.docker.reference.type": "attestation-manifest"
},
},
]
}
digest = ImageInspector._select_child_manifest_digest(manifest_list)
assert digest == CHILD_ARM64_DIGEST
@mock_aws
def test_fetch_image_scan_data_zstd_layer(self):
"""A zstd-compressed layer is streamed, decompressed, and scanned."""
zstd_manifest = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": CONFIG_DIGEST, "size": 100},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
"digest": LAYER_DIGEST,
"size": 200,
}
],
}
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = zstandard.ZstdCompressor().compress(
build_tar({"app/config.py": "TOKEN = 'x'"})
)
scan_data = self._fetch()
assert scan_data is not None
assert len(scan_data.files) == 1
assert scan_data.files[0].content == "TOKEN = 'x'"
@mock_aws
def test_fetch_image_scan_data_zstd_layer_over_compressed_cap_is_truncated(self):
"""A zstd layer whose compressed bytes exceed the cap is truncated.
The streaming decompressor reads through a _CappedLayerReader, so a
layer whose compressed size exceeds MAX_LAYER_DOWNLOAD_BYTES (patched
here) is cut off and disclosed via truncated instead of being buffered
or decompressed unbounded.
"""
zstd_manifest = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": CONFIG_DIGEST, "size": 100},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
"digest": LAYER_DIGEST,
# Declares zero size so it passes the pre-download check;
# the actual compressed bytes exceed the (patched) cap.
"size": 0,
}
],
}
# Incompressible payload (so the compressed frame stays large), split
# across two members so the first is read before the cap trips while
# the second is streamed.
rng = random.Random(0)
incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode(
"latin-1"
)
layer_blob = zstandard.ZstdCompressor().compress(
build_tar({"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible})
)
assert len(layer_blob) > 10 * 1024
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
with (
patch(_REQUESTS_GET, new=mock_requests_get),
patch(
"prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES",
len(layer_blob) - 1,
),
):
scan_data = ImageInspector().fetch_image_scan_data(
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
)
assert scan_data is not None
assert scan_data.truncated is True
@mock_aws
def test_fetch_image_scan_data_uncompressed_tar_layer(self):
"""An uncompressed tar layer is read and scanned directly."""
tar_manifest = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": CONFIG_DIGEST, "size": 100},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": LAYER_DIGEST,
"size": 200,
}
],
}
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = tar_manifest
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_tar({"app/config.py": "TOKEN = 'x'"})
scan_data = self._fetch()
assert scan_data is not None
assert len(scan_data.files) == 1
assert scan_data.files[0].content == "TOKEN = 'x'"
@mock_aws
def test_fetch_image_scan_data_skips_whiteout_and_oversized_file(self):
"""Whiteout markers and oversized files are skipped, not scanned."""
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(
{
".wh.deleted": "should never appear",
"app/config.py": "TOKEN = 'x'",
"app/oversized.bin": "x" * (MAX_FILE_BYTES + 1),
}
)
scan_data = self._fetch()
assert scan_data is not None
assert [f.path for f in scan_data.files] == ["app/config.py"]
assert scan_data.truncated is True
def test_capped_layer_reader_allows_up_to_max(self):
"""Reading exactly the byte budget succeeds and then reports EOF."""
import io
reader = _CappedLayerReader(io.BytesIO(b"x" * 10), max_bytes=10)
assert reader.read() == b"x" * 10
assert reader.read() == b""
def test_capped_layer_reader_raises_when_exceeding_max(self):
"""A stream longer than the byte budget raises _LayerTooLargeError."""
import io
reader = _CappedLayerReader(io.BytesIO(b"x" * 100), max_bytes=10)
with pytest.raises(_LayerTooLargeError):
reader.read()
@mock_aws
def test_fetch_image_scan_data_streamed_layer_over_cap_is_truncated(self):
"""A layer streaming past MAX_LAYER_DOWNLOAD_BYTES is skipped, not buffered."""
undersized_manifest = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {"digest": CONFIG_DIGEST, "size": 100},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": LAYER_DIGEST,
# Declares zero size so it passes the pre-download check;
# the actual streamed bytes exceed the (patched) cap.
"size": 0,
}
],
}
# Two-member layer: a small first file that is scanned, then a large
# incompressible second file. The blob must exceed tarfile's internal
# read buffer (~10 KB) so tarfile.open() consumes only part of it and
# the cap (set one byte below the full layer) is instead exceeded while
# the second member's content is read during archive iteration.
rng = random.Random(0)
incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode(
"latin-1"
)
layer_blob = build_gzip_tar(
{"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible}
)
assert len(layer_blob) > 10 * 1024 # larger than tarfile's read buffer
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = undersized_manifest
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
with (
patch(_REQUESTS_GET, new=mock_requests_get),
patch(
"prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES",
len(layer_blob) - 1,
),
):
scan_data = ImageInspector().fetch_image_scan_data(
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
)
# The over-cap layer is disclosed via truncated, and the config-derived
# env/history (fetched independently of the layer) are still returned.
assert scan_data is not None
assert scan_data.truncated is True
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
@@ -0,0 +1,145 @@
"""Shared fixtures for ECR image-scanning tests.
Used by both the ECR service tests (orchestration) and the image_inspection
tests (bounded extraction), so the manifest/layer fixtures and the fake layer
download live in one place. moto implements neither BatchGetImage nor
GetDownloadUrlForLayer, so each test registers the manifests/blobs it needs in
``MANIFESTS_BY_DIGEST``/``BLOBS_BY_DIGEST`` and a patched ``_make_api_call``
serves them.
"""
import io
import tarfile
IMAGE_DIGEST = f"sha256:{'1' * 64}"
CONFIG_DIGEST = f"sha256:{'c' * 64}"
LAYER_DIGEST = f"sha256:{'d' * 64}"
MULTI_ARCH_INDEX_DIGEST = f"sha256:{'2' * 64}"
CHILD_AMD64_DIGEST = f"sha256:{'3' * 64}"
CHILD_ARM64_DIGEST = f"sha256:{'4' * 64}"
ATTESTATION_DIGEST = f"sha256:{'5' * 64}"
SIMPLE_MANIFEST = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"digest": CONFIG_DIGEST,
"size": 100,
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": LAYER_DIGEST,
"size": 200,
}
],
}
MULTI_ARCH_MANIFEST_LIST = {
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"digest": CHILD_AMD64_DIGEST,
"size": 10,
"platform": {"architecture": "amd64", "os": "linux"},
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"digest": CHILD_ARM64_DIGEST,
"size": 10,
"platform": {"architecture": "arm64", "os": "linux"},
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": ATTESTATION_DIGEST,
"size": 10,
"platform": {"architecture": "unknown", "os": "unknown"},
"annotations": {"vnd.docker.reference.type": "attestation-manifest"},
},
],
}
CONFIG_JSON = {
"config": {"Env": ["PATH=/usr/bin", "TOKEN=super-secret-value"]},
"history": [
{"created_by": "/bin/sh -c #(nop) ADD file"},
{"created_by": "RUN echo hi"},
],
}
# Per-test fixtures keyed by digest. moto implements neither BatchGetImage nor
# GetDownloadUrlForLayer, so tests populate these and a patched _make_api_call /
# requests.get serves them. Cleared between tests via reset_image_fixtures().
MANIFESTS_BY_DIGEST = {}
BLOBS_BY_DIGEST = {}
def reset_image_fixtures():
"""Clear the per-test manifest/blob fixtures."""
MANIFESTS_BY_DIGEST.clear()
BLOBS_BY_DIGEST.clear()
def build_tar(files: dict) -> bytes:
"""Build an uncompressed tar archive from the given files."""
tar_buffer = io.BytesIO()
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
for name, content in files.items():
data = content.encode("latin-1")
info = tarfile.TarInfo(name=name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return tar_buffer.getvalue()
def build_gzip_tar(files: dict) -> bytes:
"""Build a gzip-compressed tar archive from the given files."""
tar_buffer = io.BytesIO()
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
for name, content in files.items():
data = content.encode("latin-1")
info = tarfile.TarInfo(name=name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return tar_buffer.getvalue()
class FakeLayerResponse:
"""A minimal stand-in for a requests.Response over a layer download."""
def __init__(self, data: bytes):
"""Store the fixture bytes to serve (via iter_content and .raw)."""
self._data = data
# Streaming gzip/tar layers read the compressed bytes straight from
# response.raw; the buffered config/zstd path uses iter_content.
self.raw = io.BytesIO(data)
def raise_for_status(self):
"""No-op: fixture responses are always successful."""
def iter_content(self, chunk_size=1024 * 1024):
"""Yield the fixture bytes in chunks."""
for start in range(0, len(self._data), chunk_size):
yield self._data[start : start + chunk_size]
def close(self):
"""Close the backing raw stream, mirroring requests.Response.close."""
self.raw.close()
def __enter__(self):
"""Support use as a context manager."""
return self
def __exit__(self, *_):
"""Close on exit, mirroring requests.Response context-manager use."""
self.close()
return False
def mock_requests_get(url, **_):
"""Return the fixture bytes registered for the requested layer's URL."""
digest = url.rsplit("/", 1)[-1]
return FakeLayerResponse(BLOBS_BY_DIGEST[digest])
@@ -0,0 +1,164 @@
from datetime import datetime, timezone
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import patch
import botocore
import pytest
from prowler.providers.aws.services.ecs.ecs_service import ECS, TaskDefinition
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
TASK_NAME = "test-task"
TASK_REVISION = "1"
TASK_ARN = (
f"arn:aws:ecs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:"
f"task-definition/{TASK_NAME}:{TASK_REVISION}"
)
make_api_call = botocore.client.BaseClient._make_api_call
def _mock_ecs_api(describe_result):
def mock_make_api_call(self, operation_name, kwargs):
if operation_name == "ListTaskDefinitions":
return {"taskDefinitionArns": [TASK_ARN]}
if operation_name == "DescribeTaskDefinition":
if isinstance(describe_result, Exception):
raise describe_result
return describe_result
if operation_name == "ListClusters":
return {"clusterArns": []}
return make_api_call(self, operation_name, kwargs)
return mock_make_api_call
def _collect_task_definition(describe_result):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with patch(
"botocore.client.BaseClient._make_api_call",
new=_mock_ecs_api(describe_result),
):
return ECS(aws_provider).task_definitions[TASK_ARN]
def _undescribed_ecs_client():
task_definition = TaskDefinition(
name=TASK_NAME,
arn=TASK_ARN,
revision=TASK_REVISION,
region=AWS_REGION_US_EAST_1,
environment_variables=[],
)
task_definition.container_definitions = None
return SimpleNamespace(
audit_config={},
task_definitions={TASK_ARN: task_definition},
)
def test_failed_describe_leaves_task_definition_undescribed():
error = botocore.exceptions.ClientError(
{"Error": {"Code": "ThrottlingException", "Message": "rate exceeded"}},
"DescribeTaskDefinition",
)
task_definition = _collect_task_definition(error)
assert task_definition.container_definitions is None
assert task_definition.pid_mode is None
assert task_definition.network_mode is None
def test_successful_describe_preserves_empty_container_definitions():
task_definition = _collect_task_definition(
{
"taskDefinition": {
"containerDefinitions": [],
"pidMode": "task",
"networkMode": "awsvpc",
},
"tags": [],
}
)
assert task_definition.container_definitions == []
assert task_definition.pid_mode == "task"
assert task_definition.network_mode == "awsvpc"
def test_partial_parse_leaves_task_definition_undescribed():
task_definition = _collect_task_definition(
{
"taskDefinition": {
"containerDefinitions": [
{"name": "valid-container"},
{"privileged": False},
],
"pidMode": "host",
"networkMode": "host",
"registeredAt": datetime(2026, 8, 13, tzinfo=timezone.utc),
},
"tags": [{"key": "Environment", "value": "production"}],
}
)
assert task_definition.container_definitions is None
assert task_definition.pid_mode is None
assert task_definition.network_mode is None
assert task_definition.registered_at is None
assert task_definition.tags == []
@pytest.mark.parametrize(
("check_package", "check_name"),
[
(
"ecs_task_definitions_containers_readonly_access",
"ecs_task_definitions_containers_readonly_access",
),
(
"ecs_task_definitions_host_namespace_not_shared",
"ecs_task_definitions_host_namespace_not_shared",
),
(
"ecs_task_definitions_host_networking_mode_users",
"ecs_task_definitions_host_networking_mode_users",
),
(
"ecs_task_definitions_logging_block_mode",
"ecs_task_definitions_logging_block_mode",
),
(
"ecs_task_definitions_logging_enabled",
"ecs_task_definitions_logging_enabled",
),
(
"ecs_task_definitions_no_environment_secrets",
"ecs_task_definitions_no_environment_secrets",
),
(
"ecs_task_definitions_no_privileged_containers",
"ecs_task_definitions_no_privileged_containers",
),
],
)
def test_undescribed_task_definitions_are_not_reported(
check_package, check_name, monkeypatch
):
with patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
):
module = import_module(
f"prowler.providers.aws.services.ecs.{check_package}.{check_name}"
)
monkeypatch.setattr(module, "ecs_client", _undescribed_ecs_client())
check = getattr(module, check_name)()
assert check.execute() == []
@@ -0,0 +1,762 @@
from types import SimpleNamespace
from unittest import mock
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import Profile
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
PROFILE_NAME = "workload-profile"
PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}"
ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-role"
READONLY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role"
UNKNOWN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/cross-account-role"
CUSTOM_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/custom-admin-role"
INLINE_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/inline-admin-role"
NAME_COLLISION_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/name-collision-role"
UNRESOLVED_POLICY_ROLE_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-policy-role"
)
ADMIN_UNRESOLVED_POLICY_ROLE_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-unresolved-policy-role"
)
INVALID_POLICY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/invalid-policy-role"
DENY_OVERRIDE_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/deny-override-role"
CONDITIONAL_DENY_ROLE_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-deny-role"
)
CONDITIONAL_ADMIN_ROLE_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-admin-role"
)
BOUNDED_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/bounded-admin-role"
UNRESOLVED_BOUNDARY_ROLE_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-boundary-role"
)
ADMIN_BOUNDED_ROLE_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-bounded-admin-role"
)
AWS_ADMIN_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess"
CUSTOMER_ADMIN_NAMED_POLICY_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AdministratorAccess"
)
CUSTOM_ADMIN_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/custom-admin"
MANAGED_POLICY_ARN = "arn:aws:iam::aws:policy/ReadOnlyAccess"
CUSTOMER_FULL_ACCESS_POLICY_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/full-access-session"
)
BOUNDARY_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/scoped-boundary"
UNRESOLVED_BOUNDARY_POLICY_ARN = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved-boundary"
)
UNRESOLVED_SESSION_POLICY_ARN = (
"arn:aws:iam::aws:policy/job-function/SupportUser" # not in iam_client.policies
)
SESSION_POLICY = (
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow",'
'"Action":["s3:GetObject"],"Resource":["*"]}]}'
)
FULL_ACCESS_SESSION_POLICY = (
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow",'
'"Action":"*","Resource":"*"}]}'
)
FULL_ACCESS_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}],
}
READONLY_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "s3:Get*", "Resource": "*"}],
}
DENY_ALL_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}],
}
CONDITIONAL_ADMIN_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*",
"Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}},
}
],
}
CONDITIONAL_DENY_DOCUMENT = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {"StringNotEquals": {"aws:PrincipalTag/team": "security"}},
}
],
}
def _profile(
*,
enabled: bool = True,
session_policy: str = "",
managed_policy_arns=None,
role_arns=None,
):
return Profile(
arn=PROFILE_ARN,
id=PROFILE_ID,
name=PROFILE_NAME,
region=AWS_REGION_US_EAST_1,
enabled=enabled,
role_arns=role_arns if role_arns is not None else [READONLY_ROLE_ARN],
session_policy=session_policy,
managed_policy_arns=managed_policy_arns or [],
)
def _role(arn, attached_policies=None, inline_policies=None, permissions_boundary=None):
return SimpleNamespace(
arn=arn,
attached_policies=attached_policies or [],
inline_policies=inline_policies or [],
permissions_boundary=permissions_boundary,
)
def _iam_client():
"""IAM client stub mirroring iam_service models: roles with attached/inline
policies and a policies dict keyed by ARN (inline keyed {role_arn}:policy/{name}).
"""
roles = [
_role(
ADMIN_ROLE_ARN,
attached_policies=[
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
],
),
_role(
READONLY_ROLE_ARN,
attached_policies=[
{"PolicyName": "ReadOnlyAccess", "PolicyArn": MANAGED_POLICY_ARN}
],
),
_role(
CUSTOM_ADMIN_ROLE_ARN,
attached_policies=[
{"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN}
],
),
_role(INLINE_ADMIN_ROLE_ARN, inline_policies=["inline-admin"]),
_role(
NAME_COLLISION_ROLE_ARN,
attached_policies=[
{
"PolicyName": "AdministratorAccess",
"PolicyArn": CUSTOMER_ADMIN_NAMED_POLICY_ARN,
}
],
),
_role(
UNRESOLVED_POLICY_ROLE_ARN,
attached_policies=[
{
"PolicyName": "unresolved",
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved",
}
],
),
# Proven admin attached policy combined with an unresolved one: the
# unresolved document could contain a deny, so the outcome is unknown.
_role(
ADMIN_UNRESOLVED_POLICY_ROLE_ARN,
attached_policies=[
{
"PolicyName": "AdministratorAccess",
"PolicyArn": AWS_ADMIN_POLICY_ARN,
},
{
"PolicyName": "unresolved",
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved",
},
],
),
# Attached policy resolves to a malformed (non-dict) document.
_role(
INVALID_POLICY_ROLE_ARN,
attached_policies=[
{
"PolicyName": "invalid",
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid",
}
],
),
# Allow *:* in the attached policy negated by an unconditional Deny *:*
# in an inline policy: not effectively administrative.
_role(
DENY_OVERRIDE_ROLE_ARN,
attached_policies=[
{"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN}
],
inline_policies=["deny-all"],
),
# Allow *:* in the attached policy plus a Condition-guarded Deny: the
# deny may or may not apply, so the outcome is unknown.
_role(
CONDITIONAL_DENY_ROLE_ARN,
attached_policies=[
{"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN}
],
inline_policies=["conditional-deny"],
),
# Allow *:* guarded by a Condition: not statically provable as admin.
_role(
CONDITIONAL_ADMIN_ROLE_ARN,
attached_policies=[
{
"PolicyName": "conditional-admin",
"PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin",
}
],
),
# Administrative identity policies constrained by a restrictive
# permissions boundary: not effectively administrative.
_role(
BOUNDED_ADMIN_ROLE_ARN,
attached_policies=[
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
],
permissions_boundary={
"PermissionsBoundaryType": "Policy",
"PermissionsBoundaryArn": BOUNDARY_POLICY_ARN,
},
),
# Boundary present but its document is not in the IAM inventory:
# restrictions cannot be evaluated, so the role is not classified admin.
_role(
UNRESOLVED_BOUNDARY_ROLE_ARN,
attached_policies=[
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
],
permissions_boundary={
"PermissionsBoundaryType": "Policy",
"PermissionsBoundaryArn": UNRESOLVED_BOUNDARY_POLICY_ARN,
},
),
# AdministratorAccess as the boundary does not restrict anything.
_role(
ADMIN_BOUNDED_ROLE_ARN,
attached_policies=[
{"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN}
],
permissions_boundary={
"PermissionsBoundaryType": "Policy",
"PermissionsBoundaryArn": AWS_ADMIN_POLICY_ARN,
},
),
]
policies = {
AWS_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT),
CUSTOM_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT),
MANAGED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT),
# Customer-managed policy that merely shares the AdministratorAccess name.
CUSTOMER_ADMIN_NAMED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT),
f"{INLINE_ADMIN_ROLE_ARN}:policy/inline-admin": SimpleNamespace(
document=FULL_ACCESS_DOCUMENT
),
f"{DENY_OVERRIDE_ROLE_ARN}:policy/deny-all": SimpleNamespace(
document=DENY_ALL_DOCUMENT
),
f"{CONDITIONAL_DENY_ROLE_ARN}:policy/conditional-deny": SimpleNamespace(
document=CONDITIONAL_DENY_DOCUMENT
),
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid": SimpleNamespace(
document="invalid"
),
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin": SimpleNamespace(
document=CONDITIONAL_ADMIN_DOCUMENT
),
BOUNDARY_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT),
# Customer-managed session policy whose document grants *:*.
CUSTOMER_FULL_ACCESS_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT),
}
iam = mock.MagicMock()
iam.roles = roles
iam.policies = policies
return iam
def _build_client(profiles):
ra_client = mock.MagicMock()
ra_client.profiles = profiles
return ra_client
def _patched(ra_client):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
check_module = "prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions"
return [
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(f"{check_module}.rolesanywhere_client", new=ra_client),
mock.patch(f"{check_module}.iam_client", new=_iam_client()),
]
def _enter(patches):
from contextlib import ExitStack
stack = ExitStack()
for p in patches:
stack.enter_context(p)
return stack
def _run():
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions import (
rolesanywhere_profile_restricts_session_permissions,
)
return rolesanywhere_profile_restricts_session_permissions().execute()
class Test_rolesanywhere_profile_restricts_session_permissions:
def test_no_profiles(self):
with _enter(_patched(_build_client({}))):
assert len(_run()) == 0
def test_unscoped_profile_with_admin_role_fails(self):
with _enter(
_patched(_build_client({PROFILE_ARN: _profile(role_arns=[ADMIN_ROLE_ARN])}))
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == PROFILE_ID
assert result[0].resource_arn == PROFILE_ARN
assert result[0].region == AWS_REGION_US_EAST_1
assert ADMIN_ROLE_ARN in result[0].status_extended
assert "administrative" in result[0].status_extended
def test_unscoped_profile_with_custom_admin_policy_fails(self):
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[CUSTOM_ADMIN_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert CUSTOM_ADMIN_ROLE_ARN in result[0].status_extended
def test_unscoped_profile_with_inline_admin_policy_fails(self):
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[INLINE_ADMIN_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert INLINE_ADMIN_ROLE_ARN in result[0].status_extended
def test_mixed_roles_fail_lists_only_admin_role(self):
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
role_arns=[READONLY_ROLE_ARN, ADMIN_ROLE_ARN]
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert ADMIN_ROLE_ARN in result[0].status_extended
assert READONLY_ROLE_ARN not in result[0].status_extended
def test_customer_policy_named_administratoraccess_passes(self):
# Name collision: customer-managed policy called AdministratorAccess
# whose document is read-only must not flag the role as administrative.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[NAME_COLLISION_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
def test_unresolved_attached_policy_is_manual(self):
# Attached policy ARN missing from iam_client.policies: a missing
# document is unknown, not proof that the role is unprivileged.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[UNRESOLVED_POLICY_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be evaluated" in result[0].status_extended
def test_invalid_policy_document_is_manual(self):
# A malformed policy document cannot prove anything about the role.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[INVALID_POLICY_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
def test_allow_all_with_unresolved_policy_is_manual(self):
# Proven admin policy plus an unresolved one: the unresolved document
# could contain a deny, so the classification is unknown.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
role_arns=[ADMIN_UNRESOLVED_POLICY_ROLE_ARN]
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
def test_unscoped_profile_with_least_privilege_role_passes(self):
with _enter(
_patched(
_build_client({PROFILE_ARN: _profile(role_arns=[READONLY_ROLE_ARN])})
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
assert "defense-in-depth" in result[0].status_extended
def test_unscoped_profile_with_unknown_role_is_manual(self):
# A referenced role missing from the IAM inventory is unknown, not
# proof that no administrative role exists.
with _enter(
_patched(
_build_client({PROFILE_ARN: _profile(role_arns=[UNKNOWN_ROLE_ARN])})
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be evaluated" in result[0].status_extended
def test_unscoped_profile_without_roles_passes(self):
with _enter(_patched(_build_client({PROFILE_ARN: _profile(role_arns=[])}))):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
def test_profile_with_session_policy_passes(self):
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
session_policy=SESSION_POLICY, role_arns=[ADMIN_ROLE_ARN]
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
assert "session policy" in result[0].status_extended
def test_full_access_session_policy_does_not_scope(self):
# A sessionPolicy granting *:* does not restrict anything.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
session_policy=FULL_ACCESS_SESSION_POLICY,
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_profile_with_managed_policies_passes(self):
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
managed_policy_arns=[MANAGED_POLICY_ARN],
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
def test_admin_managed_session_policy_does_not_scope(self):
# AdministratorAccess as the managed session policy restricts nothing.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
managed_policy_arns=[AWS_ADMIN_POLICY_ARN],
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_restrictive_inline_with_admin_managed_policy_fails(self):
# The session-policy set is evaluated as a union: AdministratorAccess as
# a managed session policy makes the boundary unrestricted even though
# the inline session policy is restrictive.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
session_policy=SESSION_POLICY,
managed_policy_arns=[AWS_ADMIN_POLICY_ARN],
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_full_access_inline_with_restrictive_managed_policy_fails(self):
# Conversely, a *:* inline session policy leaves the union unrestricted
# regardless of a restrictive managed session policy.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
session_policy=FULL_ACCESS_SESSION_POLICY,
managed_policy_arns=[MANAGED_POLICY_ARN],
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_restrictive_inline_and_restrictive_managed_policy_passes(self):
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
session_policy=SESSION_POLICY,
managed_policy_arns=[MANAGED_POLICY_ARN],
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
def test_customer_managed_full_access_session_policy_fails(self):
# A customer-managed session policy whose document grants *:* must be
# resolved through iam_client.policies and treated as unscoped.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
managed_policy_arns=[CUSTOMER_FULL_ACCESS_POLICY_ARN],
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_unresolved_managed_session_policy_is_manual(self):
# A managed session policy whose document was not collected does not
# prove that the session is restricted: the outcome is unknown.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
managed_policy_arns=[UNRESOLVED_SESSION_POLICY_ARN],
role_arns=[ADMIN_ROLE_ARN],
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "session scoping could not be evaluated" in result[0].status_extended
def test_invalid_inline_session_policy_is_manual(self):
# An inline session policy that fails to parse does not prove that the
# session is restricted: the outcome is unknown.
with _enter(
_patched(
_build_client(
{
PROFILE_ARN: _profile(
session_policy="not-json", role_arns=[ADMIN_ROLE_ARN]
)
}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
def test_allow_all_with_cross_policy_deny_all_passes(self):
# Allow *:* in an attached policy plus an unconditional Deny *:* in an
# inline policy: the merged evaluation must not classify the role admin.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[DENY_OVERRIDE_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
def test_conditional_admin_allow_is_manual(self):
# An Allow *:* guarded by a Condition is not statically provable in
# either direction: the classification is unknown.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[CONDITIONAL_ADMIN_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
def test_allow_all_with_conditional_deny_is_manual(self):
# Unconditional Allow *:* plus a Condition-guarded Deny: the deny may
# or may not negate the grant, so the classification is unknown.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[CONDITIONAL_DENY_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
def test_admin_role_with_restrictive_boundary_passes(self):
# Admin identity policies intersected with a read-only permissions
# boundary are not effectively administrative.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[BOUNDED_ADMIN_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
def test_admin_role_with_unresolved_boundary_is_manual(self):
# When the boundary document cannot be resolved the restrictions are
# unknown: neither administrative nor safe can be proven.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[UNRESOLVED_BOUNDARY_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "MANUAL"
def test_admin_role_with_admin_boundary_fails(self):
# An AdministratorAccess boundary restricts nothing: still admin.
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(role_arns=[ADMIN_BOUNDED_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert ADMIN_BOUNDED_ROLE_ARN in result[0].status_extended
def test_disabled_profile_passes(self):
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(enabled=False, role_arns=[ADMIN_ROLE_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
assert "disabled" in result[0].status_extended
@@ -4,6 +4,7 @@ import botocore
from moto import mock_aws
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import (
Profile,
RolesAnywhere,
TrustAnchor,
)
@@ -16,6 +17,9 @@ from tests.providers.aws.utils import (
TA_ID = "11111111-2222-3333-4444-555555555555"
TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}"
PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc"
PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}"
ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role"
make_api_call = botocore.client.BaseClient._make_api_call
@@ -36,6 +40,21 @@ def mock_make_api_call(self, operation_name, kwarg):
}
]
}
if operation_name == "ListProfiles":
return {
"profiles": [
{
"profileArn": PROFILE_ARN,
"profileId": PROFILE_ID,
"name": "workload-profile",
"enabled": True,
"roleArns": [ROLE_ARN],
"sessionPolicy": '{"Version":"2012-10-17","Statement":[]}',
"durationSeconds": 3600,
"acceptRoleSessionName": True,
}
]
}
if operation_name == "ListTagsForResource":
return {"tags": [{"key": "Environment", "value": "test"}]}
return make_api_call(self, operation_name, kwarg)
@@ -78,6 +97,25 @@ class Test_RolesAnywhere_Service:
assert ta.region == AWS_REGION_US_EAST_1
assert ta.tags == [{"key": "Environment", "value": "test"}]
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_list_profiles(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
rolesanywhere = RolesAnywhere(aws_provider)
assert len(rolesanywhere.profiles) == 1
profile = rolesanywhere.profiles[PROFILE_ARN]
assert isinstance(profile, Profile)
assert profile.id == PROFILE_ID
assert profile.name == "workload-profile"
assert profile.enabled is True
assert profile.role_arns == [ROLE_ARN]
assert profile.session_policy == '{"Version":"2012-10-17","Statement":[]}'
assert profile.managed_policy_arns == []
assert profile.duration_seconds == 3600
assert profile.accept_role_session_name is True
assert profile.region == AWS_REGION_US_EAST_1
assert profile.tags == [{"key": "Environment", "value": "test"}]
@patch(
"botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure
)
@@ -1,6 +1,8 @@
from copy import deepcopy
from unittest import mock
import botocore
import pytest
from boto3 import client
from moto import mock_aws
@@ -54,6 +56,113 @@ def mock_make_api_call_v2(self, operation_name, kwarg):
return make_api_call(self, operation_name, kwarg)
PUBLIC_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
PRIVATE_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}]}'
MATCHING_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
UNRELATED_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendRawEmail","Resource":"*"}]}'
PUBLIC_ALLOW_AND_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"},{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}}'
MATCHING_DENY_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*","Condition":{"StringEquals":{"AWS:SourceAccount":"123456789012"}}}}'
def make_multiple_policies_api_mock(policies):
def mock_api_call(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-email-identity-multiple-policies",
}
],
}
elif operation_name == "GetEmailIdentity":
return {"Policies": policies, "Tags": {}}
return make_api_call(self, operation_name, kwarg)
return mock_api_call
mock_make_api_call_multiple_policies = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"private-policy": PRIVATE_ALLOW_POLICY,
}
)
mock_make_api_call_multiple_policies_reversed = make_multiple_policies_api_mock(
{
"private-policy": PRIVATE_ALLOW_POLICY,
"public-policy": PUBLIC_ALLOW_POLICY,
}
)
mock_make_api_call_public_allow_and_matching_deny = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"deny-policy": MATCHING_DENY_POLICY,
}
)
mock_make_api_call_matching_deny_and_public_allow = make_multiple_policies_api_mock(
{
"deny-policy": MATCHING_DENY_POLICY,
"public-policy": PUBLIC_ALLOW_POLICY,
}
)
mock_make_api_call_public_allow_and_unrelated_deny = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"deny-policy": UNRELATED_DENY_POLICY,
}
)
mock_make_api_call_same_policy_allow_and_deny = make_multiple_policies_api_mock(
{"combined-policy": PUBLIC_ALLOW_AND_DENY_POLICY}
)
mock_make_api_call_multiple_private_policies = make_multiple_policies_api_mock(
{
"private-policy-1": PRIVATE_ALLOW_POLICY,
"private-policy-2": PRIVATE_ALLOW_POLICY,
}
)
mock_make_api_call_public_single_statement = make_multiple_policies_api_mock(
{"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY}
)
mock_make_api_call_private_single_statement = make_multiple_policies_api_mock(
{"private-policy": PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY}
)
mock_make_api_call_public_and_deny_single_statements = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY,
"deny-policy": MATCHING_DENY_SINGLE_STATEMENT_POLICY,
}
)
mock_make_api_call_conditional_single_statement = make_multiple_policies_api_mock(
{"conditional-policy": CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY}
)
def execute_check_with_api_mock(api_call_mock):
with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call_mock):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
ses_identity_not_publicly_accessible,
)
return ses_identity_not_publicly_accessible().execute()
class Test_ses_identities_not_publicly_accessible:
@mock_aws
def test_no_identities(self):
@@ -114,6 +223,114 @@ class Test_ses_identities_not_publicly_accessible:
assert result[0].resource_tags == {"tag1": "value1", "tag2": "value2"}
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
@pytest.mark.parametrize(
"api_call_mock",
[
mock_make_api_call_multiple_policies,
mock_make_api_call_multiple_policies_reversed,
],
ids=["public-policy-first", "public-policy-last"],
)
def test_email_identity_public_when_any_policy_is_public(self, api_call_mock):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies is publicly accessible due to its resource policies."
)
@mock_aws
@pytest.mark.parametrize(
"api_call_mock",
[
mock_make_api_call_public_allow_and_matching_deny,
mock_make_api_call_matching_deny_and_public_allow,
mock_make_api_call_public_allow_and_unrelated_deny,
mock_make_api_call_same_policy_allow_and_deny,
],
ids=[
"matching-deny-last",
"matching-deny-first",
"unrelated-deny",
"same-policy-deny",
],
)
def test_email_identity_public_allow_with_explicit_deny_is_manual(
self, api_call_mock
):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
)
@mock_aws
def test_email_identity_multiple_private_policies(self):
result = execute_check_with_api_mock(
mock_make_api_call_multiple_private_policies
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies is not publicly accessible."
)
@mock_aws
@pytest.mark.parametrize(
("api_call_mock", "expected_status"),
[
(mock_make_api_call_public_single_statement, "FAIL"),
(mock_make_api_call_private_single_statement, "PASS"),
(mock_make_api_call_public_and_deny_single_statements, "MANUAL"),
],
ids=["public", "private", "public-with-deny"],
)
def test_email_identity_single_statement_policy(
self, api_call_mock, expected_status
):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == expected_status
@mock_aws
def test_check_preserves_nested_policy_condition_keys(self):
with mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_conditional_single_statement,
):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ses_client = SES(aws_provider)
identity = next(iter(ses_client.email_identities.values()))
policies_before_check = deepcopy(identity.policies)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
new=ses_client,
),
):
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
ses_identity_not_publicly_accessible,
)
ses_identity_not_publicly_accessible().execute()
assert identity.policies == policies_before_check
@mock_aws
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call_v2)
def test_email_identity_public(self):
@@ -140,7 +357,7 @@ class Test_ses_identities_not_publicly_accessible:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-email-identity-public is publicly accessible due to its resource policy."
== "SES identity test-email-identity-public is publicly accessible due to its resource policies."
)
assert result[0].resource_id == "test-email-identity-public"
assert (
@@ -27,6 +27,7 @@ def mock_make_api_call(self, operation_name, kwarg):
return {
"Policies": {
"policy1": '{"policy1": "value1"}',
"policy2": '{"policy2": "value2"}',
},
"Tags": {"tag1": "value1", "tag2": "value2"},
"DkimAttributes": {
@@ -81,7 +82,11 @@ class Test_SES_Service:
assert ses.email_identities[arn].type == "EMAIL_ADDRESS"
assert ses.email_identities[arn].arn == arn
assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1
assert ses.email_identities[arn].policy == {"policy1": "value1"}
assert ses.email_identities[arn].policy == {"policy2": "value2"}
assert ses.email_identities[arn].policies == {
"policy1": {"policy1": "value1"},
"policy2": {"policy2": "value2"},
}
assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"}
assert ses.email_identities[arn].dkim_status == "SUCCESS"
assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES"
+13
View File
@@ -490,6 +490,19 @@ def mock_api_projects_calls(client: MagicMock):
}
client.projects().serviceAccounts().list_next.return_value = None
# Workload Identity Federation pools/providers: return empty pages and stop
# pagination so the discovery while-loops in the IAM service terminate.
client.projects().locations().workloadIdentityPools().list().execute.return_value = {
"workloadIdentityPools": []
}
client.projects().locations().workloadIdentityPools().list_next.return_value = None
client.projects().locations().workloadIdentityPools().providers().list().execute.return_value = {
"workloadIdentityPoolProviders": []
}
client.projects().locations().workloadIdentityPools().providers().list_next.return_value = (
None
)
def mock_list_service_accounts_keys(name):
return_value = MagicMock()
if (

Some files were not shown because too many files have changed in this diff Show More