Compare commits

...
Author SHA1 Message Date
Pablo F.G 652c950a71 docs(ui): trim the Slack disconnect test and fixture comments
- Drop assertion narration and keep contract facts stated once
2026-08-18 17:46:57 +02:00
Pablo F.G da166c04e6 docs(ui): trim the Slack disconnect component comments
- Cut each rationale to its load-bearing clause and drop restatements
2026-08-18 17:46:57 +02:00
Pablo F.G ba64cf35b8 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-18 17:46:57 +02:00
Pablo F.G e78c128de4 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-18 17:46:57 +02:00
Pablo F.G 0c296b3227 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-18 17:46:57 +02:00
Pablo F.G d9f957a892 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-18 17:46:57 +02:00
Pablo F.G 4d32fa59b7 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-18 17:46:57 +02:00
Pablo F.G 2403dbca82 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-18 17:46:57 +02:00
Pablo F.G ac3dbd1959 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-18 17:46:57 +02:00
Pablo F.G cbc6d23444 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-18 17:46:57 +02:00
Pablo F.G c02c28fc76 chore(ui): drop the disconnect and reconnect changelog entries
The Slack integration ships one changelog entry on the stack's first PR
2026-08-18 17:46:57 +02:00
Pablo F.G 00d2aacf26 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-18 17:46:57 +02:00
Pablo F.G ff3e4a4d24 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-18 17:46:57 +02:00
Pablo F.G 7da6d06f51 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-18 17:46:46 +02:00
Pablo F.G 0c9aa84676 chore(ui): drop the channel test-message changelog entry
The Slack integration ships one changelog entry on the stack's first PR
2026-08-18 17:46:46 +02:00
Pablo F.G b388bb68f6 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-18 17:46:46 +02:00
Pablo F.G df0775d135 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-18 17:46:46 +02:00
Pablo F.G 5af26a5f53 docs(ui): trim the Slack channel MSW comments
- Keep fixture semantics and handler precedence notes
- Drop narrative around refusal shapes
2026-08-18 17:46:46 +02:00
Pablo F.G 5690d1ad94 docs(ui): trim the Slack channel component comments
- Compress state and rendering rationale to one-liners
- Drop prose restating the JSX
2026-08-18 17:46:46 +02:00
Pablo F.G e178722f0b docs(ui): trim the Slack channel action comments
- Cut narrative prose to the load-bearing why
- Keep ordering, security, and contract anchors
2026-08-18 17:46:46 +02:00
Pablo F.G d28272b17b 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-18 17:46:46 +02:00
Pablo F.G 4ffe215a67 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-18 17:46:46 +02:00
Pablo F.G d60cb02f72 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-18 17:46:46 +02:00
Pablo F.G 349d6d8dd4 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-18 17:46:46 +02:00
Pablo F.G 6d1852b38a 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-18 17:46:46 +02:00
Pablo F.G 531b7fd311 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-18 17:46:46 +02:00
Pablo F.G d19c81bdbb 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-18 17:46:46 +02:00
Pablo F.G b323ab139e 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-18 17:46:46 +02:00
Pablo F.G 57434ea16e 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-18 17:46:46 +02:00
Pablo F.G c9c769c1df 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-18 17:46:46 +02:00
Pablo F.G 71be560bc2 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-18 17:46:46 +02:00
Pablo F.G 608ce240d0 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-18 17:46:46 +02:00
Pablo F.G c3420151b5 docs(ui): trim the Slack install-guard comment 2026-08-18 17:43:39 +02:00
Pablo F.G 6adbb75ca9 test(ui): pin the exact OAuth scopes Prowler asks Slack for 2026-08-18 17:40:26 +02:00
Pablo F.G 23d6c77a78 fix(ui): hide the decorative Slack icon from assistive technology 2026-08-18 17:39:03 +02:00
Pablo F.G 9d43aa0c33 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-18 17:38:40 +02:00
Pablo F.G 434e5aaf03 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-18 09:23:40 +02:00
Pablo F.G 68f1092f56 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-18 09:23:40 +02:00
Pablo F.G 5ed235088c 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-18 09:23:40 +02:00
Pablo F.G ed1fce420e 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-18 09:23:40 +02:00
Pablo F.G 8481a43fe3 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-18 09:23:40 +02:00
Pablo F.G 37b3ae7d25 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-18 09:23:40 +02:00
Pablo F.G 553c0429e6 docs(ui): focus the Slack changelog entry on what the user can do 2026-08-18 09:23:40 +02:00
Pablo F.G 2f59b89b49 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-18 09:23:40 +02:00
Pablo F.G 4cf3d7af73 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-18 09:23:40 +02:00
Pablo F.G d60bb6ee62 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-18 09:23:40 +02:00
Pablo F.G 327d0ed0c9 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-18 09:23:40 +02:00
Pablo F.G ffa593dff9 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-18 09:23:40 +02:00
Pablo F.G dfc66e43a8 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-18 09:23:40 +02:00
Pablo F.G bdb2e52261 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-18 09:23:40 +02:00
Pablo F.G e15a68c6d1 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-18 09:23:40 +02:00
Pablo F.G 866cb6077f 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-18 09:23:40 +02:00
Pablo F.G fce28e364a 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-18 09:23:40 +02:00
Pablo F.G 7cf3d4d486 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-18 09:23:40 +02:00
Pablo F.G 8089a7576e 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-18 09:23:40 +02:00
Pablo F.G 4fa4354796 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-18 09:23:40 +02:00
Pablo F.G 0aa6457ef1 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-18 09:23:40 +02:00
Pablo F.G baa0d03c06 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-18 09:23:40 +02:00
Pablo F.G 19dadeca05 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-18 09:23:40 +02:00
Pablo F.G 513b76d68d 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-18 09:23:40 +02:00
Pablo F.G 34f752cf41 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-18 09:23:40 +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
59 changed files with 7492 additions and 258 deletions
+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 --
+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
+20
View File
@@ -84,6 +84,16 @@ 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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
@@ -128,6 +138,16 @@ 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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
@@ -0,0 +1 @@
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)
@@ -0,0 +1 @@
Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe
+11 -7
View File
@@ -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",
Generated
+36 -48
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" },
@@ -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" },
]
@@ -0,0 +1 @@
ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails before container evidence is gathered
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
`ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review
@@ -0,0 +1 @@
Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe
@@ -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
)
@@ -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
+11 -11
View File
@@ -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",
@@ -199,7 +203,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 +304,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 +347,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 +391,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
@@ -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() == []
@@ -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"
+202
View File
@@ -0,0 +1,202 @@
from pathlib import Path
import pytest
from util.check_yanked_pins import (
Pin,
collect_pins,
evaluate,
main,
normalize,
pins_from_pyproject,
pins_from_uv_lock,
)
PYPROJECT = """
[project]
name = "demo"
dependencies = [
"cryptography==48.0.1",
"alibabacloud_tea_openapi==0.4.5",
"Requests[security]==2.34.2 ; python_version >= '3.10'",
"boto3>=1.40",
]
[project.optional-dependencies]
extra = ["okta==3.4.2"]
[dependency-groups]
dev = ["pytest==9.0.3", {include-group = "lint"}]
lint = ["flake8==7.1.2"]
[tool.uv]
constraint-dependencies = ["zstd==1.5.7.3"]
override-dependencies = ["okta==3.4.2"]
"""
UV_LOCK = """
version = 1
[[package]]
name = "zstd"
version = "1.5.7.3"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "Cryptography"
version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "prowler"
version = "5.40.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#abc" }
[[package]]
name = "demo"
version = "0.1.0"
source = { editable = "." }
"""
class TestNormalize:
"""normalize() applies PEP 503 so spellings of one project compare equal."""
def test_pep503_equivalence(self):
"""Underscores, dots and case collapse to the canonical dashed lowercase form."""
assert normalize("alibabacloud_tea_openapi") == "alibabacloud-tea-openapi"
assert normalize("Requests") == "requests"
assert normalize("zope.interface") == "zope-interface"
class TestPinsFromPyproject:
"""pins_from_pyproject() reads exact pins from every dependency-bearing table."""
def test_collects_exact_pins_from_every_table(self):
"""Dependencies, extras, dependency groups and both [tool.uv] lists are covered."""
pins = pins_from_pyproject(PYPROJECT, "")
assert {(p.name, p.version) for p in pins} == {
("cryptography", "48.0.1"),
("alibabacloud-tea-openapi", "0.4.5"),
("requests", "2.34.2"),
("okta", "3.4.2"),
("pytest", "9.0.3"),
("flake8", "7.1.2"),
("zstd", "1.5.7.3"),
}
def test_ignores_ranges_and_records_source_table(self):
"""Non-exact specifiers are skipped and each pin remembers its table."""
pins = pins_from_pyproject(PYPROJECT, "api/")
names = {p.name for p in pins}
assert "boto3" not in names
zstd = next(p for p in pins if p.name == "zstd")
assert zstd.source == "api/pyproject.toml [tool.uv.constraint-dependencies]"
def test_same_pin_in_two_tables_keeps_both_sources(self):
"""The same version in two tables yields two pins, one per source."""
okta = {
p.source for p in pins_from_pyproject(PYPROJECT, "") if p.name == "okta"
}
assert okta == {
"pyproject.toml [project.optional-dependencies.extra]",
"pyproject.toml [tool.uv.override-dependencies]",
}
class TestPinsFromUvLock:
"""pins_from_uv_lock() reads locked versions that live on a registry."""
def test_only_registry_packages(self):
"""git, path and editable sources are not on PyPI and are skipped."""
pins = pins_from_uv_lock(UV_LOCK, "")
assert {(p.name, p.version) for p in pins} == {
("zstd", "1.5.7.3"),
("cryptography", "48.0.1"),
}
assert all(p.source == "uv.lock" for p in pins)
class TestCollectPins:
"""collect_pins() merges a project's pyproject.toml and uv.lock."""
def test_missing_files_raise(self, tmp_path: Path):
"""A directory with neither file is a caller error, not an empty result."""
with pytest.raises(FileNotFoundError):
collect_pins(tmp_path)
def test_merges_pyproject_and_lock(self, tmp_path: Path):
"""Pins from both files are returned with the directory as source prefix."""
(tmp_path / "pyproject.toml").write_text(PYPROJECT)
(tmp_path / "uv.lock").write_text(UV_LOCK)
sources = {p.source for p in collect_pins(tmp_path)}
prefix = f"{tmp_path.as_posix()}/"
assert f"{prefix}uv.lock" in sources
assert f"{prefix}pyproject.toml [project.dependencies]" in sources
class TestEvaluate:
"""evaluate() queries PyPI once per release and reports per pin."""
def test_queries_each_release_once_and_fans_out_to_every_source(self):
"""One fetch per (name, version); its verdict reaches every source of that pin."""
calls = []
def fake_fetch(name, version):
"""Stand-in for fetch_release() that records calls and returns fixed verdicts."""
calls.append((name, version))
if (name, version) == ("zstd", "1.5.7.3"):
return "yanked", "buggy - not thread safe"
if (name, version) == ("gone", "0.0.1"):
return "missing", "not found on PyPI"
return "ok", ""
pins = {
Pin("zstd", "1.5.7.3", "pyproject.toml [tool.uv.constraint-dependencies]"),
Pin("zstd", "1.5.7.3", "uv.lock"),
Pin("cryptography", "48.0.1", "uv.lock"),
Pin("gone", "0.0.1", "uv.lock"),
}
verdicts = evaluate(pins, fetch=fake_fetch, workers=2)
assert sorted(calls) == [
("cryptography", "48.0.1"),
("gone", "0.0.1"),
("zstd", "1.5.7.3"),
]
by_status = {}
for verdict in verdicts:
by_status.setdefault(verdict.status, []).append(verdict.pin)
assert len(by_status["yanked"]) == 2
assert {p.source for p in by_status["yanked"]} == {
"pyproject.toml [tool.uv.constraint-dependencies]",
"uv.lock",
}
assert by_status["missing"] == [Pin("gone", "0.0.1", "uv.lock")]
assert by_status["ok"] == [Pin("cryptography", "48.0.1", "uv.lock")]
class TestMain:
"""main() turns verdicts into a process exit code and annotations."""
def test_exit_code_reflects_verdicts(self, tmp_path: Path, monkeypatch, capsys):
"""0 when every pin is ok, 1 plus a ::error:: line when one is yanked."""
(tmp_path / "pyproject.toml").write_text(
'[project]\ndependencies = ["zstd==1.5.7.3"]\n'
)
monkeypatch.setattr(
"util.check_yanked_pins.fetch_release",
lambda name, version, retries=3: ("ok", ""),
)
assert main([str(tmp_path)]) == 0
monkeypatch.setattr(
"util.check_yanked_pins.fetch_release",
lambda name, version, retries=3: ("yanked", "buggy - not thread safe"),
)
assert main([str(tmp_path)]) == 1
assert (
"::error::zstd==1.5.7.3 is yanked (buggy - not thread safe)"
in capsys.readouterr().out
)
+491
View File
@@ -0,0 +1,491 @@
/**
* Fixture data for the Slack handlers. Shapes follow the API contract in
* `openspec/changes/add-slack-integration/design.md`.
*/
export interface SlackWorkspaceFixture {
teamId: string;
teamName: string;
botUserId: string;
/**
* Absent from the serialized configuration until a channel is chosen: the API
* omits the keys rather than sending nulls.
*/
channelId?: string;
channelName?: string;
}
export interface SlackInstallFixture {
id: string;
/** `null` until the first connection check runs. */
connected: boolean | null;
connectionLastCheckedAt: string | null;
workspace: SlackWorkspaceFixture;
}
export const SLACK_EXCHANGE_OUTCOME = {
CREATED: "created",
/** Same workspace re-installed: the existing row keeps its id. */
REINSTALLED: "reinstalled",
REFUSED_STATE: "refused-state",
SLACK_REFUSED: "slack-refused",
/** A `409` named by its `code`: one workspace per tenant. */
DIFFERENT_WORKSPACE: "different-workspace",
/**
* The three below are `2xx`: the install happened, but the answer is
* unreadable, so nothing on the failure path sees them.
*/
UNREADABLE_NO_CONTENT: "unreadable-no-content",
UNREADABLE_HTML: "unreadable-html",
UNREADABLE_NO_DATA: "unreadable-no-data",
} as const;
export type SlackExchangeOutcome =
(typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME];
export interface SlackConnectionFixture {
connected: boolean;
error: string | null;
}
/** A channel the listing endpoint offers for the picker. */
export interface SlackChannelFixture {
id: string;
name: string;
/** Private channels are listed only where `@Prowler` has been invited. */
isPrivate: boolean;
}
export interface SlackTestMessageFixture {
accepted: boolean;
/**
* Why it did not: the reason `code` would carry, or prose — the contract
* leaves the task result's shape open.
*/
error: string | null;
}
/**
* A refusal as the API sends one: the machine-readable reason in `code`, human
* copy in `detail`, and — for a `429` — the wait in `Retry-After`.
*/
export interface SlackRefusalFixture {
status: number;
/** Slack's stable reason. `null` for the failures classified by status. */
code: string | null;
detail: string;
/** Seconds `Retry-After` asked for; only a `429` carries one. */
retryAfterSeconds: number | null;
}
/**
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
* Revocation is best-effort: the row goes either way, and the outcome travels in
* JSON:API `meta`.
*
* One boolean is the whole of it: the API sends no reason for a revocation that
* did not happen, so modelling one would let a test prove copy the real
* deployment can never produce.
*/
export interface SlackRevocationFixture {
/**
* Slack confirmed the token no longer grants Prowler anything. `null` when the
* answer reports nothing at all — the plain `204` a deployment without a
* `destroy` override sends, which is what the UI meets today.
*/
revoked: boolean | null;
}
export interface SlackFixture {
/**
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
* `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`.
*/
appConfigured: boolean;
install: SlackInstallFixture | null;
exchangeWorkspace: SlackWorkspaceFixture;
exchangeOutcome: SlackExchangeOutcome;
connection: SlackConnectionFixture;
/** The Slack OAuth calls answer `429` with a `Retry-After`. */
rateLimited: boolean;
/**
* The shared `GET /integrations` read answers `500`, which the UI's own
* helper turns into a thrown error rather than a result.
*/
listServerError: boolean;
/** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */
authorizeUrlUnreadable: boolean;
/**
* Both Slack OAuth calls answer `502`, the contract's status for upstream and
* transport failures. Distinct from `appConfigured: false`, which is a `503`.
*/
oauthUpstreamError: boolean;
channels: SlackChannelFixture[];
/**
* Small on purpose: the default workspace spans two pages, so a UI that
* stopped at `data` instead of following `links.next` would lose channels.
*/
channelsPageSize: number;
/** Slack refused the listing outright, with the reason named in `code`. */
channelsRefusal: SlackRefusalFixture | null;
/**
* The cursor the refusal starts at. Absent, the whole read fails; a page
* size serves the first page and refuses the second — the partial read.
*/
channelsRefusalFromCursor?: number;
/**
* Slack refused the chosen channel when the `PATCH` validated it — the
* listing itself answered fine.
*/
channelSaveRefusal: SlackRefusalFixture | null;
testMessage: SlackTestMessageFixture;
revocation: SlackRevocationFixture;
}
/**
* A UUID, as the API's ids are: it travels in the URL of every Slack call and
* the actions accept no other shape.
*/
export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
/** The scopes the channel picker and the posting need (design D2). */
export const SLACK_BOT_SCOPES = [
"chat:write",
"chat:write.public",
"channels:read",
"groups:read",
] as const;
export const SLACK_REDIRECT_URI =
"https://cloud.prowler.com/integrations/slack/callback";
/** Server-minted, single-use, bound to the tenant and user (design D5). */
export const SLACK_OAUTH_STATE = "st-2f1c9d7a";
export const SLACK_OAUTH_CODE = "slack-code-1f4a";
export const SLACK_AUTHORIZE_URL =
"https://slack.com/oauth/v2/authorize" +
"?client_id=1234567890.0987654321" +
`&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` +
`&state=${SLACK_OAUTH_STATE}` +
`&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`;
/**
* The `detail` strings the implementation sends. Human copy; the
* machine-readable reason travels in `code`, which is what the UI maps.
*/
export const SLACK_UNCONFIGURED_DETAIL =
"Slack integration is not configured or temporarily unavailable.";
export const SLACK_REFUSED_STATE_DETAIL =
"OAuth state is invalid, expired, or already consumed.";
export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid.";
export const SLACK_DIFFERENT_WORKSPACE_DETAIL =
"This tenant is already connected to a different Slack workspace.";
export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
/**
* The `code` on the contract's `502`. The UI maps no copy of its own to it, so
* the `detail` is what reaches the user.
*/
export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable";
/**
* Raised as a `ValidationError({"channel_id": ...})` that still points at
* `/data` rather than at the attribute.
*/
export const SLACK_NO_CHANNEL_DETAIL =
"This Slack integration has no channel configured.";
export const SLACK_RATE_LIMITED_DETAIL =
"Slack is rate limiting requests from Prowler.";
/**
* What a `500` from the shared `GET /integrations` read carries. Nothing here
* is for the user to act on, so the UI answers a server error in its own words.
*/
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
export const SLACK_MISSING_SCOPE_DETAIL =
"Slack refused the request: missing_scope.";
/**
* Names the raw reason, as the missing-scope wording does: what lets a test tell
* copy the UI mapped from `code` apart from an echoed `detail`.
*/
export const SLACK_TOKEN_EXPIRED_DETAIL =
"Slack refused the request: token_expired.";
/**
* The same sentence for "it is gone" and "the app was removed from it": only
* `code` separates them, which is why a client must read `code`.
*/
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
"That channel is not one Prowler can post to.";
export const SLACK_NO_DEFAULT_CHANNEL_DETAIL =
"No default channel is recorded on this integration.";
/** A task result that reports the refusal as prose instead of as a reason. */
export const SLACK_TEST_MESSAGE_REFUSED_DETAIL =
"Slack rejected the message: the channel is archived.";
/**
* A `200` challenge page from a proxy or WAF that took the call instead of the
* API. V8 truncates the parser message for this body before the word `html`, so
* the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either.
*/
export const PROXY_CHALLENGE_PAGE = [
"<!DOCTYPE html>",
"<html><head><title>Attention Required</title></head>",
"<body><h1>Checking your browser before you proceed.</h1></body></html>",
].join("\n");
/**
* The `code` values the refusals below are named by. Wire values, spelled out
* rather than imported from the UI's own mapping: a rename on our side must
* fail these tests, not quietly agree with itself.
*/
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
export const SLACK_MISSING_SCOPE_CODE = "missing_scope";
export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found";
export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
/**
* A reason Slack really sends that the UI's mapping does not cover — the set is
* open-ended, so having no copy for one is the ordinary case.
*/
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
/**
* Two of the four dead-grant codes the contract lists. Whichever call surfaces
* one, the integration is disconnected and the only way out is connecting the
* workspace again (contract, Cross-cutting).
*/
export const SLACK_TOKEN_REVOKED_CODE = "token_revoked";
export const SLACK_TOKEN_EXPIRED_CODE = "token_expired";
export const SLACK_RETRY_AFTER_SECONDS = 30;
/** The install never granted a scope the call needs: actionable, so a `400`. */
export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_MISSING_SCOPE_CODE,
detail: SLACK_MISSING_SCOPE_DETAIL,
retryAfterSeconds: null,
};
/**
* Where this really happens is the channel listing: `conversations.list` is
* tier 2 and paginated.
*/
export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
status: 429,
code: null,
detail: SLACK_RATE_LIMITED_DETAIL,
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
};
/**
* The stored grant is no longer usable: a `400` like any other actionable
* refusal, deliberately not the `401` that would read as an expired Prowler
* session (contract, Errors).
*/
export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_TOKEN_EXPIRED_CODE,
detail: SLACK_TOKEN_EXPIRED_DETAIL,
retryAfterSeconds: null,
};
/** Slack-side or transport failure — a `502` naming no reason at all. */
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
status: 502,
code: null,
detail: SLACK_UPSTREAM_DETAIL,
retryAfterSeconds: null,
};
/** The chosen channel is archived, deleted, or was never in the workspace. */
export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_CHANNEL_NOT_FOUND_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/**
* The channel is fine, the Prowler app is simply not in it — fixed with
* `/invite @Prowler`. Identical `detail` to the refusal above, deliberately.
*/
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_NOT_IN_CHANNEL_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/**
* Two public channels and one private the Prowler app was invited to, ordered
* so the private one lands on the second cursor page.
*/
export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0123AB",
name: "security",
isPrivate: false,
};
export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0789EF",
name: "platform",
isPrivate: false,
};
export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = {
id: "C0456CD",
name: "security-alerts",
isPrivate: true,
};
export const SLACK_CHANNELS: SlackChannelFixture[] = [
SLACK_PUBLIC_CHANNEL,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_PRIVATE_CHANNEL,
];
/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */
export const SLACK_CHANNELS_PAGE_SIZE = 2;
/**
* The first channel the picker offers, so an install seeded with it always
* points at a channel the listing really has.
*/
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
const PROWLER_HQ: SlackWorkspaceFixture = {
teamId: "T01PROWLER",
teamName: "Prowler HQ",
botUserId: "U01PROWLERBOT",
};
export const slackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture => ({
appConfigured: true,
install: null,
exchangeWorkspace: { ...PROWLER_HQ },
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED,
connection: { connected: true, error: null },
rateLimited: false,
listServerError: false,
authorizeUrlUnreadable: false,
oauthUpstreamError: false,
channels: SLACK_CHANNELS.map((channel) => ({ ...channel })),
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
channelsRefusal: null,
channelSaveRefusal: null,
testMessage: { accepted: true, error: null },
revocation: { revoked: true },
...overrides,
});
/**
* A workspace approved with no destination channel yet. `connected` is `null`,
* not `true`: the check runs against the channel, so it has never run
* (design.md, "Connection state, in order").
*/
export const connectedSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixture({
install: {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...PROWLER_HQ },
},
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
...overrides,
});
const configuredInstall = (
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
): SlackInstallFixture => ({
id: SLACK_INTEGRATION_ID,
connected: true,
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
workspace: {
...PROWLER_HQ,
channelId: channel.id,
channelName: channel.name,
},
});
/**
* The same tenant with a destination channel already on record: the state a
* second visit starts from.
*/
export const slackFixtureWithDefaultChannel = (
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
/**
* The same finished setup, with a check time no parser can read: a zero date
* from a bad write or a serializer change. The contract types the attribute as
* a string and rules nothing else out.
*/
export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
connectedSlackFixture({
install: {
...configuredInstall(),
connectionLastCheckedAt: "0000-00-00T00:00:00Z",
},
});
/**
* The first cursor page is served and Slack rate limits the second: what is
* already read stays usable, the refusal only says why the list is short.
*/
export const partiallyReadSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE,
...overrides,
});
/**
* A workspace connected *and* a channel on record. Anything the API refuses
* until a channel exists (the connection check) needs this fixture.
*/
export const configuredSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
/**
* A connected tenant whose disconnect removes the row but cannot revoke at
* Slack — the outcome the user has to finish by hand in the workspace.
*/
export const revokeFailureSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
revocation: { revoked: false },
...overrides,
});
/**
* A connected tenant whose disconnect answers a plain `204` with no body: the
* row is gone and the revocation is unreported.
*/
export const unreportedRevocationSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
revocation: { revoked: null },
...overrides,
});
/**
* A connected tenant whose token has been revoked at Slack: the row still says
* connected until a check runs, and the check is what surfaces it.
*/
export const revokedTokenSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
configuredSlackFixture({
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
...overrides,
});
+381
View File
@@ -0,0 +1,381 @@
/**
* MSW handlers for the Slack integration, derived from the API contract in
* `openspec/changes/add-slack-integration/design.md` (the API itself lives in
* the cloud repository). State is per-call: an exchange creates the install the
* subsequent `GET /integrations` returns.
*
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
*/
import { http, HttpResponse } from "msw";
import {
INTEGRATIONS_SERVER_ERROR_DETAIL,
PROXY_CHALLENGE_PAGE,
SLACK_AUTHORIZE_URL,
SLACK_DIFFERENT_WORKSPACE_DETAIL,
SLACK_EXCHANGE_OUTCOME,
SLACK_INTEGRATION_ID,
SLACK_INVALID_CODE_DETAIL,
SLACK_NO_CHANNEL_DETAIL,
SLACK_NO_DEFAULT_CHANNEL_DETAIL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_REFUSED_STATE_DETAIL,
SLACK_UNCONFIGURED_DETAIL,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_DETAIL,
SLACK_UPSTREAM_ERROR_CODE,
SLACK_WORKSPACE_CONFLICT_CODE,
} from "./slack.fixtures";
import type {
SlackExchangeOutcome,
SlackFixture,
SlackInstallFixture,
SlackRefusalFixture,
} from "./slack.fixtures";
const API = process.env.UI_API_BASE_URL;
const TS = "2026-08-10T09:00:00Z";
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
const TEST_MESSAGE_TASK_PREFIX = "slack-test-message-task-";
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
const CHANNEL_CURSOR_PARAM = "page[cursor]";
/**
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
* for a field-shaped `ValidationError`: the errors are about the request.
*/
const errorBody = (detail: string, status: number, code?: string) => ({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
});
/**
* Answer a fixture's refusal as the API would: its own status, its `code`
* when it names one, and `Retry-After` only where the status carries a wait.
*/
const refuse = (refusal: SlackRefusalFixture) =>
HttpResponse.json(
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
{
status: refusal.status,
...(refusal.retryAfterSeconds === null
? {}
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
},
);
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
team_id: workspace.teamId,
team_name: workspace.teamName,
bot_user_id: workspace.botUserId,
// The API omits these keys until a channel is chosen, never sending nulls.
...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
});
const integrationResource = (install: SlackInstallFixture) => ({
id: install.id,
type: "integrations",
attributes: {
inserted_at: TS,
updated_at: TS,
enabled: true,
connected: install.connected,
connection_last_checked_at: install.connectionLastCheckedAt,
integration_type: "slack",
// No credentials: the bot token is encrypted at rest and never serialized.
configuration: configuration(install.workspace),
},
links: { self: `${API}/integrations/${install.id}` },
});
const collection = (install: SlackInstallFixture | null) => ({
data: install ? [integrationResource(install)] : [],
meta: {
version: "v1",
pagination: {
page: 1,
pages: 1,
count: install ? 1 : 0,
},
},
});
const taskResource = (id: string, state: string, result: unknown) => ({
data: { id, type: "tasks", attributes: { state, result } },
});
/**
* All three are `2xx`: the first two make `response.json()` throw, the third
* parses into a body that names no resource.
*/
const unreadableExchange = (outcome: SlackExchangeOutcome): Response => {
switch (outcome) {
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
return new HttpResponse(null, { status: 204 });
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
default:
return HttpResponse.json({ meta: { version: "v1" } });
}
};
export const handlersForSlack = (fx: SlackFixture) => {
// Mutable copy: the exchange must not write through to the caller's fixture.
let install: SlackInstallFixture | null = fx.install
? { ...fx.install, workspace: { ...fx.install.workspace } }
: null;
const unconfigured = () =>
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
status: 503,
});
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
/** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
const upstreamError = () =>
HttpResponse.json(
errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE),
{ status: 502, statusText: "Bad Gateway" },
);
return [
// --- OAuth ------------------------------------------------------------
http.post(`${API}/integrations/slack/oauth/authorize-url`, () => {
if (!fx.appConfigured) return unconfigured();
if (fx.rateLimited) return rateLimited();
if (fx.oauthUpstreamError) return upstreamError();
if (fx.authorizeUrlUnreadable) {
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
}
// The URL travels in `meta`; the call creates nothing.
return HttpResponse.json({
meta: { authorize_url: SLACK_AUTHORIZE_URL },
});
}),
http.post(`${API}/integrations/slack/oauth/exchange`, () => {
if (!fx.appConfigured) return unconfigured();
if (fx.rateLimited) return rateLimited();
if (fx.oauthUpstreamError) return upstreamError();
switch (fx.exchangeOutcome) {
case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE:
return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), {
status: 400,
});
case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED:
return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), {
status: 400,
});
case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE:
return HttpResponse.json(
errorBody(
SLACK_DIFFERENT_WORKSPACE_DETAIL,
409,
SLACK_WORKSPACE_CONFLICT_CODE,
),
{ status: 409 },
);
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
// The install still happened: the API upserts before it answers.
install = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return unreadableExchange(fx.exchangeOutcome);
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
install = {
id: install?.id ?? SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return HttpResponse.json({ data: integrationResource(install) });
default:
install = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return HttpResponse.json(
{ data: integrationResource(install) },
{ status: 201 },
);
}
}),
// --- Generic integration endpoints the Slack UI reuses -----------------
http.get(`${API}/integrations`, ({ request }) => {
if (fx.listServerError) {
return HttpResponse.json(
errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500),
{ status: 500 },
);
}
const type = new URL(request.url).searchParams.get(
"filter[integration_type]",
);
// An unfiltered read would pull every type into the Slack page.
return HttpResponse.json(collection(type === "slack" ? install : null));
}),
http.post<{ id: string }>(
`${API}/integrations/:id/connection`,
({ params }) => {
// The check posts to the channel, so the API refuses until one exists.
if (!install?.workspace.channelId) {
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
status: 400,
});
}
return HttpResponse.json(
taskResource(
`${CONNECTION_TASK_PREFIX}${params.id}`,
"executing",
null,
),
{ status: 202 },
);
},
),
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
// The test message settles as its own task (design D9).
if (params.taskId.startsWith(TEST_MESSAGE_TASK_PREFIX)) {
const { accepted, error } = fx.testMessage;
return HttpResponse.json(
taskResource(params.taskId, accepted ? "completed" : "failed", {
error,
}),
);
}
const { connected, error } = fx.connection;
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
install.connected = connected;
install.connectionLastCheckedAt = TS;
}
return HttpResponse.json(
taskResource(params.taskId, "completed", { connected, error }),
);
}),
// --- Channels ----------------------------------------------------------
http.get<{ id: string }>(
`${API}/integrations/:id/slack/channels`,
({ params, request }) => {
// The UI follows `links.next` opaquely, so the cursor's shape is this
// fixture's business alone. Read first: the page decides the refusal.
const cursor = Number(
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
);
// An endpoint-specific refusal wins over the blanket rate limiting,
// and applies from the named cursor, so a partial read is expressible.
if (
fx.channelsRefusal &&
cursor >= (fx.channelsRefusalFromCursor ?? 0)
) {
return refuse(fx.channelsRefusal);
}
if (fx.rateLimited) return rateLimited();
const nextCursor = cursor + fx.channelsPageSize;
const page = fx.channels.slice(cursor, nextCursor);
const hasMore = nextCursor < fx.channels.length;
return HttpResponse.json({
data: page.map((channel) => ({
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: channel.isPrivate },
})),
links: {
next: hasMore
? `${API}/integrations/${params.id}/slack/channels` +
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
: null,
},
});
},
),
/**
* The generic PATCH. The UI submits only `channel_id`; the name is derived
* from it here, as the API derives it from Slack (design D6).
*/
http.patch(`${API}/integrations/:id`, async ({ request }) => {
const body = (await request.json().catch(() => null)) as {
data?: { attributes?: { configuration?: { channel_id?: string } } };
} | null;
const channelId = body?.data?.attributes?.configuration?.channel_id;
const channel = fx.channels.find((c) => c.id === channelId);
if (!install) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
// Checked before the id lookup: the picker did offer this channel, and
// Slack refused it anyway when the API validated it.
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
if (!channel) {
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
status: 400,
});
}
install.workspace.channelId = channel.id;
install.workspace.channelName = channel.name;
return HttpResponse.json({ data: integrationResource(install) });
}),
// --- Test message ------------------------------------------------------
http.post<{ id: string }>(
`${API}/integrations/:id/slack/test-message`,
({ params }) => {
if (!install?.workspace.channelId) {
return HttpResponse.json(
errorBody(SLACK_NO_DEFAULT_CHANNEL_DETAIL, 400),
{ status: 400 },
);
}
return HttpResponse.json(
taskResource(
`${TEST_MESSAGE_TASK_PREFIX}${params.id}`,
"available",
null,
),
{ status: 202 },
);
},
),
// Disconnect. Revocation at Slack is best-effort: the row is removed either
// way and the outcome travels in `meta` — or nowhere at all, in the plain
// `204` a deployment with no `destroy` override sends.
http.delete(`${API}/integrations/:id`, () => {
install = null;
if (fx.revocation.revoked === null) {
return new HttpResponse(null, { status: 204 });
}
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
}),
];
};
+2
View File
@@ -341,6 +341,7 @@ export const testIntegrationConnection = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
@@ -387,6 +388,7 @@ export const pollConnectionTestStatus = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
+782
View File
@@ -0,0 +1,782 @@
/**
* What the Slack actions do off the DOM, which
* `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry,
* and the URLs the channel listing's cursor pagination follows.
*/
import { revalidatePath } from "next/cache";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
SLACK_ERROR_CODE,
SLACK_ERROR_MESSAGES,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
} from "@/lib/integrations/slack-errors";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
() => ({
/**
* The real SDK marks the exception `__sentry_captured__`, and
* `handleApiError` reads that mark to avoid reporting the same throw twice.
*/
captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => {
if (exception !== null && typeof exception === "object") {
Object.defineProperty(exception, "__sentry_captured__", {
configurable: true,
value: true,
});
}
}),
captureMessageMock: vi.fn(),
fetchMock: vi.fn(),
}),
);
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
captureMessage: captureMessageMock,
// The task poll leaves breadcrumbs on every read it makes.
addBreadcrumb: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches
// next-auth through `@/auth.config`; stubbing the session lets that copy load.
vi.mock("@/auth.config", () => ({
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: vi.fn(() =>
Promise.resolve({ Authorization: "Bearer test-token" }),
),
parseStringify: (value: unknown) => value,
}));
import {
disconnectSlackIntegration,
exchangeSlackOAuthCode,
getSlackAuthorizeUrl,
getSlackChannels,
sendSlackTestMessage,
setSlackDefaultChannel,
} from "./slack";
/** The status the contract reserves for an upstream Slack failure. */
const UPSTREAM_STATUS = 502;
const UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
const GENERIC_SERVER_ERROR_MESSAGE =
"Server is temporarily unavailable. Please try again in a few minutes.";
const errorResponse = (status: number, detail: string, code?: string) =>
new Response(
JSON.stringify({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
}),
{ status, headers: { "content-type": "application/vnd.api+json" } },
);
const exchange = () =>
exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" });
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe.each([
{ action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" },
{ action: exchange, name: "exchangeSlackOAuthCode" },
])("$name", ({ action }) => {
it("reports an upstream Slack failure instead of only turning it into copy", async () => {
// 502 covers `internal_error`, `fatal_error`, `service_unavailable` and
// transport failures.
fetchMock.mockResolvedValue(
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"),
);
const result = await action();
// Once, not twice: `handleApiResponse` reports and throws, and the action's
// catch sees the mark.
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
tags: {
api_error: true,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
error_type: SentryErrorType.SERVER_ERROR,
status_code: String(UPSTREAM_STATUS),
},
});
expect(captureMessageMock).not.toHaveBeenCalled();
// The throw lands in the action's catch, so the page gets a result to
// render rather than a rejection that strands the callback on its spinner.
expect(result).toEqual({ error: UPSTREAM_DETAIL });
});
it("answers a 5xx the API described in HTML in Prowler's own words", async () => {
fetchMock.mockResolvedValue(
new Response("<html><body><h1>502 Bad Gateway</h1></body></html>", {
status: UPSTREAM_STATUS,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
}),
);
const result = await action();
expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE });
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
});
it.each([503, 404])(
"reports nothing for a %s: that is the feature being dark, not a fault",
async (status) => {
// 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served
// in this deployment at all.
fetchMock.mockResolvedValue(
errorResponse(status, "Slack integration is not configured."),
);
const result = await action();
// Capturing this would report the deliberate ship-dark state from every
// tenant on every page load.
expect(result).toEqual({ unavailable: true });
expect(captureExceptionMock).not.toHaveBeenCalled();
},
);
it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
errors: [{ status: "429", detail: "Slack is rate limiting." }],
}),
{
status: 429,
headers: {
"content-type": "application/vnd.api+json",
"Retry-After": "30",
},
},
),
);
const result = await action();
expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 });
expect(captureExceptionMock).not.toHaveBeenCalled();
});
});
/**
* The URL is rendered as the `Add to Slack` link's `href`, so a value the API
* got wrong must not become a redirect to somewhere that is not Slack.
*/
describe("getSlackAuthorizeUrl authorize URL", () => {
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
const CONSENT_SCREEN_URL =
"https://slack.com/oauth/v2/authorize" +
"?client_id=1234567890.0987654321&state=st-2f1c9d7a";
const authorizeUrlResponse = (authorizeUrl: unknown) =>
new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
});
it.each([
["a hostile scheme", "javascript:alert(document.domain)"],
["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"],
["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"],
["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"],
[
"another Slack path",
"https://slack.com/redirect?to=https%3A%2F%2Fevil.test",
],
["a value that is not a URL", "oauth/v2/authorize"],
])(
"refuses %s instead of offering it as the install link",
async (_label, authorizeUrl) => {
// Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen.
fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl));
// When
const result = await getSlackAuthorizeUrl();
// Then — the answer for no URL at all: nothing here is safe to link to.
expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE });
},
);
it("hands over Slack's consent screen with its query untouched", async () => {
// Given
fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL));
// When / Then
expect(await getSlackAuthorizeUrl()).toEqual({
authorizeUrl: CONSENT_SCREEN_URL,
});
});
});
/**
* The callback names the workspace and redirects on `integration` alone, so a
* `2xx` body it cannot read back as an integration must not reach it.
*/
describe("exchangeSlackOAuthCode result shape", () => {
const INTEGRATION = {
id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42",
type: "integrations",
attributes: {
integration_type: "slack",
configuration: { team_name: "Prowler HQ" },
},
};
const exchangeResponse = (data: unknown) =>
new Response(JSON.stringify({ data }), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
});
it.each<[string, unknown]>([
["an empty object", {}],
["an array", []],
["a bare string", "invalid"],
["a resource with no id", { type: "integrations", attributes: {} }],
["a resource with an empty id", { ...INTEGRATION, id: "" }],
["a resource of another type", { ...INTEGRATION, type: "tasks" }],
[
"a resource with no attributes",
{ id: INTEGRATION.id, type: "integrations" },
],
[
"another kind of integration",
{
...INTEGRATION,
attributes: { ...INTEGRATION.attributes, integration_type: "jira" },
},
],
])("cannot confirm the install from %s", async (_label, data) => {
// Given — a 2xx whose `data` is truthy but is not an integration resource.
fetchMock.mockResolvedValue(exchangeResponse(data));
// When
const result = await exchange();
// Then — the answer for a body with no `data`: the install happened, only
// its result is unknown.
expect(result).toEqual({
unconfirmed: true,
message: SLACK_UNREADABLE_RESULT_MESSAGE,
});
});
it("hands over the workspace the API upserted", async () => {
// Given
fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION));
// When / Then
expect(await exchange()).toEqual({ integration: INTEGRATION });
});
});
/** The shape the API's integration ids have, which is the only shape accepted. */
const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a";
const CHANNELS_URL =
`https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` +
"/slack/channels";
const FIRST_CHANNEL = { id: "C0123AB", name: "security" };
const SECOND_CHANNEL = { id: "C0789EF", name: "platform" };
const channelPage = (
channel: { id: string; name: string },
next: string | null,
) =>
new Response(
JSON.stringify({
data: [
{
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: false },
},
],
links: { next },
}),
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
);
const channelOption = (channel: { id: string; name: string }) => ({
id: channel.id,
name: channel.name,
is_private: false,
});
const requestedUrls = (): string[] =>
fetchMock.mock.calls.map(([url]) => String(url));
/**
* `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot
* export: only async functions may leave one.
*/
const MAX_CHANNEL_PAGES = 20;
const channelOptions = (count: number) =>
Array.from({ length: count }, () => channelOption(FIRST_CHANNEL));
/** What a `429` carrying `Retry-After: 30` is turned into. */
const RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */
const TOKEN_EXPIRED_CODE = "token_expired";
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
const TOKEN_EXPIRED_MESSAGE =
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
describe("getSlackChannels", () => {
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
// The link is opaque (design D6), so the API may answer with the cursor
// alone; resolved against the API root it loses the listing's own path.
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([
CHANNELS_URL,
`${CHANNELS_URL}?page[cursor]=2`,
]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)],
});
});
it.each([
{
shape: "an absolute",
next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2",
},
{ shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" },
])(
"stops at $shape off-origin `next` rather than sending the tenant's token to it",
async ({ next }) => {
// `fetch` strips the tenant's `Authorization` on a redirect that leaves
// the origin, but not on a hop the UI makes itself.
fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([CHANNELS_URL]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
},
);
it("answers an unreadable page as no channels rather than parser prose", async () => {
fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ channels: [] });
expectNoParserProse(result);
});
it("says the list is short of the workspace when the page budget runs out", async () => {
// The budget exists because `conversations.list` is tier 2 and a workspace
// can outgrow it (design.md, Risks). A fresh `Response` per call: one
// instance is already consumed on its second read.
fetchMock.mockImplementation(() =>
Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({
channels: channelOptions(MAX_CHANNEL_PAGES),
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
});
it("says nothing about a short list for a workspace that just fits the budget", async () => {
let page = 0;
fetchMock.mockImplementation(() => {
page += 1;
return Promise.resolve(
channelPage(
FIRST_CHANNEL,
page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null,
),
);
});
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) });
expect(result).not.toHaveProperty("incomplete");
});
it("keeps the pages it read when a later one is refused, saying why the list stops", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
// A rate limit says nothing about the grant, so the truncation names none.
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: RATE_LIMITED_MESSAGE,
code: null,
});
});
it("names the reason a later page was refused, not only the wording", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: TOKEN_EXPIRED_MESSAGE,
code: TOKEN_EXPIRED_CODE,
});
});
it("answers a refusal on the first page as a failure, having nothing to show", async () => {
fetchMock.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
});
});
/**
* A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or
* WAF puts in front of one. The raw `SyntaxError` survives
* `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its
* `<!doctype html>` branch never matches) and would be shown verbatim.
*/
const HTML_INTERSTITIAL =
"<!DOCTYPE html><html><body><h1>Checking your browser</h1></body></html>";
const unreadableOk = (body: string) =>
new Response(body, {
status: 200,
headers: { "content-type": body ? "text/html" : "application/json" },
});
/** V8's parser wording, which no user should ever be shown. */
const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i;
const expectNoParserProse = (result: unknown) => {
const message = (result as { error?: string }).error ?? "";
expect(message).not.toMatch(PARSER_PROSE);
};
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
const saveChannel = () =>
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
const expectIntegrationsRevalidated = () => {
expect(vi.mocked(revalidatePath).mock.calls).toEqual([
["/integrations"],
["/integrations/slack"],
]);
};
describe("setSlackDefaultChannel", () => {
it("returns the saved integration and revalidates the pages listing it", async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
type: "integrations",
id: SLACK_INTEGRATION_ID,
attributes: {
integration_type: "slack",
configuration: {
channel_id: FIRST_CHANNEL.id,
channel_name: FIRST_CHANNEL.name,
},
},
},
}),
{
status: 200,
headers: { "content-type": "application/vnd.api+json" },
},
),
);
const result = await saveChannel();
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
expect(result).toMatchObject({
integration: {
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
},
});
expectIntegrationsRevalidated();
});
it.each([
{ shape: "empty", body: "" },
{ shape: "an HTML interstitial", body: HTML_INTERSTITIAL },
])(
"answers a $shape `200` as an unread result, not as a failed save",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(unreadableOk(body));
const result = await saveChannel();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
// The API recorded the channel before answering, so both pages refresh.
expectIntegrationsRevalidated();
},
);
// The caller reads `integration.attributes.configuration`, so a shallower
// guard lets the miss surface later as the manager's generic catch.
it.each([
{ shape: "no `data`", body: {} },
{ shape: "a null `data`", body: { data: null } },
{ shape: "a `data` with no configuration", body: { data: {} } },
])(
"answers a `200` carrying $shape as an unread result",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
}),
);
const result = await saveChannel();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
expectIntegrationsRevalidated();
},
);
});
/** The `202` that hands back the task the post is reported on (design D9). */
const TEST_MESSAGE_TASK_ID = "5f8b1c2d-7e64-4a90-8c31-2b7d6e5f4a90";
const testMessageAccepted = () =>
new Response(JSON.stringify({ data: { id: TEST_MESSAGE_TASK_ID } }), {
status: 202,
headers: { "content-type": "application/vnd.api+json" },
});
/** The task read the poll makes, already settled on its first look. */
const settledTask = (state: string, result: unknown) =>
new Response(
JSON.stringify({
data: {
type: "tasks",
id: TEST_MESSAGE_TASK_ID,
attributes: { state, result },
},
}),
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
);
describe("sendSlackTestMessage", () => {
it("answers an unreadable `202` as no task started, not as parser prose", async () => {
fetchMock.mockResolvedValueOnce(
new Response(HTML_INTERSTITIAL, {
status: 202,
headers: { "content-type": "text/html" },
}),
);
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: "Slack did not start the test message." });
expectNoParserProse(result);
});
it("wraps a reason it has no copy for instead of answering with the bare token", async () => {
fetchMock
.mockResolvedValueOnce(testMessageAccepted())
.mockResolvedValueOnce(settledTask("failed", { error: "is_archived" }));
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
const error = (result as { error?: string }).error ?? "";
expect(error).toMatch(/Slack refused the message/);
expect(error).toContain("is_archived");
expect(error).not.toBe("is_archived");
});
it("keeps Prowler's own wording for a reason the mapping covers", async () => {
fetchMock
.mockResolvedValueOnce(testMessageAccepted())
.mockResolvedValueOnce(
settledTask("failed", { error: "not_in_channel" }),
);
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
expect(result).toEqual({
error: SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL],
code: SLACK_ERROR_CODE.NOT_IN_CHANNEL,
});
});
it("shows a reason the task worded itself as the prose it is", async () => {
// Not token-shaped, so nothing is wrapped around it.
const prose = "Slack rejected the message: the channel is archived.";
fetchMock
.mockResolvedValueOnce(testMessageAccepted())
.mockResolvedValueOnce(settledTask("failed", { error: prose }));
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: prose });
});
});
/** The calls whose only failure path is one line of copy. */
const COPY_ONLY_ACTIONS = [
{
name: "getSlackChannels",
call: (id: string) => getSlackChannels(id),
},
{
name: "setSlackDefaultChannel",
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
},
{
name: "sendSlackTestMessage",
call: (id: string) => sendSlackTestMessage(id),
},
{
name: "disconnectSlackIntegration",
call: (id: string) => disconnectSlackIntegration(id),
},
];
const rateLimitedResponse = () =>
new Response(
JSON.stringify({
errors: [{ status: "429", detail: "Slack is rate limiting." }],
}),
{
status: 429,
headers: {
"content-type": "application/vnd.api+json",
"Retry-After": "30",
},
},
);
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it("reports an upstream Slack failure and still answers in the same words", async () => {
fetchMock.mockResolvedValue(
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL),
);
const result = await call(SLACK_INTEGRATION_ID);
// Once, not twice: `handleApiResponse` reports and throws, and the action's
// catch sees the mark.
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
tags: {
api_error: true,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
error_type: SentryErrorType.SERVER_ERROR,
status_code: String(UPSTREAM_STATUS),
},
});
expect(captureMessageMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: UPSTREAM_DETAIL });
});
it.each([
{
status: 503,
why: "Slack being unavailable, not a fault",
response: () => errorResponse(503, "Slack is unavailable."),
expected: "Slack is unavailable.",
},
{
status: 429,
why: "a wait, not a fault",
response: rateLimitedResponse,
expected:
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.",
},
{
status: 400,
why: "a refusal the API meant to give",
response: () => errorResponse(400, "No default channel is set."),
expected: "No default channel is set.",
},
])("reports nothing for a $status: that is $why", async (refusal) => {
fetchMock.mockResolvedValue(refusal.response());
const result = await call(SLACK_INTEGRATION_ID);
expect(captureExceptionMock).not.toHaveBeenCalled();
expect(captureMessageMock).not.toHaveBeenCalled();
// None of these refusals names a `code`.
expect(result).toEqual({ error: refusal.expected, code: null });
});
});
/**
* The integration id is interpolated into every one of these URLs, so a
* malformed one is refused before the request is built.
*/
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it.each(["../../users", "not-a-uuid", ""])(
"asks the API nothing when the integration id is %o",
async (id) => {
const result = await call(id);
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE });
},
);
});
+640
View File
@@ -0,0 +1,640 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { pollTaskUntilSettled } from "@/actions/task/poll";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import {
readSlackFailure,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_REASON_TOKEN,
SLACK_UNREADABLE_RESULT_MESSAGE,
slackErrorMessage,
slackRateLimitMessage,
slackUnknownReasonMessage,
} from "@/lib/integrations/slack-errors";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
INTEGRATION_TYPE,
type IntegrationProps,
type SlackChannelOption,
} from "@/types/integrations";
interface SlackUnavailable {
unavailable: true;
}
interface SlackRateLimited {
rateLimited: true;
retryAfterSeconds: number | null;
message: string;
}
/**
* The API accepted the exchange (`2xx`) and the UI could not read the workspace
* back: the install happened, only its result is unknown.
*/
interface SlackUnconfirmed {
unconfirmed: true;
message: string;
}
interface SlackActionError {
error: string;
/**
* The refusal's `code`, when it named one, alongside the copy. A caller reads
* it to recognise a class of failure the wording cannot be pattern-matched
* for — a Slack grant that has stopped working, which the contract allows
* from any of these calls (Cross-cutting) and is recovered from by
* reconnecting rather than by retrying.
*/
code?: string | null;
}
interface SlackAuthorizeUrl {
authorizeUrl: string;
}
export type SlackAuthorizeUrlResult =
| SlackAuthorizeUrl
| SlackUnavailable
| SlackRateLimited
| SlackActionError;
interface SlackExchangeInput {
code: string;
state: string;
}
const slackExchangeInputSchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
});
/**
* SSRF guard: the integration id is interpolated into the request URL, so only
* the shape the API's ids have reaches it.
*/
const integrationIdSchema = z.uuid();
const parseIntegrationId = (integrationId: string): string | null => {
const parsed = integrationIdSchema.safeParse(integrationId);
return parsed.success ? parsed.data : null;
};
interface SlackExchangeSuccess {
integration: IntegrationProps;
}
export type SlackExchangeResult =
| SlackExchangeSuccess
| SlackUnavailable
| SlackRateLimited
| SlackUnconfirmed
| SlackActionError;
/**
* `503`: no Slack app configured in this deployment. `404`: no Slack API at
* all. Both mean "not available here", unlike `429`/`502` which mean "not now".
*/
const isUnavailableStatus = (status: number): boolean =>
status === 503 || status === 404;
const RATE_LIMITED_STATUS = 429;
const SLACK_AUTHORIZE_HOSTNAME = "slack.com";
const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize";
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
/**
* The URL is rendered as the `Add to Slack` link's `href`, so anything that is
* not Slack's consent screen is a redirect to an origin the user did not choose.
*/
const isSlackAuthorizeUrl = (value: string): boolean => {
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
url.hostname === SLACK_AUTHORIZE_HOSTNAME &&
url.pathname === SLACK_AUTHORIZE_PATHNAME
);
} catch {
return false;
}
};
const INTEGRATIONS_RESOURCE_TYPE = "integrations";
/**
* The callback names the workspace and redirects on this value alone, so a `2xx`
* payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as
* unreadable rather than as a connected workspace. Identity too: a resource
* that is not a linkable Slack integration would be shown as the workspace
* just installed.
*/
const isIntegrationResource = (value: unknown): boolean => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const { id, type, attributes } = value as Record<string, unknown>;
if (
typeof id !== "string" ||
id === "" ||
type !== INTEGRATIONS_RESOURCE_TYPE ||
typeof attributes !== "object" ||
attributes === null ||
Array.isArray(attributes)
) {
return false;
}
return (
(attributes as Record<string, unknown>).integration_type ===
INTEGRATION_TYPE.SLACK
);
};
const failureFrom = async (
response: Response,
fallback: string,
): Promise<SlackUnavailable | SlackRateLimited | SlackActionError> => {
if (isUnavailableStatus(response.status)) return { unavailable: true };
// A 5xx (including the `502` the contract reserves for "Slack upstream
// broke") goes through the repo's 5xx handling, which reports to Sentry and
// throws, so the caller's catch answers the user. Must run before
// `readSlackFailure`: a body can only be read once.
if (response.status >= 500) await handleApiResponse(response);
const failure = await readSlackFailure(response);
if (failure.status === RATE_LIMITED_STATUS) {
return {
rateLimited: true,
retryAfterSeconds: failure.retryAfterSeconds,
message: slackRateLimitMessage(failure.retryAfterSeconds),
};
}
return { error: slackErrorMessage(failure, fallback), code: failure.code };
};
/**
* `failureFrom` flattened to one refusal, for the calls whose only outcome is
* "it did not work". Rate limiting keeps its own wording: `conversations.list`
* is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it
* names is the useful part.
*/
const refusalFrom = async (
response: Response,
fallback: string,
): Promise<SlackActionError> => {
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
// is unavailable. Must run before `readSlackFailure`: a body can only be read
// once.
if (response.status >= 500 && response.status !== 503) {
await handleApiResponse(response);
}
const failure = await readSlackFailure(response);
return {
error:
failure.status === RATE_LIMITED_STATUS
? slackRateLimitMessage(failure.retryAfterSeconds)
: slackErrorMessage(failure, fallback),
code: failure.code,
};
};
/** Mint an OAuth state and get the consent URL. Creates no integration. */
export const getSlackAuthorizeUrl =
async (): Promise<SlackAuthorizeUrlResult> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
if (!response.ok) {
// Awaited inside the `try`: a returned promise's rejection would skip
// this `catch`, and a 5xx rejects.
return await failureFrom(
response,
`Unable to start the Slack install: ${response.statusText}`,
);
}
// The URL travels in JSON:API `meta`: the call creates no resource. A
// non-JSON `2xx` reads as "no URL" instead of throwing a parser message
// the user would be shown verbatim.
const body = await response.json().catch(() => null);
const authorizeUrl = body?.meta?.authorize_url;
// A URL that is not Slack's own is no more usable than a missing one.
if (
typeof authorizeUrl !== "string" ||
!isSlackAuthorizeUrl(authorizeUrl)
) {
return { error: NO_AUTHORIZE_URL_MESSAGE };
}
return { authorizeUrl };
} catch (error) {
return handleApiError(error);
}
};
/**
* Complete the install with what Slack put in the callback URL. The API
* consumes the `state`, exchanges the single-use `code`, and upserts the
* tenant's Slack integration.
*/
export const exchangeSlackOAuthCode = async (
input: SlackExchangeInput,
): Promise<SlackExchangeResult> => {
const parsed = slackExchangeInputSchema.safeParse(input);
if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const { code, state } = parsed.data;
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "slack-oauth-exchanges",
attributes: { code, state },
},
}),
});
if (!response.ok) {
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip this
// `catch` and leave the callback on its spinner.
return await failureFrom(
response,
`Unable to connect the Slack workspace: ${response.statusText}`,
);
}
const body = await response.json().catch(() => null);
// Before the guard and on both paths: the API upserted the integration
// before answering, so a cache filled when there was none would list the
// connected workspace as missing.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
if (!isIntegrationResource(body?.data)) {
return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE };
}
return { integration: parseStringify(body.data) as IntegrationProps };
} catch (error) {
return handleApiError(error);
}
};
interface SlackChannelsSuccess {
channels: SlackChannelOption[];
/**
* Present when these channels are only part of the workspace's, carrying the
* sentence that says why: a partial read is a success, so the caller renders
* the picker *and* the reason.
*/
incomplete?: string;
/**
* The `code` of the refusal that cut the read short, when it named one. A
* grant that has stopped working refuses the second cursor page exactly as it
* refuses the first, and a caller reading only the failure path would never
* hear about it.
*/
code?: string | null;
}
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
/**
* Cursor pages followed before giving up: `conversations.list` is a tier-2,
* rate-limited Slack call (design.md, Risks), so the aggregation is bounded
* rather than open-ended.
*/
const MAX_CHANNEL_PAGES = 20;
/**
* Every channel Prowler can post to in the connected workspace — the picker's
* options.
*
* The durable primitive, not the channel stored on the integration (design D6):
* a consumer needing a per-rule channel reads the same endpoint. `links.next`
* is followed opaquely — the contract does not pin the cursor parameter naming,
* so the UI never builds one of its own. An early stop that still read
* something reports through `incomplete`, not as a failure.
*/
export const getSlackChannels = async (
integrationId: string,
): Promise<SlackChannelsResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: false });
const channels: SlackChannelOption[] = [];
const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`);
let next: string | null = listing.toString();
let incomplete: string | null = null;
try {
for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) {
const current: string = next;
const response: Response = await fetch(current, {
method: "GET",
headers,
});
if (!response.ok) {
const refusal = await refusalFrom(
response,
`Unable to read the workspace's channels: ${response.statusText}`,
);
return channels.length > 0
? { channels, incomplete: refusal.error, code: refusal.code }
: refusal;
}
// A page that is not JSON reads as no channels, rather than throwing a
// parser message the user would be shown verbatim.
const body = await response.json().catch(() => null);
for (const resource of body?.data ?? []) {
channels.push({
id: resource?.id,
name: resource?.attributes?.name ?? "",
is_private: Boolean(resource?.attributes?.is_private),
});
}
const rawNext = body?.links?.next;
const candidate =
typeof rawNext === "string" && rawNext.length > 0
? new URL(rawNext, current)
: null;
// Resolved against the page it arrived on, so a cursor-only `next` keeps
// this listing's path. Followed only while it stays on the listing's
// origin: every page is fetched with the tenant's token, and an
// off-origin hop made here would carry it along.
if (candidate === null) {
next = null;
} else if (candidate.origin === listing.origin) {
next = candidate.toString();
} else {
next = null;
incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
}
}
// A link still waiting when the budget ran out. Checked rather than assumed
// from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was
// read to the end.
if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
return incomplete === null ? { channels } : { channels, incomplete };
} catch (error) {
return handleApiError(error);
}
};
interface SlackDefaultChannelSuccess {
integration: IntegrationProps;
}
export type SlackDefaultChannelResult =
| SlackDefaultChannelSuccess
| SlackActionError;
/**
* Record the channel Prowler posts to, on the generic integration endpoint.
*
* A Slack action despite the generic `PATCH`: `channel_not_found` and
* `not_in_channel` carry the same `detail`, so only `code` tells them apart,
* and the generic action reads `detail` alone. Only `channel_id` travels — the
* API derives `channel_name` server-side (design D6).
*/
export const setSlackDefaultChannel = async (
integrationId: string,
channelId: string,
): Promise<SlackDefaultChannelResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "integrations",
id,
attributes: {
integration_type: "slack",
configuration: { channel_id: channelId },
},
},
}),
});
if (!response.ok) {
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip
// this `catch`.
return await refusalFrom(
response,
`Unable to save the destination channel: ${response.statusText}`,
);
}
const body = await response.json().catch(() => null);
// Before the guard and on both paths: the save happened, so a cache still
// holding the previous channel would keep showing it.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
// Guarded as deep as the caller reads: it names the saved channel from
// `attributes.configuration`.
if (!body?.data?.attributes?.configuration) {
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
}
return { integration: parseStringify(body.data) as IntegrationProps };
} catch (error) {
return handleApiError(error);
}
};
interface SlackTestMessageSuccess {
sent: true;
}
export type SlackTestMessageResult = SlackTestMessageSuccess | SlackActionError;
interface SlackTestMessageTaskResult {
error?: string | null;
}
const TEST_MESSAGE_POLL = { maxAttempts: 20, delayMs: 3000 } as const;
/**
* Post the test message to the integration's default channel.
*
* Async on the API's side — `202` plus a Task (design D9) — so this polls the
* same task machinery the connection test uses. A `400` means no default
* channel is recorded.
*/
export const sendSlackTestMessage = async (
integrationId: string,
): Promise<SlackTestMessageResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}/slack/test-message`);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
if (!response.ok) {
return await refusalFrom(
response,
`Unable to send the test message: ${response.statusText}`,
);
}
// As above: an unreadable `202` is "no task to follow", not a parser
// message.
const body = await response.json().catch(() => null);
const taskId = body?.data?.id;
if (!taskId) {
return { error: "Slack did not start the test message." };
}
const settled = await pollTaskUntilSettled<SlackTestMessageTaskResult>(
taskId,
TEST_MESSAGE_POLL,
);
if (!settled.ok) {
return { error: settled.error };
}
// Slack's refusal travels in the task result, not in an HTTP error: the
// post happens after the `202`. A known code gets Prowler's own wording, a
// code-shaped reason is wrapped in one (contract, test-message), and prose
// is shown as it arrived.
const reason = settled.result?.error?.trim();
if (reason) {
return SLACK_REASON_TOKEN.test(reason)
? {
error: slackErrorMessage(
{ code: reason },
slackUnknownReasonMessage(reason),
),
// A dead grant can surface here too, so the reason travels on as
// a `code`, not only as its sentence.
code: reason,
}
: { error: reason };
}
if (settled.state !== "completed") {
return { error: "Slack did not accept the test message." };
}
return { sent: true };
} catch (error) {
return handleApiError(error);
}
};
/**
* What the API reports about revoking Prowler's token at Slack: one boolean in
* `meta`, and nothing else — it sends no reason for a revocation that did not
* happen, so there is no field here to hold one.
*/
export interface SlackRevocation {
/**
* Whether Slack confirmed the token no longer grants Prowler anything, or
* `null` when the response carried no outcome. The contract says the outcome
* is always reported, so `null` means the response is wrong, not the
* revocation.
*/
revoked: boolean | null;
}
interface SlackDisconnectSuccess {
/** The integration is gone from Prowler, whatever Slack answered. */
disconnected: true;
revocation: SlackRevocation;
}
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
/**
* Disconnect the workspace: `DELETE /integrations/{id}`.
*
* The generic `deleteIntegration` cannot serve this: it discards the response
* body, and the body is the whole point. Revocation at Slack is best-effort —
* the row is removed either way and the outcome travels in JSON:API `meta` — so
* a caller has to tell "gone and revoked" from "gone, but still installed in
* Slack".
*
* A body without the field (an empty `204`, say) yields `null`, not `false`: an
* unreported outcome must not send the user off to clean up Slack, nor be shown
* as access revoked.
*/
export const disconnectSlackIntegration = async (
integrationId: string,
): Promise<SlackDisconnectResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), { method: "DELETE", headers });
if (!response.ok) {
return await refusalFrom(
response,
`Unable to disconnect the Slack workspace: ${response.statusText}`,
);
}
const body = await response.json().catch(() => ({}));
const meta = body?.meta ?? {};
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
return {
disconnected: true,
revocation: {
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
},
};
} catch (error) {
return handleApiError(error);
}
};
@@ -0,0 +1,44 @@
import { ApiKeyLinkCard } from "@/components/integrations/api-key/api-key-link-card";
import { JiraIntegrationCard } from "@/components/integrations/jira/jira-integration-card";
import { S3IntegrationCard } from "@/components/integrations/s3/s3-integration-card";
import { SecurityHubIntegrationCard } from "@/components/integrations/security-hub/security-hub-integration-card";
import { SlackIntegrationCard } from "@/components/integrations/slack/slack-integration-card";
import { SsoLinkCard } from "@/components/integrations/sso/sso-link-card";
import { isCloud } from "@/lib/shared/env";
/**
* Split out of `page.tsx` for the browser-mode tests: `ContentLayout`'s navbar
* streams async server children a client renderer can't resolve.
*/
export function IntegrationsContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard />
{/* AWS Security Hub Integration */}
<SecurityHubIntegrationCard />
{/* Jira Integration */}
<JiraIntegrationCard />
{/* Slack Integration - cloud-only API, nothing to manage self-hosted */}
{isCloud() && <SlackIntegrationCard />}
{/* SSO Configuration - redirects to Profile */}
<SsoLinkCard />
{/* API Keys - redirects to Profile */}
<ApiKeyLinkCard />
</div>
</div>
);
}
@@ -0,0 +1,40 @@
/**
* Browser-mode tests for the Slack entry in the integrations catalogue
* (`/integrations`), which is offered in Prowler Cloud only. Driven through
* `SlackIntegrationHarness` against the MSW handlers.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import { slackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack/slack-integration.harness";
describe("the integrations catalogue", () => {
it("offers Slack in Prowler Cloud, with a way to manage it", async () => {
// Given — a Prowler Cloud deployment (the fixtures' default island).
const harness = new SlackIntegrationHarness(slackFixture());
harness.mountCatalogue();
expect(await harness.listedIntegrations()).toContain("Slack");
expect(harness.offersSlackManagement()).toBe(true);
}, 30000);
it("omits Slack in a deployment that is not Prowler Cloud", async ({
seedRuntimeConfig,
}) => {
seedRuntimeConfig({ cloudEnabled: false });
const harness = new SlackIntegrationHarness(slackFixture());
harness.mountCatalogue();
const listed = await harness.listedIntegrations();
expect(listed).not.toContain("Slack");
expect(harness.offersSlackManagement()).toBe(false);
// Tripwire: the catalogue rendered, so the assertions above are Slack's
// absence rather than the page failing to load.
expect(listed).toContain("Jira");
}, 30000);
});
+3 -32
View File
@@ -1,40 +1,11 @@
import {
ApiKeyLinkCard,
JiraIntegrationCard,
S3IntegrationCard,
SecurityHubIntegrationCard,
SsoLinkCard,
} from "@/components/integrations";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { IntegrationsContent } from "./integrations-content";
export default async function Integrations() {
return (
<ContentLayout title="Integrations" icon="lucide:puzzle">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard />
{/* AWS Security Hub Integration */}
<SecurityHubIntegrationCard />
{/* Jira Integration */}
<JiraIntegrationCard />
{/* SSO Configuration - redirects to Profile */}
<SsoLinkCard />
{/* API Keys - redirects to Profile */}
<ApiKeyLinkCard />
</div>
</div>
<IntegrationsContent />
</ContentLayout>
);
}
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
export default async function SlackCallbackPage() {
if (!isCloud()) {
redirect("/");
}
return (
<ContentLayout title="Slack">
{/* `SlackCallback` reads the query string, so it needs a boundary. */}
<Suspense fallback={null}>
<SlackCallback />
</Suspense>
</ContentLayout>
);
}
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
import { SlackIntegrationContent } from "./slack-integration-content";
export default async function SlackIntegrationPage() {
// The Slack API is cloud-only, so self-hosted has nothing behind this page.
// Mirrors `/alerts`.
if (!isCloud()) {
redirect("/");
}
return (
<ContentLayout title="Slack">
<div className="flex flex-col gap-6">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect a Slack workspace so Prowler can post to one of its channels.
</p>
<SlackIntegrationContent />
</div>
</ContentLayout>
);
}
@@ -0,0 +1,246 @@
/**
* Browser-mode tests for the Slack OAuth callback
* (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`.
* MSW answers from handlers derived from the API contract in `design.md`.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
SLACK_EXCHANGE_OUTCOME,
SLACK_OAUTH_CODE,
SLACK_OAUTH_STATE,
slackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack-integration.harness";
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/**
* Callback headlines, spelled out rather than imported so a rename fails here.
* `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
* for answers that arrive after the API already upserted the integration.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack", () => {
it("completes the install and shows the connected workspace", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
expect(await harness.completedInstall()).toBe(true);
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
// The code is single-use and the exchange runs from a render (design D4):
// without the once-guard, a second call burns it and reports a failure.
expect(harness.exchangeCallCount).toBe(1);
// A completed install invalidates the cached "none connected".
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("does not report an install the API completed as failed when it answers no content", async () => {
// Given — a `204`: the API consumed the code and upserted the integration,
// then answered with no body. `response.ok` is true, so this is no refusal.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).toMatch(/Slack integration page/);
expect(reason).not.toMatch(/JSON/i);
expect(harness.offersRetry()).toBe(true);
// The `204` says the workspace is connected; the headline cannot deny it.
expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE);
// The install exists, so the cached "none connected" has to go with it.
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => {
// Given — a proxy answering `200` with a challenge page instead of JSON.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// V8's parse message truncates before the word `html`, so the shared
// HTML-shaped-error filter cannot catch this one.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/DOCTYPE/i);
expect(reason).not.toMatch(/not valid JSON/i);
}, 30000);
it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => {
// Given — a `200` carrying well-formed JSON:API with no `data` member.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/undefined/i);
expect(await harness.completedInstall()).toBe(false);
}, 30000);
it("connects nothing when the user declines in Slack, and offers to retry", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ error: "access_denied" });
expect(await harness.installFailureReason()).toMatch(
/not approved in Slack/,
);
expect(harness.offersRetry()).toBe(true);
// A declined consent carries no code, so there was nothing to exchange.
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
it("surfaces the reason when Slack refuses to complete the install", async () => {
// Given — Slack rejects the code, and the API's own wording explains it.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// A refusal Prowler has no wording of its own for falls back to the API's
// `detail`, not to a generic failure.
expect(await harness.installFailureReason()).toMatch(
/OAuth code is invalid/,
);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("surfaces a completion the API refuses, and connects nothing", async () => {
// Given — the state was minted for another session, or already consumed.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: "state-from-another-session",
});
expect(await harness.installFailureReason()).toMatch(
/state is invalid, expired, or already consumed/,
);
// The API refused before consuming anything, so nothing was created and the
// headline states that plainly.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
// Refused once, not retried into a second burnt code.
expect(harness.exchangeCallCount).toBe(1);
}, 30000);
it("says how to resolve a workspace conflict, in Prowler's own words", async () => {
// Given — this tenant already has a different workspace connected, which
// the API refuses as a 409 naming the conflict in `code`.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The copy comes from the error `code`: the API's `detail` states the
// conflict but not the way out of it.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/already connected to a different Slack workspace/);
expect(reason).toMatch(/Disconnect it before connecting another/);
expect(reason).not.toMatch(/tenant/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("tells the user when to come back if Slack is rate limiting the install", async () => {
// Given — Slack answers 429 with a Retry-After.
const harness = new SlackIntegrationHarness(
slackFixture({ rateLimited: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/rate limiting/);
expect(reason).toMatch(/about 30 seconds/);
expect(reason).not.toMatch(/not available in this environment/);
// A 429 refuses the exchange outright, so nothing was connected: the plain
// headline, unlike the unreadable `2xx` that arrives after the upsert.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => {
// Given — the completion answers `502`, the contract's status for a Slack
// upstream failure. The shared 5xx handling throws, so the callback only
// renders this if the action answers that rejection itself.
const harness = new SlackIntegrationHarness(
slackFixture({ oauthUpstreamError: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The API refused, so nothing was created: not the "could not confirm" the
// page falls back to when the action never answers at all.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/temporarily unavailable/);
expect(reason).not.toMatch(/could not confirm/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("does not attempt an exchange when the completion carries no state", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ code: SLACK_OAUTH_CODE });
// Refused before the API is ever asked, so no code is spent.
expect(await harness.installFailureReason()).toMatch(/incomplete response/);
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
});
@@ -0,0 +1,61 @@
import { getIntegrations } from "@/actions/integrations/integrations";
import { getSlackAuthorizeUrl } from "@/actions/integrations/slack";
import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager";
import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
/**
* `getIntegrations` throws a `>= 500` answer past its own catch, which covers
* only transport. Uncaught it trips the route's error boundary and replaces a
* page that could still offer the install, so report it as `{ error }` and take
* the page's one error path.
*/
const readSlackIntegrations = async (searchParams: URLSearchParams) => {
try {
return await getIntegrations(searchParams);
} catch {
// The thrown message can carry the server's own wording; `handleApiResponse`
// already reported it to Sentry.
return { error: GENERIC_SERVER_ERROR_MESSAGE };
}
};
/**
* Split out of `page.tsx` so the browser-mode tests can render it without the
* surrounding `ContentLayout`.
*/
export async function SlackIntegrationContent() {
const searchParams = new URLSearchParams();
searchParams.set("filter[integration_type]", INTEGRATION_TYPE.SLACK);
// One workspace per tenant, so one row is the whole result set.
searchParams.set("page[size]", "1");
const integrations = await readSlackIntegrations(searchParams);
const loadError =
integrations && "error" in integrations
? (integrations.error as string)
: null;
const integration: IntegrationProps | null =
(integrations?.data?.[0] as IntegrationProps | undefined) ?? null;
const authorize = integration ? null : await getSlackAuthorizeUrl();
return (
<SlackIntegrationManager
integration={integration}
authorizeUrl={
authorize && "authorizeUrl" in authorize ? authorize.authorizeUrl : null
}
unavailable={Boolean(authorize && "unavailable" in authorize)}
// Rate limited is not unavailable: the install is still on offer, it just
// cannot be started yet.
rateLimitMessage={
authorize && "rateLimited" in authorize ? authorize.message : null
}
loadError={
loadError ??
(authorize && "error" in authorize ? authorize.error : null)
}
/>
);
}
@@ -0,0 +1,809 @@
/**
* Page-level test harness for the Slack integration (Vitest Browser Mode).
*
* A client renderer cannot render an async server component, so the component is
* called and the element it returns is what gets rendered.
*/
import { revalidatePath } from "next/cache";
import { createElement } from "react";
import { vi } from "vitest";
import { BrowserHarness } from "@/__tests__/browser-harness";
import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import { setSlackDefaultChannel } from "@/actions/integrations/slack";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { IntegrationsContent } from "../integrations-content";
import { SlackIntegrationContent } from "./slack-integration-content";
export const CONNECTION_OUTCOME = {
SUCCESS: "success",
FAILURE: "failure",
} as const;
export type ConnectionOutcome =
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
export const TEST_MESSAGE_OUTCOME = {
SENT: "sent",
FAILED: "failed",
} as const;
export type TestMessageOutcome =
(typeof TEST_MESSAGE_OUTCOME)[keyof typeof TEST_MESSAGE_OUTCOME];
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
export const REVOCATION_OUTCOME = {
REVOKED: "revoked",
NOT_REVOKED: "not-revoked",
/** The answer said nothing either way, so the page claims neither. */
UNREPORTED: "unreported",
} as const;
export type RevocationOutcome =
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
interface CallbackParams {
code?: string;
state?: string;
/** Slack's own refusal code, e.g. `access_denied`. */
error?: string;
}
export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
get exchangeCallCount(): number {
return this.countRequests("POST", "/slack/oauth/exchange");
}
get authorizeUrlCallCount(): number {
return this.countRequests("POST", "/slack/oauth/authorize-url");
}
/** Paths the actions asked Next to refresh (`next/cache` is stubbed in this lane). */
get revalidatedPaths(): string[] {
return vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
}
// --- Mounting -----------------------------------------------------------
private wireHandlers(): void {
// The stub is module-level and shared, so clearing it here is what makes
// `revalidatedPaths` mean "since this mount".
vi.mocked(revalidatePath).mockClear();
worker.use(...handlersForSlack(this.fixture));
this.trackRequests(worker);
}
async mount(): Promise<void> {
window.history.replaceState(null, "", "/integrations/slack");
this.wireHandlers();
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
private mounted: ReturnType<typeof render> | null = null;
/**
* Open the management page again, the way a later visit does — the handlers in
* place keep serving what the previous visit left behind. Unmounts the previous
* render first: two live copies would make every assertion ambiguous.
*/
async revisit(): Promise<void> {
(await this.mounted)?.unmount();
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
await this.mounted;
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
/**
* Refresh the page's server data under the open card, as `revalidatePath` does
* after an action: new props, no unmount, so React state survives — unlike
* `revisit()`, which re-seeds everything from scratch.
*/
async refreshPageData(): Promise<void> {
const rendered = await this.mounted;
if (!rendered) {
throw new Error("refreshPageData: the page is not mounted");
}
await rendered.rerender(await SlackIntegrationContent());
}
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
const params = new URLSearchParams();
if (code) params.set("code", code);
if (state) params.set("state", state);
if (error) params.set("error", error);
window.history.replaceState(
null,
"",
`/integrations/slack/callback?${params.toString()}`,
);
this.wireHandlers();
render(createElement(SlackCallback));
}
/** Mount the integrations catalogue. No handlers: every card there is static. */
mountCatalogue(): void {
window.history.replaceState(null, "", "/integrations");
render(createElement(IntegrationsContent));
}
// --- The integrations catalogue ------------------------------------------
async listedIntegrations(): Promise<string[]> {
const headings = await this.waitFor(
() => {
const found = Array.from(
this.container.querySelectorAll<HTMLElement>("h4"),
);
return found.length > 0 ? found : null;
},
5000,
"the integrations catalogue",
);
return headings.map((heading) => (heading.textContent ?? "").trim());
}
offersSlackManagement(): boolean {
return this.q('a[href="/integrations/slack"]') !== null;
}
// --- Starting the install -----------------------------------------------
private connectLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
/Add to Slack/.test(anchor.textContent ?? ""),
) ?? null
);
}
async authorizeUrl(): Promise<string> {
const link = await this.waitFor(
() => this.connectLink(),
5000,
"the Add to Slack link",
);
return link.href;
}
/**
* Clicks the install affordance and reports where it points. The default
* action is cancelled: following the link navigates the test frame off the app.
*/
async connect(): Promise<string> {
const link = await this.waitFor(
() => this.connectLink(),
5000,
"the Add to Slack link",
);
let destination = "";
const intercept = (event: MouseEvent) => {
event.preventDefault();
destination = link.href;
};
link.addEventListener("click", intercept);
try {
await this.clickElement(link, { fallbackToDomClick: true });
} finally {
link.removeEventListener("click", intercept);
}
return destination;
}
offersInstall(): boolean {
return this.connectLink() !== null;
}
async waitForUnavailable(): Promise<void> {
await this.waitForText(/Slack is not available in this environment yet/);
}
saysUnavailable(): boolean {
return this.containsText(/Slack is not available in this environment yet/);
}
saysLoadFailed(): boolean {
return this.containsText(/Could not load your Slack integration/);
}
async rateLimitNotice(): Promise<string> {
await this.waitForText(/Slack is busy right now/, 10000);
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the rate limit notice",
);
return (description.textContent ?? "").trim();
}
async loadErrorNotice(): Promise<string> {
await this.waitForText(/Could not load your Slack integration/, 10000);
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the load error notice",
);
return (description.textContent ?? "").trim();
}
// --- Connected state ----------------------------------------------------
/**
* Read from the heading element, not the page text: in `textContent`
* "Connected to <workspace>" runs straight into the copy that follows it.
*/
async connectedWorkspaceName(): Promise<string> {
const heading = await this.waitFor(
() => this.deepestElementMatching(/^Connected to \S/),
5000,
"the connected workspace name",
);
return (heading.textContent ?? "").trim().replace(/^Connected to /, "");
}
/** Last match in document order: every ancestor of a match matches too. */
private deepestElementMatching(pattern: RegExp): HTMLElement | null {
return (
Array.from(this.container.querySelectorAll<HTMLElement>("*"))
.reverse()
.find((element) => pattern.test((element.textContent ?? "").trim())) ??
null
);
}
/**
* Keyed on the badge's state attribute, not its copy: the heading beside it
* also starts "Connected to …".
*/
async connectionBadge(): Promise<string> {
const badge = await this.waitFor(
() => this.q("[data-connection-status]"),
5000,
"the connection badge",
);
return (badge.textContent ?? "").trim();
}
async offersConnectionTest(): Promise<boolean> {
const button = await this.waitFor(
() => this.buttonByText(/Test connection/),
5000,
"the Test connection button",
);
return !button.disabled;
}
saysChannelIsNextStep(): boolean {
return this.containsText(/Choosing a destination channel is the next step/);
}
/**
* The "last checked" line as rendered, or null when the page shows none —
* which is what a workspace whose connection was never checked shows.
*/
lastCheckedLine(): string | null {
const line = Array.from(
this.container.querySelectorAll<HTMLElement>("p"),
).find((p) => /^Last checked:/.test((p.textContent ?? "").trim()));
return line ? (line.textContent ?? "").trim() : null;
}
async testConnection(): Promise<ConnectionOutcome> {
await this.clickButton(/Test connection/);
return this.waitFor(
() => {
if (this.containsText(/Connection test successful/)) {
return CONNECTION_OUTCOME.SUCCESS;
}
if (this.containsText(/Connection test failed/)) {
return CONNECTION_OUTCOME.FAILURE;
}
return null;
},
15000,
"the connection test outcome",
);
}
// --- Returning from Slack -----------------------------------------------
/**
* The one element every non-success outcome renders. Keyed on it rather than
* the alert title, which is not the same claim on every outcome.
*/
private backLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find(
(anchor) =>
anchor.getAttribute("href") === "/integrations/slack" &&
/Back to Slack integration/.test(anchor.textContent ?? ""),
) ?? null
);
}
async completedInstall(): Promise<boolean> {
const outcome = await this.waitFor(
() => this.containsText(/Connected to /) || this.backLink() !== null,
10000,
"the callback outcome",
);
return outcome && this.containsText(/Connected to /);
}
async installFailureReason(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the failure reason",
);
return (description.textContent ?? "").trim();
}
async installFailureTitle(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
const title = await this.waitFor(
() => this.q('[data-slot="alert-title"]'),
5000,
"the failure title",
);
return (title.textContent ?? "").trim();
}
offersRetry(): boolean {
return this.backLink() !== null || this.offersInstall();
}
// --- Choosing a destination channel --------------------------------------
/** Channel reads issued — one per cursor page the UI followed. */
get channelListCallCount(): number {
return this.countRequests("GET", "/slack/channels");
}
/**
* Wait for the channel read every connected mount starts, counting from the
* reads already issued: one still in flight when the test ends lands in the
* middle of the next, against a harness that never asked for it.
*/
private async waitForChannelsRead(readsBefore: number): Promise<void> {
await this.waitFor(
() => {
const refresh = this.buttonByText(/Refresh channels/);
return this.channelListCallCount > readsBefore &&
refresh !== null &&
!refresh.disabled
? true
: null;
},
15000,
"the workspace's channels to be read",
);
}
/**
* Open the picker and hand back its options. A re-render landing mid-gesture
* makes Radix drop the open state, so re-open from the keyboard when nothing
* mounted at all.
*/
private async openChannelPicker(): Promise<HTMLElement[]> {
const mounted = (): HTMLElement[] | null => {
const options = Array.from(
document.querySelectorAll<HTMLElement>('[role="option"]'),
);
return options.length > 0 ? options : null;
};
const alreadyOpen = mounted();
if (alreadyOpen) return alreadyOpen;
const trigger = await this.waitFor<HTMLElement>(
() => this.q("#slack-channel"),
10000,
"the channel picker",
);
await this.clickElement(trigger, { fallbackToDomClick: true });
let options = await this.waitForOrNull(
mounted,
2000,
"the channel options",
);
if (!options) {
await this.user.keyboard("{Enter}");
options = await this.waitForOrNull(mounted, 8000, "the channel options");
}
if (!options) {
throw new Error("openChannelPicker: the channel picker offered nothing");
}
return options;
}
private async closeChannelPicker(): Promise<void> {
await this.user.keyboard("{Escape}");
await this.waitForTransition();
}
/**
* Re-read the workspace's channels, the way a user does after inviting
* `@Prowler` to one in Slack. Waits for the read to have settled, not for the
* click alone.
*/
async refreshChannels(): Promise<void> {
const readsBefore = this.channelListCallCount;
await this.clickButton(/Refresh channels/);
await this.waitFor(
() => {
const button = this.buttonByText(/Refresh channels/);
return (
this.channelListCallCount > readsBefore &&
button !== null &&
!button.disabled
);
},
15000,
"the workspace's channels to be read again",
);
}
/** The channels the workspace offers, in the order the picker lists them. */
async channelOptions(): Promise<string[]> {
const options = await this.openChannelPicker();
const names = options.map(
(option) => option.getAttribute("data-channel") ?? "",
);
await this.closeChannelPicker();
return names;
}
/**
* Whether the channel offered under `name` is presented as private — read
* from the marker the user sees, not from how the option is wired up.
*/
async isChannelShownAsPrivate(name: string): Promise<boolean> {
const options = await this.openChannelPicker();
const option = options.find(
(element) => element.getAttribute("data-channel") === name,
);
await this.closeChannelPicker();
return /Private/.test(option?.textContent ?? "");
}
private async pickAndSave(name: string): Promise<void> {
const options = await this.openChannelPicker();
const option = options.find(
(element) => element.getAttribute("data-channel") === name,
);
if (!option) {
throw new Error(`pickAndSave: no channel named "${name}" is offered`);
}
await this.user.click(option);
await this.waitForTransition();
await this.clickButton(/Save channel/);
}
/** Pick a channel, save it, and wait for it to be recorded as the destination. */
async chooseChannel(name: string): Promise<void> {
await this.pickAndSave(name);
await this.waitFor(
() => this.defaultChannelName() === name,
15000,
`#${name} to be recorded as the destination`,
);
}
/**
* Record a different destination away from this page — a second tab, or someone
* else in the tenant. Goes through the same call the page makes, leaving this
* page's own copy of it untouched.
*/
async channelRecordedElsewhere(name: string): Promise<void> {
const channel = this.fixture.channels.find((c) => c.name === name);
if (!channel) {
throw new Error(
`channelRecordedElsewhere: no channel named "${name}" is offered`,
);
}
const integrationId = this.fixture.install?.id;
if (!integrationId) {
throw new Error("channelRecordedElsewhere: no workspace is connected");
}
const result = await setSlackDefaultChannel(integrationId, channel.id);
if ("error" in result) {
throw new Error(`channelRecordedElsewhere: ${result.error}`);
}
}
/** Whether the picked channel can be saved — false when there is nothing new to save. */
offersChannelSave(): boolean {
const button = this.buttonByText(/Save channel/);
return button !== null && !button.disabled;
}
/**
* Try to save a channel the API refuses and hand back what the user is told. A
* save that succeeds fails the test rather than timing out.
*/
async refusedChannelSave(name: string): Promise<string> {
await this.pickAndSave(name);
return this.waitFor(
() => {
if (this.defaultChannelName() === name) {
throw new Error(
`refusedChannelSave: #${name} was recorded, not refused`,
);
}
return this.toastText(/Could not save the destination channel/);
},
15000,
"the refused channel save",
);
}
/**
* The text of the toast matching `pattern` — title and message together. Radix
* portals each toast into its viewport as an `<li>`, outside the page's markup.
*/
private toastText(pattern: RegExp): string | null {
const toast = Array.from(
document.querySelectorAll<HTMLElement>("ol li"),
).find((element) => pattern.test(element.textContent ?? ""));
return toast ? (toast.textContent ?? "").replace(/\s+/g, " ").trim() : null;
}
private defaultChannelName(): string | null {
return (
/Prowler posts to #(\S+?)\./.exec(
this.container.textContent ?? "",
)?.[1] ?? null
);
}
/** The channel recorded as the integration's destination, if any. */
async defaultChannel(): Promise<string | null> {
const settled = await this.waitFor(
() =>
this.defaultChannelName() ??
(this.containsText(/No destination channel recorded yet/)
? NO_DEFAULT_CHANNEL
: null),
10000,
"the recorded destination channel",
);
return settled === NO_DEFAULT_CHANNEL ? null : settled;
}
/** What the user is told when the workspace exposes no channel at all. */
async channelPickerMessage(): Promise<string> {
const alert = await this.waitFor(
() =>
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((element) =>
/No channels available yet|Could not read the workspace/.test(
element.textContent ?? "",
),
),
10000,
"the channel picker's message",
);
return (alert.textContent ?? "").replace(/\s+/g, " ").trim();
}
/**
* What the user is told about a list short of the workspace, shown beside a
* picker that still works — unlike `channelPickerMessage()`, which replaces it.
*/
partialListNotice(): string | null {
const notice = this.q("[data-channels-notice]");
return notice
? (notice.textContent ?? "").replace(/\s+/g, " ").trim()
: null;
}
/** Whether the picker was replaced by the "could not read them" alert. */
saysChannelsUnreadable(): boolean {
return this.containsText(/Could not read the workspace/);
}
/** The invite copy that says how to make a private channel appear. */
channelInviteHint(): string | null {
const hint = Array.from(
this.container.querySelectorAll<HTMLElement>("p"),
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
return hint ? (hint.textContent ?? "").trim() : null;
}
// --- The test message ----------------------------------------------------
offersTestMessage(): boolean {
return this.buttonByText(/Send test message/) !== null;
}
private testMessageAlert(): HTMLElement | null {
return (
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((element) =>
/Test message (sent|failed)/.test(element.textContent ?? ""),
) ?? null
);
}
async sendTestMessage(): Promise<TestMessageOutcome> {
await this.clickButton(/Send test message/);
return this.waitFor(
() => {
const alert = this.testMessageAlert();
if (!alert) return null;
return /Test message sent/.test(alert.textContent ?? "")
? TEST_MESSAGE_OUTCOME.SENT
: TEST_MESSAGE_OUTCOME.FAILED;
},
15000,
"the test message outcome",
);
}
async lastTestMessageOutcome(): Promise<string> {
const alert = await this.waitFor(
() => this.testMessageAlert(),
10000,
"the test message outcome",
);
const description = alert.querySelector<HTMLElement>(
'[data-slot="alert-description"]',
);
return (description?.textContent ?? "").trim();
}
// --- Disconnecting ------------------------------------------------------
get disconnectCallCount(): number {
return this.countRequests("DELETE", "/integrations/");
}
/**
* Disconnects the workspace, confirming the way a user has to, and reports
* what the page says about the revocation. The outcomes are mutually
* exclusive, so asking for one also checks the others are absent.
*
* The revoked and unreported outcomes share a toast title, so each is read
* from its own description: a title match would agree with either.
*/
async disconnect(): Promise<RevocationOutcome> {
// The dialog's own button carries the noun too, hence the exact match on
// the card's action.
await this.clickButton(/^\s*Disconnect\s*$/);
await this.clickButton(/Disconnect workspace/);
return this.waitFor(
() => {
if (this.alertMatching(/revocation/i)) {
return REVOCATION_OUTCOME.NOT_REVOKED;
}
if (this.containsText(/has been revoked/)) {
return REVOCATION_OUTCOME.REVOKED;
}
if (this.containsText(/is no longer connected to Prowler/)) {
return REVOCATION_OUTCOME.UNREPORTED;
}
return null;
},
15000,
"the disconnect outcome",
);
}
/**
* Whether the page is back to offering an install with no workspace
* connected. The consent URL is minted after the disconnect, so the install
* affordance appears a beat after the copy does.
*/
async returnedToUnconnectedState(): Promise<boolean> {
await this.waitForText(/No workspace connected/, 10000);
return (
(await this.waitForOrNull(
() => this.offersInstall(),
5000,
"the install to be offered again",
)) ?? false
);
}
/** Whether the page is asking the user to remove the access in Slack. */
showsRevocationNotice(): boolean {
return this.alertMatching(/revocation/i) !== null;
}
/**
* What the user is told when the row was removed but Slack never confirmed
* the revocation.
*/
async revocationNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(/revocation/i),
10000,
"the revocation notice",
);
return (notice.textContent ?? "").trim();
}
// --- A credential Slack no longer accepts --------------------------------
/** What the user is told when Slack has stopped accepting the credential. */
async revokedCredentialNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(/no longer accepts Prowler's access/),
10000,
"the revoked-credential notice",
);
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
}
/** Whether the page is saying Slack has stopped accepting the credential. */
showsRevokedCredentialNotice(): boolean {
return this.alertMatching(/no longer accepts Prowler's access/) !== null;
}
private reconnectLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
/Reconnect to Slack/.test(anchor.textContent ?? ""),
) ?? null
);
}
/** Whether the page offers to approve Prowler in the workspace again. */
offersReconnect(): boolean {
return this.reconnectLink() !== null;
}
/** The consent URL the reconnect affordance points at, once it is offered. */
async reconnectUrl(): Promise<string> {
const link = await this.waitFor(
() => this.reconnectLink(),
10000,
"the reconnect link",
);
return link.href;
}
/** The alert whose text matches, of however many the page is showing. */
private alertMatching(pattern: RegExp): HTMLElement | null {
return (
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
);
}
}
@@ -0,0 +1,834 @@
/**
* Browser-mode tests for the Slack integration page (`/integrations/slack`),
* driven through `SlackIntegrationHarness`. MSW answers from handlers derived
* from the API contract in `design.md`. The OAuth callback is its own route,
* covered in `slack-callback-page.integration.test.tsx`.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
configuredSlackFixture,
connectedSlackFixture,
INTEGRATIONS_SERVER_ERROR_DETAIL,
partiallyReadSlackFixture,
revokedTokenSlackFixture,
revokeFailureSlackFixture,
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
SLACK_MISSING_SCOPE_CODE,
SLACK_MISSING_SCOPE_REFUSAL,
SLACK_NOT_IN_CHANNEL_CODE,
SLACK_NOT_IN_CHANNEL_REFUSAL,
SLACK_PRIVATE_CHANNEL,
SLACK_PUBLIC_CHANNEL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
SLACK_TOKEN_EXPIRED_CODE,
SLACK_TOKEN_EXPIRED_REFUSAL,
SLACK_TOKEN_REVOKED_CODE,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UNMAPPED_REASON_CODE,
SLACK_UPSTREAM_REFUSAL,
slackFixture,
slackFixtureWithDefaultChannel,
unreadableCheckTimeSlackFixture,
unreportedRevocationSlackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import {
CONNECTION_OUTCOME,
REVOCATION_OUTCOME,
SlackIntegrationHarness,
TEST_MESSAGE_OUTCOME,
} from "./slack-integration.harness";
/** The shape the channel save is asserted against — only the id travels. */
interface PatchIntegrationBody {
data: { attributes: { configuration: { channel_id: string } } };
}
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/** The only scopes Prowler asks a workspace for (design D2). */
const REQUIRED_SCOPES = [
"chat:write",
"chat:write.public",
"channels:read",
"groups:read",
];
describe("starting the install", () => {
it("sends the user to Slack's consent screen for the access Prowler needs", async () => {
// Given — a tenant with no workspace connected yet.
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mount();
const consentScreen = new URL(await harness.connect());
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
"https://slack.com/oauth/v2/authorize",
);
const scopes = (consentScreen.searchParams.get("scope") ?? "").split(",");
expect(scopes).toHaveLength(REQUIRED_SCOPES.length);
expect(scopes).toEqual(expect.arrayContaining(REQUIRED_SCOPES));
// The state is server-minted, binding this install to the session
// (design D5).
expect(consentScreen.searchParams.get("state")).toBeTruthy();
}, 30000);
it("says so when the deployment has no Slack app, instead of offering an install", async () => {
// Given — no SLACK_CLIENT_ID/SECRET/REDIRECT_URI, which the API answers
// with a 503.
const harness = new SlackIntegrationHarness(
slackFixture({ appConfigured: false }),
);
await harness.mount();
// The read itself succeeded: an empty collection is what a deployment with
// no Slack app has, so nothing claims it failed.
await harness.waitForUnavailable();
expect(harness.offersInstall()).toBe(false);
expect(harness.saysLoadFailed()).toBe(false);
}, 30000);
it("still says the read failed when the deployment also has no Slack app", async () => {
// Given — both states, which coincide during rollout and rollback
// (design.md, Migration Plan §2-3 and §5).
const harness = new SlackIntegrationHarness(
slackFixture({ appConfigured: false, listServerError: true }),
);
await harness.mount();
// Both notices: the read's is the actionable half (a retry may still show a
// workspace this tenant has connected).
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
expect(harness.saysUnavailable()).toBe(true);
// The install is still not on offer: there is no Slack app to install into.
expect(harness.offersInstall()).toBe(false);
}, 30000);
it("says Slack is busy, not that the deployment has no Slack app, when it is rate limiting", async () => {
// Given — the app is configured; Slack rate limits (429) the call that
// mints the consent URL.
const harness = new SlackIntegrationHarness(
slackFixture({ rateLimited: true }),
);
await harness.mount();
expect(await harness.rateLimitNotice()).toMatch(/about 30 seconds/);
expect(harness.saysUnavailable()).toBe(false);
}, 30000);
it("keeps the page usable when reading the install fails on the server", async () => {
// Given — the shared `GET /integrations` read answers 500. The action
// throws instead of returning a result, so the page has to catch it:
// uncaught, the route's error boundary replaces the Slack page.
const harness = new SlackIntegrationHarness(
slackFixture({ listServerError: true }),
);
await harness.mount();
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
// The install stays on offer: one read failed, the Slack app is fine.
expect(harness.offersInstall()).toBe(true);
}, 30000);
it("keeps the page usable when Slack's own side is broken upstream", async () => {
// Given — the `502` the contract reserves for a Slack upstream failure.
// The UI's shared 5xx handling throws, so this is the page's other
// rejection path.
const harness = new SlackIntegrationHarness(
slackFixture({ oauthUpstreamError: true }),
);
await harness.mount();
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
// 502 is not 503: the app is configured, Slack is down.
expect(harness.saysUnavailable()).toBe(false);
}, 30000);
it("names the missing consent URL when a proxy answers that call with an HTML page", async () => {
// Given — a 200 carrying a challenge page instead of JSON. Nothing refused
// the call, so the action reaches its success path with no URL.
const harness = new SlackIntegrationHarness(
slackFixture({ authorizeUrlUnreadable: true }),
);
await harness.mount();
// V8 truncates the parse message to `"<!DOCTYPE "`, before the word `html`,
// so the UI's HTML-shaped-error filter can never match it.
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/did not return an authorization URL/);
expect(notice).not.toMatch(/DOCTYPE/i);
expect(notice).not.toMatch(/not valid JSON/i);
}, 30000);
});
describe("a connected workspace", () => {
it("identifies the workspace and reports the connection as healthy", async () => {
// Given — a finished setup: workspace approved and a destination channel
// recorded, which the API requires before it will check a connection.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
expect(await harness.offersConnectionTest()).toBe(true);
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
// One workspace per tenant (design D10): no second install on offer, and no
// consent URL minted for a page that would never use it.
expect(harness.offersInstall()).toBe(false);
expect(harness.authorizeUrlCallCount).toBe(0);
}, 30000);
it("still identifies the workspace before a destination channel is chosen", async () => {
// Given — the state the OAuth exchange leaves behind.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// The configuration carries no channel keys at all, which is "nothing
// chosen yet", not a broken install.
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(harness.offersInstall()).toBe(false);
}, 30000);
it("reports the connection as never checked, not as broken, before the first check", async () => {
// Given — the state the OAuth exchange leaves behind: `connected` is null,
// neither true nor false (design.md, "Connection state, in order").
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
const badge = await harness.connectionBadge();
expect(badge).toBe("Not checked yet");
expect(badge).not.toMatch(/Disconnected/);
}, 30000);
it("keeps the page usable when the recorded check time is one no parser can read", async () => {
// Given — a finished setup whose `connection_last_checked_at` is a zero
// date. `date-fns` throws a RangeError on it, which would replace the whole
// page with the route's error boundary.
const harness = new SlackIntegrationHarness(
unreadableCheckTimeSlackFixture(),
);
await harness.mount();
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
// Nothing to show, so nothing is shown: the same line a workspace that was
// never checked renders.
expect(harness.lastCheckedLine()).toBeNull();
}, 30000);
it("does not offer a connection check the API is bound to refuse", async () => {
// Given — a workspace connected and no destination channel recorded.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// The check posts to the destination channel, so with none recorded the API
// answers 400 rather than `connected: false`.
expect(await harness.offersConnectionTest()).toBe(false);
expect(harness.saysChannelIsNextStep()).toBe(true);
}, 30000);
});
describe("choosing a destination channel", () => {
it("offers the workspace's channels and remembers the one chosen", async () => {
// Given — a connected tenant whose channels span two cursor pages.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// Then — every channel is offered, so the picker followed `links.next`
// rather than stopping at the first page (design D6).
expect(await harness.channelOptions()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PRIVATE_CHANNEL.name,
]);
expect(harness.channelListCallCount).toBe(2);
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// Then — only the id is submitted: the API derives the name from it.
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
"PATCH",
"/integrations/",
);
expect(saved?.data.attributes.configuration).toEqual({
channel_id: SLACK_PUBLIC_CHANNEL.id,
});
// And — a later visit shows it, under the name the API derived from the id.
await harness.revisit();
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
}, 60000);
it("offers a private channel the app was invited to, marked as private, and saves it", async () => {
// Given — `@Prowler` was invited to one private channel; `groups:read` is
// membership-gated (D2).
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// Then
expect(await harness.channelOptions()).toContain(
SLACK_PRIVATE_CHANNEL.name,
);
expect(
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
).toBe(true);
expect(
await harness.isChannelShownAsPrivate(SLACK_PUBLIC_CHANNEL.name),
).toBe(false);
// When
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
// Then
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
}, 60000);
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
// Given — a workspace whose only channels are public: `groups:read` is
// membership-gated (design D2).
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channels: [
{ ...SLACK_PUBLIC_CHANNEL },
{ ...SLACK_SECOND_PUBLIC_CHANNEL },
],
}),
);
await harness.mount();
expect(await harness.channelOptions()).not.toContain(
SLACK_PRIVATE_CHANNEL.name,
);
// When — `@Prowler` is invited to a private channel, and the user refreshes
// instead of reconnecting the workspace.
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
await harness.refreshChannels();
// Then
expect(await harness.channelOptions()).toContain(
SLACK_PRIVATE_CHANNEL.name,
);
expect(
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
).toBe(true);
}, 60000);
it("says what to do when the workspace exposes no channel Prowler can post to", async () => {
// Given — a connected workspace exposing no channels at all.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({ channels: [] }),
);
// When
await harness.mount();
// Then — the user is told what to do, not merely that the list is empty.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/No channels available yet/);
expect(message).toMatch(/invite @Prowler/);
expect(await harness.defaultChannel()).toBeNull();
expect(harness.offersTestMessage()).toBe(false);
}, 30000);
it("offers the connection check as soon as the destination is saved, without a revisit", async () => {
// Given — connected with nothing recorded: the check posts to the
// destination, so it is not offered yet.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
expect(await harness.offersConnectionTest()).toBe(false);
expect(harness.saysChannelIsNextStep()).toBe(true);
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// Then — everything waiting on a destination moves with the save, in the
// same paint: no reload to find the check on offer.
expect(await harness.offersConnectionTest()).toBe(true);
expect(harness.saysChannelIsNextStep()).toBe(false);
// And — the check really runs.
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
}, 60000);
it("follows the destination recorded elsewhere when the page's data refreshes under it", async () => {
// Given — a finished setup, open on screen.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
// When — the destination changes elsewhere (a second tab, another user) and
// this page's server data refreshes under the open card, as
// `revalidatePath` does after an action.
await harness.channelRecordedElsewhere(SLACK_SECOND_PUBLIC_CHANNEL.name);
await harness.refreshPageData();
// Then — the card reports what is on record, not the copy it took at mount.
expect(await harness.defaultChannel()).toBe(
SLACK_SECOND_PUBLIC_CHANNEL.name,
);
expect(harness.offersTestMessage()).toBe(true);
// And — the picker followed too: the superseded destination is not left one
// click from being saved back.
expect(harness.offersChannelSave()).toBe(false);
}, 60000);
it("says which permission is missing when Slack refuses the channel listing, leaving the recorded channel alone", async () => {
// Given — a recorded destination, and an install missing a scope the listing
// needs. The API names it in `code` (contract, Errors), not in `detail`.
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
}),
);
// When
await harness.mount();
// Then — the reason, worded as a fix, with the invite copy still beside the
// picker.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/missing a permission it needs in Slack/);
expect(message).toMatch(/Connect the workspace again and approve/);
// Slack's reason is a protocol token: it travels in `code` and is never
// shown.
expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE);
expect(harness.channelInviteHint()).toMatch(/invites @Prowler/);
// And — a listing Prowler could not read says nothing about the channel
// already recorded.
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(harness.offersTestMessage()).toBe(true);
}, 30000);
it("names the wait Slack asked for when it rate limits the channel listing", async () => {
// Given — `conversations.list` is Slack tier 2 and paginated (contract,
// Errors); the `429` carries the wait in `Retry-After`.
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
}),
);
// When
await harness.mount();
// Then — when to come back, not just that it was refused: the wait is
// asserted, not only the wording.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/rate limiting/);
expect(message).toMatch(/about 30 seconds/);
// And — waiting is the fix, so nothing is said about permissions.
expect(message).not.toMatch(/permission/);
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
}, 30000);
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
// Given — a two-page workspace whose second page is rate limited
// (`conversations.list` is tier 2, contract, Errors).
const harness = new SlackIntegrationHarness(partiallyReadSlackFixture());
// When
await harness.mount();
// Then — the picker offers what was read rather than being replaced by the
// refusal: every reload re-runs the same reads into the same limit.
expect(await harness.channelOptions()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
SLACK_SECOND_PUBLIC_CHANNEL.name,
]);
expect(harness.saysChannelsUnreadable()).toBe(false);
// And — the wait is still said, as the explanation for the short list.
const notice = harness.partialListNotice();
expect(notice).toMatch(/rate limiting/);
expect(notice).toMatch(/about 30 seconds/);
// And — a partial read says nothing about the destination already recorded.
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(harness.offersTestMessage()).toBe(true);
}, 60000);
it("says nothing about a short list when the whole workspace was read", async () => {
// Given — the default workspace: two cursor pages, read to the end.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
// When
await harness.mount();
// Then
expect(harness.partialListNotice()).toBeNull();
}, 30000);
it("falls back to the API's wording when the listing fails upstream", async () => {
// Given — a `502`, which names no `code` because there is nothing to act on
// (contract, Errors).
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
}),
);
// When
await harness.mount();
// Then — the API's own `detail`, and not a wait that was never promised.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/Slack is temporarily unavailable/);
expect(message).not.toMatch(/rate limiting/);
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
}, 30000);
it("says to invite @Prowler when Slack refuses the channel because the app is not in it", async () => {
// Given — a private channel the app was removed from. The API validates the
// channel against Slack on the way in and refuses with `not_in_channel`.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
}),
);
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(
SLACK_PRIVATE_CHANNEL.name,
);
// Then — the one fix the user can carry out themselves, in Slack.
expect(refusal).toMatch(/Prowler is not in that channel/);
expect(refusal).toMatch(/Invite @Prowler to it in Slack/);
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
// And — nothing was recorded, so nothing is offered to post with.
expect(await harness.defaultChannel()).toBeNull();
expect(harness.offersTestMessage()).toBe(false);
}, 60000);
it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => {
// Given — a channel archived since the listing was read. The API's `detail`
// is word-for-word the one for `not_in_channel`, so only `code` tells them
// apart.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channelSaveRefusal: SLACK_CHANNEL_NOT_FOUND_REFUSAL,
}),
);
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
// Then — a different problem, so different copy: nothing to invite to a
// channel that no longer exists.
expect(refusal).toMatch(/no longer exists in the workspace/);
expect(refusal).toMatch(/Choose another one/);
expect(refusal).not.toMatch(/Invite @Prowler/);
expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL);
expect(await harness.defaultChannel()).toBeNull();
}, 60000);
});
describe("sending a test message", () => {
it("is not offered until a destination channel is recorded", async () => {
// Given — connected, but no channel chosen yet.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
// When
await harness.mount();
// Then
expect(await harness.defaultChannel()).toBeNull();
expect(harness.offersTestMessage()).toBe(false);
}, 30000);
it("sends a test message to the recorded channel and reports it delivered", async () => {
// Given — a tenant that has recorded where Prowler should post.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// When
const outcome = await harness.sendTestMessage();
// Then — sent, and the user reads which channel it went to.
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.SENT);
expect(await harness.lastTestMessageOutcome()).toMatch(
`#${SLACK_PUBLIC_CHANNEL.name}`,
);
}, 60000);
it("surfaces the reason when Slack refuses the test message", async () => {
// Given — the post fails, which the API reports on the task it handed back
// (design D9), not on the request that started it, using the same stable
// reason the synchronous endpoints put in `code`.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
testMessage: { accepted: false, error: SLACK_NOT_IN_CHANNEL_CODE },
}),
);
await harness.mount();
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// When
const outcome = await harness.sendTestMessage();
// Then — the same copy the synchronous refusals get, not the raw token.
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
const reported = await harness.lastTestMessageOutcome();
expect(reported).toMatch(/Prowler is not in that channel/);
expect(reported).toMatch(/Invite @Prowler to it in Slack/);
expect(reported).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
}, 60000);
it("reports a refusal the task words itself, rather than swallowing it", async () => {
// Given — a task result carrying prose instead of a stable reason; its exact
// shape is the cloud lane's to pin down (contract, test-message).
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
testMessage: {
accepted: false,
error: SLACK_TEST_MESSAGE_REFUSED_DETAIL,
},
}),
);
await harness.mount();
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// When
const outcome = await harness.sendTestMessage();
// Then
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
expect(await harness.lastTestMessageOutcome()).toMatch(
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
);
}, 60000);
it("keeps a reason it has no copy for inside its own sentence, not as the whole message", async () => {
// Given — a real Slack reason this UI has no copy for; Slack's set is
// open-ended, so this is the ordinary case.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
testMessage: { accepted: false, error: SLACK_UNMAPPED_REASON_CODE },
}),
);
await harness.mount();
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// When
const outcome = await harness.sendTestMessage();
// Then — Prowler's wording, with Slack's word for it kept for diagnosis.
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
const reported = await harness.lastTestMessageOutcome();
expect(reported).toMatch(/Slack refused the message/);
expect(reported).toMatch(SLACK_UNMAPPED_REASON_CODE);
expect(reported).not.toBe(SLACK_UNMAPPED_REASON_CODE);
}, 60000);
});
describe("disconnecting a workspace", () => {
it("removes the integration and returns the card to its unconnected state", async () => {
// Given — a tenant with a workspace connected.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// When — the user disconnects and confirms; Slack confirms the revocation.
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
expect(harness.disconnectCallCount).toBe(1);
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
it("still removes the integration when the revocation fails, and says access may need removing by hand", async () => {
// Given — Slack will not accept the revocation; the row goes either way.
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
await harness.mount();
// When
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
// And — the disconnect revalidates, so the copy below is read from props
// that no longer carry an integration at all.
await harness.refreshPageData();
// Then — what is true of both sides: nothing is left in Prowler to retry,
// and the app may still be installed at Slack.
const notice = await harness.revocationNotice();
expect(notice).toMatch(/gone from Prowler/);
expect(notice).toMatch(/nothing to retry here/);
expect(notice).toMatch(/may still be installed in Prowler HQ/);
expect(notice).toMatch(
/remove it from that workspace's Slack app settings/,
);
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
it("says only that the workspace is no longer connected when nothing reports the revocation", async () => {
// Given — the plain `204` a deployment that overrides nothing answers: no
// body, so no `meta` to read the outcome from. The case users really meet.
const harness = new SlackIntegrationHarness(
unreportedRevocationSlackFixture(),
);
await harness.mount();
// When
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.UNREPORTED);
// Then — nothing sends the user to Slack to finish a job no answer said
// was unfinished.
expect(harness.showsRevocationNotice()).toBe(false);
expect(await harness.returnedToUnconnectedState()).toBe(true);
}, 30000);
});
describe("a credential Slack no longer accepts", () => {
it("says the connection check found a dead credential, and offers to connect the workspace again", async () => {
// Given — the token was revoked at Slack, so the row still reads connected
// until a check runs (contract, Cross-cutting).
const harness = new SlackIntegrationHarness(revokedTokenSlackFixture());
await harness.mount();
// When
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
// Then — a way forward rather than only an error: a revoked token is fixed
// by approving Prowler again, not by checking a second time.
const notice = await harness.revokedCredentialNotice();
expect(notice).toMatch(/no longer accepts Prowler's access to Prowler HQ/);
expect(notice).toMatch(/Prowler's access to Slack was revoked/);
expect(notice).toMatch(/Connect the workspace again to restore access/);
// Slack's reason is a protocol token: it is what the UI switched on, never
// what it showed.
expect(notice).not.toMatch(new RegExp(SLACK_TOKEN_REVOKED_CODE));
const consentScreen = new URL(await harness.reconnectUrl());
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
"https://slack.com/oauth/v2/authorize",
);
expect(harness.offersReconnect()).toBe(true);
}, 30000);
it("offers the same recovery when the channel listing is what finds the credential dead", async () => {
// Given — a finished setup whose credential expired. The listing runs on
// arrival, so it meets Slack before any check does, and the contract says
// any call can be the one that surfaces this.
const harness = new SlackIntegrationHarness(
configuredSlackFixture({ channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL }),
);
// When — nothing but opening the page.
await harness.mount();
// Then — the same answer the connection check gives, worded for how this
// credential died rather than left as a channel problem.
const notice = await harness.revokedCredentialNotice();
expect(notice).toMatch(/Prowler's Slack credential has expired/);
expect(notice).toMatch(/Connect the workspace again to restore access/);
expect(harness.offersReconnect()).toBe(true);
// And — the picker says the same, in the same words: `detail` names the raw
// reason, and it is `code` the UI answered from.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/Prowler's Slack credential has expired/);
expect(message).not.toMatch(new RegExp(SLACK_TOKEN_EXPIRED_CODE));
}, 30000);
it("offers it too when only a later cursor page is what Slack refuses", async () => {
// Given — a two-page workspace whose second page is refused by a credential
// Slack no longer accepts: the read stops short rather than failing.
const harness = new SlackIntegrationHarness(
partiallyReadSlackFixture({
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
}),
);
// When — nothing but opening the page.
await harness.mount();
// Then — what was read stays on offer, as it does for any short list.
expect(await harness.channelOptions()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
SLACK_SECOND_PUBLIC_CHANNEL.name,
]);
// And — the dead credential is reported all the same: a picker that still
// works is no reason to leave the user without the one fix there is.
const notice = await harness.revokedCredentialNotice();
expect(notice).toMatch(/Prowler's Slack credential has expired/);
expect(harness.offersReconnect()).toBe(true);
expect(await harness.connectionBadge()).toBe("Disconnected");
}, 60000);
it("keeps saying so when a later check fails without Slack naming a reason", async () => {
// Given — the listing found the credential dead on arrival, and a later
// check that fails naming no reason at all.
const harness = new SlackIntegrationHarness(
configuredSlackFixture({
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
connection: { connected: false, error: null },
}),
);
await harness.mount();
expect(await harness.revokedCredentialNotice()).toMatch(
/Prowler's Slack credential has expired/,
);
// When
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
// Then — a failure Slack never answered is no evidence the grant works
// again, so the dead credential is still what the page reports.
expect(await harness.revokedCredentialNotice()).toMatch(
/Prowler's Slack credential has expired/,
);
expect(harness.offersReconnect()).toBe(true);
expect(await harness.connectionBadge()).toBe("Disconnected");
}, 60000);
it("stops saying so once a save Slack validated goes through", async () => {
// Given — a finished setup whose test message found the grant revoked.
const harness = new SlackIntegrationHarness(
configuredSlackFixture({
testMessage: { accepted: false, error: SLACK_TOKEN_REVOKED_CODE },
}),
);
await harness.mount();
expect(await harness.connectionBadge()).toBe("Connected");
expect(await harness.sendTestMessage()).toBe(TEST_MESSAGE_OUTCOME.FAILED);
expect(harness.showsRevokedCredentialNotice()).toBe(true);
expect(await harness.connectionBadge()).toBe("Disconnected");
// When — the access is approved again in Slack, away from this page, and
// the user saves a destination here. The API validates the channel against
// Slack, so the save is an answer about the credential.
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
// Then — Slack answered, so the notice about a credential it no longer
// accepts goes, and the card is back to what it reported on arrival.
expect(harness.showsRevokedCredentialNotice()).toBe(false);
expect(harness.offersReconnect()).toBe(false);
expect(await harness.connectionBadge()).toBe("Connected");
}, 60000);
});
@@ -32,13 +32,6 @@ export const ProvidersTabContent = async ({
}: {
searchParams: SearchParamsProps;
}) => {
// The React Compiler (`reactCompiler: true`) otherwise instruments this as a
// client component and injects `useMemoCache`, which needs a React dispatcher.
// An async server component renders once per request, so there is nothing to
// memoize — and the injected hook makes it uncallable outside a render, which
// is exactly how the browser-mode tests mount it.
"use no memo";
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({
@@ -0,0 +1 @@
Slack integration: connect a Slack workspace from the Integrations page (Prowler Cloud only)
@@ -752,6 +752,51 @@ export const JiraIcon: React.FC<IconSvgProps> = ({
</svg>
);
export const SlackIcon: React.FC<IconSvgProps> = ({
size = 32,
width,
height,
className = "rounded-md",
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={height ?? size}
viewBox="0 0 48 48"
width={width ?? size}
className={className}
{...props}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z"
fill="#FFFFFF"
/>
{/* Slack mark on its native 122.8 grid, scaled into the 48px tile's 30px
safe area (30 / 122.8 = 0.2443). */}
<g transform="translate(9 9) scale(0.2443)">
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</g>
</svg>
);
export const AWSSecurityHubIcon: React.FC<IconSvgProps> = ({
size = 32,
width,
@@ -6,18 +6,47 @@ import { ReactNode } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
// `null` means never checked, not disconnected: it must not get the fail tokens.
const CONNECTION_BADGE = {
connected: {
label: "Connected",
className:
"bg-bg-pass-secondary text-text-success-primary border-transparent",
},
disconnected: {
label: "Disconnected",
className:
"bg-bg-fail-secondary text-text-error-primary border-transparent",
},
unchecked: {
label: "Not checked yet",
className: "border-border-tag bg-bg-tag text-text-neutral-secondary",
},
} as const;
type ConnectionBadgeState = keyof typeof CONNECTION_BADGE;
const connectionBadgeState = (
connected: boolean | null,
): ConnectionBadgeState =>
connected === null ? "unchecked" : connected ? "connected" : "disconnected";
interface IntegrationCardChip {
label: string;
className?: string;
}
interface IntegrationConnectionStatus {
connected: boolean | null;
label?: string;
}
interface IntegrationCardHeaderProps {
icon: ReactNode;
title: string;
subtitle?: string;
chips?: Array<{
label: string;
className?: string;
}>;
connectionStatus?: {
connected: boolean;
label?: string;
};
chips?: IntegrationCardChip[];
connectionStatus?: IntegrationConnectionStatus;
navigationUrl?: string;
}
@@ -29,6 +58,11 @@ export const IntegrationCardHeader = ({
connectionStatus,
navigationUrl,
}: IntegrationCardHeaderProps) => {
const badgeState = connectionStatus
? connectionBadgeState(connectionStatus.connected)
: null;
const badge = badgeState ? CONNECTION_BADGE[badgeState] : null;
return (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
@@ -55,7 +89,7 @@ export const IntegrationCardHeader = ({
)}
</div>
</div>
{(chips.length > 0 || connectionStatus) && (
{(chips.length > 0 || badge) && (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip, index) => (
<Badge
@@ -69,18 +103,13 @@ export const IntegrationCardHeader = ({
{chip.label}
</Badge>
))}
{connectionStatus && (
{badge && badgeState && (
<Badge
variant="outline"
className={cn(
"text-xs font-normal",
connectionStatus.connected
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
: "bg-bg-fail-secondary text-text-error-primary border-transparent",
)}
data-connection-status={badgeState}
className={cn("text-xs font-normal", badge.className)}
>
{connectionStatus.label ||
(connectionStatus.connected ? "Connected" : "Disconnected")}
{connectionStatus?.label || badge.label}
</Badge>
)}
</div>
@@ -0,0 +1,156 @@
/**
* The cases `slack-page.integration.test.tsx` cannot express: it runs the Server
* Action as a plain function, so there is no client→server transport to reject,
* and its handler only answers the contract's shapes. React error boundaries
* cannot see a rejection awaited in an effect, so an uncaught one leaves the
* user on the spinner with no error and no way out.
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import { SlackCallback } from "./slack-callback";
const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a";
const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted(
() => ({
exchangeSlackOAuthCode: vi.fn(),
callbackQuery: { value: "" },
routerReplace: vi.fn(),
}),
);
vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode }));
// One router across renders, so the redirect off the spent code is assertable.
const router = { replace: routerReplace };
vi.mock("next/navigation", () => ({
useRouter: () => router,
useSearchParams: () => new URLSearchParams(callbackQuery.value),
}));
beforeEach(() => {
callbackQuery.value = COMPLETED_QUERY;
routerReplace.mockClear();
});
const SPINNER_COPY = /Connecting your Slack workspace/;
/**
* Literals, not imports: a rename on the component's side has to fail here.
* `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes
* that happen before the API consumed the code.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack when the completion answers unexpectedly", () => {
it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => {
// The client→server POST itself fails (dropped connection, action id
// invalidated by a deploy), so the action's own error handling never runs.
exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch"));
render(<SlackCallback />);
// The API consumes the single-use code before answering, so the workspace
// may well be connected: unknown, not failed.
expect(
await screen.findByText(/could not confirm whether the workspace/i),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Back to Slack integration/ }),
).toHaveAttribute("href", "/integrations/slack");
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument();
expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument();
});
it("still reports the workspace as connected when the created integration carries no configuration", async () => {
// The install already succeeded; `configuration` only goes missing on the
// client, where the callback reads the workspace name off it.
exchangeSlackOAuthCode.mockResolvedValue({
integration: {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: null,
connection_last_checked_at: null,
integration_type: "slack",
},
links: { self: "/api/v1/integrations/slack-integration-1" },
// Cast: the shape is the one the contract rules out.
} as unknown as IntegrationProps,
});
render(<SlackCallback />);
expect(
await screen.findByText(/Connected to your Slack workspace/),
).toBeInTheDocument();
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
// Keyed on the escape link, the only element unique to the failure branch,
// so this holds whichever headline that branch would have carried.
expect(
screen.queryByRole("link", { name: /Back to Slack integration/ }),
).not.toBeInTheDocument();
// `replace`, not `push`: a back navigation must not remount onto the code.
expect(routerReplace).toHaveBeenCalledWith("/integrations/slack");
});
});
describe("returning from Slack with an error on the callback URL", () => {
it("says the install was declined when Slack reports the approval was refused", async () => {
// The one code Slack reliably sends to this redirect.
callbackQuery.value = "error=access_denied&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(/was not approved in Slack/),
).toBeInTheDocument();
expect(exchangeSlackOAuthCode).not.toHaveBeenCalled();
// Slack refused before issuing a code, so the flat "not connected" is a
// fact here, unlike in the outcomes that follow an exchange.
expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument();
expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument();
});
it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => {
// Slack publishes no closed set of codes for this redirect, so the guard is
// on the shape of the value rather than on an allowlist.
callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(
"Slack could not complete the install (invalid_scope).",
),
).toBeInTheDocument();
});
it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => {
// The balancing punctuation is the point: it closes Prowler's parenthetical
// and reopens it, so the payload would read as Prowler's own sentence.
const payload =
"). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting (";
callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`;
render(<SlackCallback />);
expect(
await screen.findByText("Slack could not complete the install."),
).toBeInTheDocument();
expect(document.body.textContent).not.toContain("+1-555-0100");
expect(document.body.textContent).not.toContain("flagged this workspace");
});
});
@@ -0,0 +1,159 @@
"use client";
import { AlertCircle, CircleCheck, Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
} from "@/components/shadcn";
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
const SLACK_INTEGRATION_PATH = "/integrations/slack";
const STATUS = {
CONNECTING: "connecting",
CONNECTED: "connected",
FAILED: "failed",
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
const UNCONFIRMED_COMPLETION_MESSAGE =
"Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again.";
const FAILURE_TITLE = "Slack workspace not connected";
/**
* The API consumes the code and upserts the integration before it answers, so an
* unreadable or missing answer can still mean a connected workspace. Kept short:
* `AlertTitle` clamps to one line.
*/
const UNCONFIRMED_TITLE = "Slack install not confirmed";
const describeSlackError = (reason: string): string => {
if (reason === "access_denied") {
return "The install was not approved in Slack, so no workspace was connected.";
}
// `error` comes straight off the URL and is interpolated into Prowler's own
// copy, so gate on the shape of a code: Slack publishes no closed set.
return SLACK_REASON_TOKEN.test(reason)
? `Slack could not complete the install (${reason}).`
: "Slack could not complete the install.";
};
/**
* Slack's `code` is single-use: `hasStarted` holds the exchange to one run per
* mount, and `router.replace` (not `push`) keeps a back navigation from
* remounting onto a spent code.
*/
export const SlackCallback = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<Status>(STATUS.CONNECTING);
const [workspaceName, setWorkspaceName] = useState<string | null>(null);
const [failure, setFailure] = useState<string>("");
const [failureTitle, setFailureTitle] = useState<string>(FAILURE_TITLE);
const hasStarted = useRef(false);
useEffect(() => {
if (hasStarted.current) return;
hasStarted.current = true;
const slackError = searchParams.get("error");
const code = searchParams.get("code");
const state = searchParams.get("state");
// Slack answers a declined install with `error` and no code, so there is
// nothing to exchange.
if (slackError) {
setFailure(describeSlackError(slackError));
setStatus(STATUS.FAILED);
return;
}
if (!code || !state) {
setFailure(
"Slack sent an incomplete response back, so the install could not be completed.",
);
setStatus(STATUS.FAILED);
return;
}
const complete = async () => {
const result = await exchangeSlackOAuthCode({ code, state });
if ("integration" in result) {
setWorkspaceName(
result.integration.attributes?.configuration?.team_name ?? null,
);
setStatus(STATUS.CONNECTED);
router.replace(SLACK_INTEGRATION_PATH);
return;
}
if ("unavailable" in result) {
setFailure("Slack is not available in this environment yet.");
} else if ("rateLimited" in result) {
setFailure(result.message);
} else if ("unconfirmed" in result) {
setFailure(result.message);
setFailureTitle(UNCONFIRMED_TITLE);
} else {
setFailure(result.error);
}
setStatus(STATUS.FAILED);
};
// A rejection here means the call never came back (stale action id after a
// deploy, HTML 502): error boundaries cannot see a rejection awaited inside
// an effect, and the once-guard blocks a retry, so the page would spin.
void complete().catch(() => {
setFailure(UNCONFIRMED_COMPLETION_MESSAGE);
setFailureTitle(UNCONFIRMED_TITLE);
setStatus(STATUS.FAILED);
});
}, [router, searchParams]);
if (status === STATUS.CONNECTING) {
return (
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<Loader2 className="animate-spin" size={16} />
Connecting your Slack workspace...
</div>
);
}
if (status === STATUS.CONNECTED) {
return (
<Alert variant="success">
<CircleCheck />
<AlertTitle>
Connected to {workspaceName ?? "your Slack workspace"}
</AlertTitle>
<AlertDescription>
Taking you back to the Slack integration, where you can choose the
channel Prowler posts to.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col items-start gap-4">
<Alert variant="error">
<AlertCircle />
<AlertTitle>{failureTitle}</AlertTitle>
<AlertDescription>{failure}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href={SLACK_INTEGRATION_PATH}>Back to Slack integration</Link>
</Button>
</div>
);
};
@@ -0,0 +1,132 @@
"use client";
import { Lock, RefreshCw } from "lucide-react";
import {
Alert,
AlertDescription,
AlertTitle,
Badge,
Button,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import type { SlackChannelOption } from "@/types/integrations";
const INVITE_HINT =
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
interface SlackChannelSelectorProps {
options: SlackChannelOption[];
value: string | null;
onChange: (channelId: string) => void;
isLoading?: boolean;
/** Why the channels could not be read — Slack's own reason, when it gave one. */
error?: string | null;
/** Why the list is partial. Shown with the picker, not instead of it. */
incompleteNotice?: string | null;
onRefresh?: () => void;
disabled?: boolean;
}
/** Driven entirely by props (design D13) so the alert-rule form can reuse it. */
export const SlackChannelSelector = ({
options,
value,
onChange,
isLoading = false,
error = null,
incompleteNotice = null,
onRefresh,
disabled = false,
}: SlackChannelSelectorProps) => {
const isEmpty = !isLoading && !error && options.length === 0;
// `htmlFor` may only name an element that exists, and the trigger is only
// rendered in the picker branch.
const hasPicker = !error && !isEmpty;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={hasPicker ? "slack-channel" : undefined}>
Destination channel
</Label>
{onRefresh && (
<Button
size="sm"
variant="outline"
disabled={isLoading}
onClick={onRefresh}
>
<RefreshCw size={14} />
{isLoading ? "Refreshing..." : "Refresh channels"}
</Button>
)}
</div>
{error ? (
<Alert variant="error">
<AlertTitle>Could not read the workspace&apos;s channels</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : isEmpty ? (
<Alert variant="info">
<AlertTitle>No channels available yet</AlertTitle>
<AlertDescription>
Prowler cannot see a single channel in this workspace. Create a
public channel, or invite @Prowler to a private one in Slack with
<span className="font-medium"> /invite @Prowler</span>, then
refresh.
</AlertDescription>
</Alert>
) : (
<>
{incompleteNotice && (
<Alert variant="warning" data-channels-notice>
<AlertTitle>Not every channel is listed</AlertTitle>
<AlertDescription>{incompleteNotice}</AlertDescription>
</Alert>
)}
<Select
value={value ?? undefined}
onValueChange={onChange}
disabled={disabled || isLoading}
>
<SelectTrigger id="slack-channel" size="sm">
<SelectValue
placeholder={
isLoading ? "Reading channels..." : "Choose a channel"
}
/>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.id}
value={option.id}
// Name hook: the rendered label mixes it with a lock icon
// and a "Private" badge.
data-channel={option.name}
>
{option.is_private && <Lock size={14} aria-hidden="true" />}
<span className="min-w-0 truncate">#{option.name}</span>
{option.is_private && (
<Badge variant="tag" size="sm">
Private
</Badge>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<p className="text-text-neutral-secondary text-xs">{INVITE_HINT}</p>
</div>
);
};
@@ -0,0 +1,55 @@
import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
// Placeholder slug: the docs slice writes the page and confirms it.
const SLACK_DOCS_URL =
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/";
export const SlackIntegrationCard = () => {
return (
<Card variant="base" padding="lg">
<CardHeader>
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<SlackIcon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
Slack
</h4>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<p className="text-xs text-nowrap text-gray-500 dark:text-gray-300">
Send Prowler messages to your Slack workspace.
</p>
<CustomLink
href={SLACK_DOCS_URL}
aria-label="Learn more about Slack integration"
size="xs"
>
Learn more
</CustomLink>
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<Button asChild size="sm">
<Link href="/integrations/slack">
<SettingsIcon size={14} />
Manage
</Link>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect a Slack workspace and pick the channel Prowler posts to, so
your team gets security updates where it already works.
</p>
</CardContent>
</Card>
);
};
@@ -0,0 +1,66 @@
/**
* The case `slack-page.integration.test.tsx` cannot express: it asserts against
* a hydrated, settled page, so it never sees the first frame the user is
* served. The effect that reads the channels only runs in the browser, so the
* channel state at render time is what the served HTML says until hydration.
*/
import { renderToString } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
import { SlackIntegrationManager } from "./slack-integration-manager";
vi.mock("@/actions/integrations/slack", () => ({
getSlackChannels: vi.fn(),
sendSlackTestMessage: vi.fn(),
setSlackDefaultChannel: vi.fn(),
}));
vi.mock("@/actions/integrations/integrations", () => ({
testIntegrationConnection: vi.fn(),
}));
/**
* A connected workspace with no channel recorded, as the contract has it before
* a save: with one, the picker would show that channel instead of the
* placeholder this test reads.
*/
const CONNECTED_WORKSPACE: IntegrationProps = {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-08-10T09:05:00Z",
integration_type: INTEGRATION_TYPE.SLACK,
configuration: {
team_id: "T024BE7LD",
team_name: "Prowler HQ",
bot_user_id: "U0KRQLJ9H",
},
},
links: { self: "/api/v1/integrations/slack-integration-1" },
};
describe("the first paint of a connected workspace", () => {
it("reads as still reading the channels rather than as a workspace with none", () => {
// When
const serverHtml = renderToString(
<SlackIntegrationManager
integration={CONNECTED_WORKSPACE}
authorizeUrl={null}
unavailable={false}
rateLimitMessage={null}
loadError={null}
/>,
);
// Then
expect(serverHtml).toContain("Reading channels...");
expect(serverHtml).not.toContain("No channels available yet");
});
});
@@ -0,0 +1,745 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import { Send, TestTube, Unplug } from "lucide-react";
import { useEffect, useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import {
disconnectSlackIntegration,
getSlackAuthorizeUrl,
getSlackChannels,
sendSlackTestMessage,
setSlackDefaultChannel,
} from "@/actions/integrations/slack";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { IntegrationCardHeader } from "@/components/integrations/shared";
import { SlackChannelSelector } from "@/components/integrations/slack/slack-channel-selector";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
useToast,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
isSlackTokenErrorCode,
SLACK_REASON_TOKEN,
slackErrorMessage,
} from "@/lib/integrations/slack-errors";
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
import type {
IntegrationProps,
SlackChannelOption,
} from "@/types/integrations";
const CHANNELS_STATUS = {
LOADING: "loading",
ERROR: "error",
LOADED: "loaded",
} as const;
interface ChannelsLoading {
status: typeof CHANNELS_STATUS.LOADING;
}
interface ChannelsFailed {
status: typeof CHANNELS_STATUS.ERROR;
message: string;
}
interface ChannelsLoaded {
status: typeof CHANNELS_STATUS.LOADED;
channels: SlackChannelOption[];
// Rides with the list it qualifies, so it can never outlive it.
notice: string | null;
}
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
const TEST_MESSAGE_STATUS = {
IDLE: "idle",
SENDING: "sending",
SENT: "sent",
FAILED: "failed",
} as const;
interface TestMessageIdle {
status: typeof TEST_MESSAGE_STATUS.IDLE;
}
interface TestMessageSending {
status: typeof TEST_MESSAGE_STATUS.SENDING;
}
interface TestMessageSent {
status: typeof TEST_MESSAGE_STATUS.SENT;
detail: string;
}
interface TestMessageFailed {
status: typeof TEST_MESSAGE_STATUS.FAILED;
detail: string;
}
type TestMessageState =
| TestMessageIdle
| TestMessageSending
| TestMessageSent
| TestMessageFailed;
/**
* A disconnect that removed the row without Slack confirming the revocation.
* The workspace name travels with it: the notice exists to name the workspace
* to clean up, and the record is gone by the time revalidation lands.
*/
interface UnconfirmedRevocation {
workspace: string | null;
}
// The name may be missing: the id decides what the UI can do with it.
interface SlackChannelRef {
id: string;
name: string | null;
}
const channelRefEquals = (
a: SlackChannelRef | null,
b: SlackChannelRef | null,
) => a?.id === b?.id && a?.name === b?.name;
/**
* Slack's own reason, when the string is one: the connection check reports a
* reason and its own prose in the same field, and only a reason is an answer
* from Slack about the credential.
*/
const asReasonCode = (reason: string | null): string | null =>
reason && SLACK_REASON_TOKEN.test(reason) ? reason : null;
interface SlackIntegrationManagerProps {
/** At most one exists per tenant (one workspace). */
integration: IntegrationProps | null;
authorizeUrl: string | null;
/** This deployment has no Prowler Slack app, so no install can be started. */
unavailable: boolean;
rateLimitMessage: string | null;
loadError: string | null;
}
export const SlackIntegrationManager = ({
integration,
authorizeUrl,
unavailable,
rateLimitMessage,
loadError,
}: SlackIntegrationManagerProps) => {
const [isTesting, setIsTesting] = useState(false);
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
// The row is gone the moment the API says so; the server component's
// revalidation only catches up on the next navigation.
const [disconnected, setDisconnected] = useState(false);
const [unconfirmedRevocation, setUnconfirmedRevocation] =
useState<UnconfirmedRevocation | null>(null);
/**
* The `code` of the last refusal any Slack-backed call ran into, or `null`
* when the last answer was not a refusal. A dead grant can surface from any
* of them (contract, Cross-cutting), so every call reports here instead of
* deciding on its own.
*/
const [lastRefusalCode, setLastRefusalCode] = useState<string | null>(null);
// A connected workspace arrives with no consent URL, since no install is left
// to start (design D10), so one is minted only if a reconnect turns out to be
// the way out.
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
const { toast } = useToast();
const integrationId = integration?.id ?? null;
const recordedChannelId =
integration?.attributes.configuration.channel_id ?? null;
const recordedChannelName =
integration?.attributes.configuration.channel_name ?? null;
const recordedChannel: SlackChannelRef | null = recordedChannelId
? { id: recordedChannelId, name: recordedChannelName }
: null;
// Seeded `loading`, not by the effect: the effect never runs on the server,
// so anything else would server-render a "no channels" picker until
// hydration.
const [channelsState, setChannelsState] = useState<ChannelsState>(
integrationId
? { status: CHANNELS_STATUS.LOADING }
: { status: CHANNELS_STATUS.LOADED, channels: [], notice: null },
);
// Bumped by refresh: a channel invited after load only shows on a re-read.
const [channelReloads, setChannelReloads] = useState(0);
// Local state needed: the pick is buffered until the user saves it.
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
recordedChannelId,
);
// Mirrored in state, not read from the prop, so channel-gated affordances
// move on save instead of waiting for the revalidation.
const [defaultChannel, setDefaultChannel] = useState(recordedChannel);
// The prop the mirror was last taken from: the card never unmounts, so a
// mirror seeded only at mount would go stale when the record changes.
const [syncedChannel, setSyncedChannel] = useState(recordedChannel);
const [isSavingChannel, setIsSavingChannel] = useState(false);
const [testMessageState, setTestMessageState] = useState<TestMessageState>({
status: TEST_MESSAGE_STATUS.IDLE,
});
if (!channelRefEquals(recordedChannel, syncedChannel)) {
const previousSyncedId = syncedChannel?.id ?? null;
setSyncedChannel(recordedChannel);
setDefaultChannel(recordedChannel);
// Follow the record only while the buffered pick still matches it: an
// unsaved pick is the user's, not ours to overwrite mid-edit.
if (selectedChannelId === previousSyncedId) {
setSelectedChannelId(recordedChannel?.id ?? null);
}
}
// Only an answer from Slack moves the bus: a call that never got one proves
// nothing and leaves the last answer standing.
const provedCredentialAlive = () => setLastRefusalCode(null);
const recordRefusal = (code: string | null | undefined) => {
if (code) setLastRefusalCode(code);
};
/**
* Whether the last refusal proves the grant itself is dead, rather than a
* channel unreachable or Slack busy. Derived, not stored, so it self-clears:
* a later call Slack answered at all (even to refuse a channel) is proof the
* credential works again, and the notice goes with it.
*/
const credentialFailure: SlackTokenErrorCode | null = isSlackTokenErrorCode(
lastRefusalCode,
)
? lastRefusalCode
: null;
const needsInstallUrl = disconnected || credentialFailure !== null;
useEffect(() => {
if (!needsInstallUrl) return;
let cancelled = false;
getSlackAuthorizeUrl()
.then((result) => {
if (cancelled || !("authorizeUrl" in result)) return;
setMintedInstallUrl(result.authorizeUrl);
})
.catch(() => {
// Nothing to say: the page loses a shortcut, not a way to reconnect.
});
return () => {
cancelled = true;
};
}, [needsInstallUrl]);
useEffect(() => {
if (!integrationId) return;
let cancelled = false;
setChannelsState({ status: CHANNELS_STATUS.LOADING });
getSlackChannels(integrationId)
.then((result) => {
if (cancelled) return;
setChannelsState(
"error" in result
? { status: CHANNELS_STATUS.ERROR, message: result.error }
: {
status: CHANNELS_STATUS.LOADED,
channels: result.channels,
notice: result.incomplete ?? null,
},
);
// The listing runs on arrival, so it is where a dead credential shows
// up first. A read cut short still names its refusal's code, so a grant
// that died on a later cursor page is heard too; a truncation naming
// none was Slack busy, not refusing.
if ("error" in result || result.code) recordRefusal(result.code);
else provedCredentialAlive();
})
.catch(() => {
if (cancelled) return;
setChannelsState({
status: CHANNELS_STATUS.ERROR,
message: "Could not reach Slack to read the channel list.",
});
});
return () => {
cancelled = true;
};
}, [integrationId, channelReloads]);
const channels =
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.channels
: [];
const handleSaveChannel = async () => {
if (!integrationId || !selectedChannelId) return;
setIsSavingChannel(true);
try {
// Only the id travels — the API validates it and derives the name
// (design D6).
const result = await setSlackDefaultChannel(
integrationId,
selectedChannelId,
);
if ("error" in result) {
// The API validates the channel against Slack, so the save can
// discover the credential is gone.
recordRefusal(result.code);
toast({
variant: "destructive",
title: "Could not save the destination channel",
description: result.error,
});
return;
}
// Prefer the API's derived name: a channel renamed in Slack since the
// list was read would otherwise show its old name.
const savedName =
result.integration.attributes.configuration.channel_name ??
channels.find((channel) => channel.id === selectedChannelId)?.name ??
null;
provedCredentialAlive();
setDefaultChannel({ id: selectedChannelId, name: savedName });
// An outcome about the previous destination would mislead here.
setTestMessageState({ status: TEST_MESSAGE_STATUS.IDLE });
toast({
title: "Destination channel saved",
description: savedName
? `Prowler will post to #${savedName}.`
: "Prowler will post to the channel you chose.",
});
} catch (_error) {
toast({
variant: "destructive",
title: "Could not save the destination channel",
description: "Something went wrong. Please try again.",
});
} finally {
setIsSavingChannel(false);
}
};
const handleSendTestMessage = async () => {
if (!integrationId) return;
setTestMessageState({ status: TEST_MESSAGE_STATUS.SENDING });
try {
const result = await sendSlackTestMessage(integrationId);
// The post happens in a task, so a credential that died since the last
// check surfaces here.
if ("error" in result) recordRefusal(result.code);
else provedCredentialAlive();
setTestMessageState(
"sent" in result
? {
status: TEST_MESSAGE_STATUS.SENT,
detail: defaultChannel?.name
? `Prowler posted a test message to #${defaultChannel.name}.`
: "Prowler posted a test message to your default channel.",
}
: { status: TEST_MESSAGE_STATUS.FAILED, detail: result.error },
);
} catch (_error) {
setTestMessageState({
status: TEST_MESSAGE_STATUS.FAILED,
detail: "Something went wrong. Please try again.",
});
}
};
const handleTestConnection = async (id: string) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
provedCredentialAlive();
toast({
title: "Connection test successful!",
description:
result.message || "Prowler can reach your Slack workspace.",
});
} else {
// A dead credential named here is not a failure checking again can
// fix, so the reason is recorded and not only reported.
const reason = result.error?.trim() || null;
recordRefusal(asReasonCode(reason));
toast({
variant: "destructive",
title: "Connection test failed",
description: reason
? slackErrorMessage({ code: reason, detail: reason })
: "Failed to reach your Slack workspace.",
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(false);
}
};
const handleDisconnect = async (id: string) => {
const recordedWorkspace =
integration?.attributes.configuration.team_name ?? null;
const workspace = recordedWorkspace ?? "your Slack workspace";
setIsDisconnecting(true);
try {
const result = await disconnectSlackIntegration(id);
if ("error" in result) {
toast({
variant: "destructive",
title: "Disconnect failed",
description: result.error,
});
return;
}
const { revoked } = result.revocation;
// The row is gone whatever Slack answered, so the page goes back to its
// unconnected state either way, and a dead credential is moot once the
// row it belonged to is gone.
setDisconnected(true);
setLastRefusalCode(null);
// Only an explicit `false` sends the user to finish the job in Slack: an
// unreported outcome is neither a failed revocation nor a confirmed one,
// so it claims neither.
setUnconfirmedRevocation(
revoked === false ? { workspace: recordedWorkspace } : null,
);
if (revoked !== false) {
toast({
title: "Slack workspace disconnected",
description:
revoked === true
? `Prowler's access to ${workspace} has been revoked.`
: `${workspace} is no longer connected to Prowler.`,
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to disconnect Slack. Please try again.",
});
} finally {
setIsDisconnecting(false);
setIsDisconnectOpen(false);
}
};
const workspaceName = integration?.attributes.configuration.team_name;
const installUrl = mintedInstallUrl ?? authorizeUrl;
const checkedAt = integration?.attributes.connection_last_checked_at;
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
// `format` throws a RangeError on an unreadable value, which would replace
// the page with the route's error boundary: show nothing instead, as for a
// connection that was never checked.
const lastCheckedOn =
checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null;
return (
<div className="flex flex-col gap-6">
{rateLimitMessage && (
<Alert variant="warning">
<AlertTitle>Slack is busy right now</AlertTitle>
<AlertDescription>{rateLimitMessage}</AlertDescription>
</Alert>
)}
<Modal
open={isDisconnectOpen}
onOpenChange={setIsDisconnectOpen}
title="Disconnect Slack workspace"
description={`Prowler will revoke its access at Slack and stop posting to ${workspaceName ?? "this workspace"}. Connecting again means approving Prowler in Slack.`}
>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isDisconnecting}
onClick={() => integration && handleDisconnect(integration.id)}
>
{!isDisconnecting && <Unplug size={20} />}
{isDisconnecting ? "Disconnecting..." : "Disconnect workspace"}
</Button>
</div>
</Modal>
{loadError && (
<Alert variant="error">
<AlertTitle>Could not load your Slack integration</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
)}
{unconfirmedRevocation && (
<Alert variant="warning">
<AlertTitle>
Slack disconnected remove Prowler&apos;s access in Slack
</AlertTitle>
<AlertDescription>
The integration and the token Prowler had stored are gone from
Prowler, so there is nothing to retry here. Slack did not confirm
the revocation, so the Prowler app may still be installed in{" "}
{unconfirmedRevocation.workspace ?? "the workspace"} remove it
from that workspace&apos;s Slack app settings.
</AlertDescription>
</Alert>
)}
{credentialFailure && (
<Alert variant="error">
<AlertTitle>
Slack no longer accepts Prowler&apos;s access to{" "}
{workspaceName ?? "this workspace"}
</AlertTitle>
{/* Each mapped sentence already ends in the thing that fixes it. */}
<AlertDescription>
{slackErrorMessage({ code: credentialFailure })} Until then, nothing
Prowler sends will reach the workspace.
</AlertDescription>
{installUrl && (
<div className="col-start-2 mt-3">
<Button asChild size="sm">
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Reconnect to Slack
</a>
</Button>
</div>
)}
</Alert>
)}
{/* Replaces the cards, not the whole page: an early return here would
swallow the rate-limit and load-error notices above. */}
{unavailable ? (
<Alert variant="info">
<AlertTitle>
Slack is not available in this environment yet
</AlertTitle>
<AlertDescription>
The Prowler Slack app is not configured here, so no workspace can be
connected. Nothing to do on your side this page starts working as
soon as it is.
</AlertDescription>
</Alert>
) : integration && !disconnected ? (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
subtitle="Prowler posts to this workspace only."
connectionStatus={{
// A dead token outranks the state the page was loaded with.
connected:
credentialFailure === null
? integration.attributes.connected
: false,
}}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{lastCheckedOn && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{lastCheckedOn}
</p>
)}
{!defaultChannel && (
<p>
Choosing a destination channel is the next step the
connection is checked against it.
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* The check posts to the destination channel: the API answers
400 when none is recorded yet. */}
<Button
size="sm"
variant="outline"
disabled={isTesting || !defaultChannel}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(true)}
>
<Unplug size={14} />
Disconnect
</Button>
</div>
</div>
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
<SlackChannelSelector
options={channels}
value={selectedChannelId}
onChange={setSelectedChannelId}
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
error={
channelsState.status === CHANNELS_STATUS.ERROR
? channelsState.message
: null
}
incompleteNotice={
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.notice
: null
}
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
disabled={isSavingChannel}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-text-neutral-secondary text-xs">
{/* The id decides, not the name: a missing name would deny
a destination the test button posts to. */}
{defaultChannel
? defaultChannel.name
? `Prowler posts to #${defaultChannel.name}.`
: "Prowler posts to the channel you saved."
: "No destination channel recorded yet."}
</p>
<div className="flex gap-2">
<Button
size="sm"
disabled={
!selectedChannelId ||
selectedChannelId === (defaultChannel?.id ?? null) ||
isSavingChannel
}
onClick={handleSaveChannel}
>
{isSavingChannel ? "Saving..." : "Save channel"}
</Button>
{defaultChannel && (
<Button
size="sm"
variant="outline"
disabled={
testMessageState.status === TEST_MESSAGE_STATUS.SENDING
}
onClick={handleSendTestMessage}
>
<Send size={14} />
{testMessageState.status === TEST_MESSAGE_STATUS.SENDING
? "Sending..."
: "Send test message"}
</Button>
)}
</div>
</div>
{(testMessageState.status === TEST_MESSAGE_STATUS.SENT ||
testMessageState.status === TEST_MESSAGE_STATUS.FAILED) && (
<Alert
variant={
testMessageState.status === TEST_MESSAGE_STATUS.SENT
? "success"
: "error"
}
>
<AlertTitle>
{testMessageState.status === TEST_MESSAGE_STATUS.SENT
? "Test message sent"
: "Test message failed"}
</AlertTitle>
<AlertDescription>{testMessageState.detail}</AlertDescription>
</Alert>
)}
</div>
</CardContent>
</Card>
) : (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title="No workspace connected"
subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy."
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-300">
Prowler asks for permission to post messages and to read the
workspace&apos;s channel list.
</p>
{installUrl ? (
<Button asChild>
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Add to Slack
</a>
</Button>
) : (
<Button disabled>
<SlackIcon size={16} />
Add to Slack
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
};
+211
View File
@@ -0,0 +1,211 @@
/**
* Unit-tested because most of the codes in the mapping belong to flows this
* layer does not have yet: the channel picker, the test message, the disconnect.
*/
import { describe, expect, it } from "vitest";
import {
isSlackTokenErrorCode,
SLACK_ERROR_CODE,
SLACK_ERROR_MESSAGES,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_RATE_LIMITED_MESSAGE,
SLACK_REASON_TOKEN,
SLACK_TOKEN_ERROR_CODES,
readSlackFailure,
slackErrorMessage,
slackRateLimitMessage,
slackUnknownReasonMessage,
} from "./slack-errors";
describe("slackErrorMessage", () => {
it("prefers the code's own copy over the API's wording", () => {
// `detail` states the condition; the code's copy states the fix.
const failure = {
code: SLACK_ERROR_CODE.WORKSPACE_CONFLICT,
detail:
"This tenant is already connected to a different Slack workspace.",
};
expect(slackErrorMessage(failure)).toBe(
SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.WORKSPACE_CONFLICT],
);
expect(slackErrorMessage(failure)).toMatch(/Disconnect it/);
});
it("tells the user how to grant a scope Prowler is missing", () => {
// A missing scope is fixable by the reader, so the copy names the fix.
const message = slackErrorMessage({
code: SLACK_ERROR_CODE.MISSING_SCOPE,
detail: "missing_scope",
});
expect(message).toMatch(/Connect the workspace again/);
expect(message).not.toMatch(/missing_scope/);
});
it("says what to do about each channel refusal", () => {
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.CHANNEL_NOT_FOUND }),
).toMatch(/Choose another one/);
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }),
).toMatch(/Invite @Prowler/);
expect(slackErrorMessage({ code: SLACK_ERROR_CODE.NO_PERMISSION })).toMatch(
/Choose another channel/,
);
});
it("points every dead-credential code at reconnecting, not at retrying", () => {
for (const code of SLACK_TOKEN_ERROR_CODES) {
// Revoked, invalid, inactive or expired: no retry helps for any of them.
expect(slackErrorMessage({ code })).toMatch(
/Connect the workspace again to restore access/,
);
expect(isSlackTokenErrorCode(code)).toBe(true);
}
});
it("does not treat an actionable refusal as a dead credential", () => {
expect(isSlackTokenErrorCode(SLACK_ERROR_CODE.MISSING_SCOPE)).toBe(false);
expect(isSlackTokenErrorCode(null)).toBe(false);
expect(isSlackTokenErrorCode(undefined)).toBe(false);
});
it("falls back to the API's detail for a code it does not know", () => {
expect(
slackErrorMessage({
code: "some_future_slack_reason",
detail: "Slack said no.",
}),
).toBe("Slack said no.");
});
it("falls back to the generic line when there is neither", () => {
expect(slackErrorMessage({ code: null, detail: null })).toBe(
SLACK_GENERIC_ERROR_MESSAGE,
);
expect(slackErrorMessage(null)).toBe(SLACK_GENERIC_ERROR_MESSAGE);
expect(
slackErrorMessage({ detail: " " }, "Could not read channels."),
).toBe("Could not read channels.");
});
});
describe("slackUnknownReasonMessage", () => {
/** A real Slack reason this UI has no copy of its own for. */
const UNMAPPED_REASON = "is_archived";
it("keeps an unmapped reason diagnosable without letting it be the message", () => {
const message = slackUnknownReasonMessage(UNMAPPED_REASON);
expect(message).toMatch(/Slack refused the message/);
expect(message).toContain(UNMAPPED_REASON);
expect(message).not.toBe(UNMAPPED_REASON);
expect(message).toMatch(/Choose another channel/);
});
it("is only reached for a code the mapping does not cover", () => {
expect(
slackErrorMessage(
{ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL },
slackUnknownReasonMessage(SLACK_ERROR_CODE.NOT_IN_CHANNEL),
),
).toBe(SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL]);
// No `detail`: one holding the same token would make the raw token the
// whole message again.
expect(
slackErrorMessage(
{ code: UNMAPPED_REASON },
slackUnknownReasonMessage(UNMAPPED_REASON),
),
).toBe(slackUnknownReasonMessage(UNMAPPED_REASON));
});
});
describe("SLACK_REASON_TOKEN", () => {
it("recognises a reason code and refuses anything that reads as a sentence", () => {
// Slack publishes no closed set of reasons, so the guard is on shape rather
// than an allowlist.
for (const reason of [
"is_archived",
"restricted_action",
"team_access_not_granted",
"ekm_access_denied",
"messages_tab_disabled",
]) {
expect(SLACK_REASON_TOKEN.test(reason)).toBe(true);
}
for (const prose of [
"Slack rejected the message: the channel is archived.",
"). Contact support at +1-555-0100 (",
"",
"a".repeat(49),
]) {
expect(SLACK_REASON_TOKEN.test(prose)).toBe(false);
}
});
});
describe("slackRateLimitMessage", () => {
it("names the wait Slack asked for", () => {
expect(slackRateLimitMessage(30)).toMatch(/about 30 seconds/);
expect(slackRateLimitMessage(1)).toMatch(/about 1 second\b/);
expect(slackRateLimitMessage(90)).toMatch(/about 2 minutes/);
});
it("still says to come back when Slack named no wait", () => {
expect(slackRateLimitMessage(null)).toBe(SLACK_RATE_LIMITED_MESSAGE);
expect(slackRateLimitMessage(0)).toBe(SLACK_RATE_LIMITED_MESSAGE);
});
});
describe("readSlackFailure", () => {
it("reads the code, the detail and the wait off a JSON:API refusal", async () => {
const response = new Response(
JSON.stringify({
errors: [
{
status: "429",
detail: "Slack is rate limiting requests from Prowler.",
source: { pointer: "/data" },
},
],
}),
{ status: 429, headers: { "Retry-After": "30" } },
);
const failure = await readSlackFailure(response);
expect(failure).toEqual({
status: 429,
code: null,
detail: "Slack is rate limiting requests from Prowler.",
retryAfterSeconds: 30,
});
});
it("survives a body that is not JSON:API at all", async () => {
const failure = await readSlackFailure(
new Response("<html>Bad gateway</html>", { status: 502 }),
);
expect(failure).toEqual({
status: 502,
code: null,
detail: null,
retryAfterSeconds: null,
});
});
it("ignores a Retry-After it cannot use", async () => {
const failure = await readSlackFailure(
new Response("{}", { status: 429, headers: { "Retry-After": "soon" } }),
);
expect(failure.retryAfterSeconds).toBeNull();
});
});
+155
View File
@@ -0,0 +1,155 @@
export const SLACK_ERROR_CODE = {
MISSING_SCOPE: "missing_scope",
CHANNEL_NOT_FOUND: "channel_not_found",
NOT_IN_CHANNEL: "not_in_channel",
NO_PERMISSION: "no_permission",
TOKEN_REVOKED: "token_revoked",
INVALID_AUTH: "invalid_auth",
ACCOUNT_INACTIVE: "account_inactive",
TOKEN_EXPIRED: "token_expired",
/** One workspace per tenant. */
WORKSPACE_CONFLICT: "slack_workspace_conflict",
} as const;
export type SlackErrorCode =
(typeof SLACK_ERROR_CODE)[keyof typeof SLACK_ERROR_CODE];
/**
* The grant itself is dead: reconnecting is the only way out, not retrying. The
* API answers these with `400`, not `401`, so they are not mistaken for an
* expired Prowler session.
*/
export const SLACK_TOKEN_ERROR_CODES = [
SLACK_ERROR_CODE.TOKEN_REVOKED,
SLACK_ERROR_CODE.INVALID_AUTH,
SLACK_ERROR_CODE.ACCOUNT_INACTIVE,
SLACK_ERROR_CODE.TOKEN_EXPIRED,
] as const;
export type SlackTokenErrorCode = (typeof SLACK_TOKEN_ERROR_CODES)[number];
export const isSlackTokenErrorCode = (
code: string | null | undefined,
): code is SlackTokenErrorCode =>
SLACK_TOKEN_ERROR_CODES.includes(code as SlackTokenErrorCode);
export const SLACK_GENERIC_ERROR_MESSAGE =
"Slack could not complete that request. Try again in a moment.";
export const SLACK_RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in a few moments.";
/**
* For a channel list that stopped short of the workspace: the page budget ran
* out, or `links.next` left the API's origin.
*/
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler has to be invited to.";
/**
* For a `2xx` the UI could not read. Not phrased as a failure: the install
* happened, only the workspace cannot be named.
*/
export const SLACK_UNREADABLE_RESULT_MESSAGE =
"Prowler could not read the result of the install. Open the Slack integration page to see the workspace — if none is listed there, start the install again.";
/**
* The shape of a Slack reason code, as opposed to a sentence: the set is
* open-ended, so a reason is gated on its shape before being interpolated.
*/
export const SLACK_REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
/**
* Copy for a reason code Prowler has no wording of its own for — the ordinary
* case, since the set is open-ended.
*/
export const slackUnknownReasonMessage = (reason: string): string =>
`Slack refused the message (${reason}). Choose another channel, or check the channel in Slack.`;
const RECONNECT = "Connect the workspace again to restore access.";
export const SLACK_ERROR_MESSAGES = {
[SLACK_ERROR_CODE.MISSING_SCOPE]:
"Prowler is missing a permission it needs in Slack. Connect the workspace again and approve the access Prowler asks for.",
[SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]:
"That channel no longer exists in the workspace. Choose another one.",
[SLACK_ERROR_CODE.NOT_IN_CHANNEL]:
"Prowler is not in that channel. Invite @Prowler to it in Slack, or choose a channel it can already post to.",
[SLACK_ERROR_CODE.NO_PERMISSION]:
"Slack did not allow Prowler to post there. Choose another channel, or ask a workspace admin to allow it.",
[SLACK_ERROR_CODE.TOKEN_REVOKED]: `Prowler's access to Slack was revoked. ${RECONNECT}`,
[SLACK_ERROR_CODE.INVALID_AUTH]: `Slack no longer accepts Prowler's credential. ${RECONNECT}`,
[SLACK_ERROR_CODE.ACCOUNT_INACTIVE]: `The Slack account Prowler was installed with is no longer active. ${RECONNECT}`,
[SLACK_ERROR_CODE.TOKEN_EXPIRED]: `Prowler's Slack credential has expired. ${RECONNECT}`,
[SLACK_ERROR_CODE.WORKSPACE_CONFLICT]:
"Prowler is already connected to a different Slack workspace. Disconnect it before connecting another one.",
} as const satisfies Record<SlackErrorCode, string>;
/** The parts of a JSON:API error this mapping reads. */
export interface SlackErrorSource {
code?: string | null;
detail?: string | null;
}
export interface SlackApiFailure extends SlackErrorSource {
status: number;
retryAfterSeconds: number | null;
}
const isKnownCode = (code: string | null | undefined): code is SlackErrorCode =>
typeof code === "string" &&
Object.prototype.hasOwnProperty.call(SLACK_ERROR_MESSAGES, code);
/**
* Copy for a refusal: Prowler's wording for a known `code`, else the API's
* `detail`, else `fallback`.
*/
export const slackErrorMessage = (
error: SlackErrorSource | null | undefined,
fallback: string = SLACK_GENERIC_ERROR_MESSAGE,
): string => {
if (isKnownCode(error?.code)) return SLACK_ERROR_MESSAGES[error.code];
return error?.detail?.trim() || fallback;
};
const describeWait = (seconds: number): string => {
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
const minutes = Math.ceil(seconds / 60);
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
};
export const slackRateLimitMessage = (
retryAfterSeconds: number | null,
): string => {
if (retryAfterSeconds === null || retryAfterSeconds <= 0) {
return SLACK_RATE_LIMITED_MESSAGE;
}
return `Slack is rate limiting Prowler right now. Try again in about ${describeWait(
Math.ceil(retryAfterSeconds),
)}.`;
};
const retryAfterFrom = (response: Response): number | null => {
const header = response.headers.get("retry-after");
if (!header) return null;
const seconds = Number(header.trim());
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
};
/**
* Read a non-OK Slack response into the failure it describes. Never throws: a
* body that is not JSON:API still yields a failure carrying the status.
*/
export const readSlackFailure = async (
response: Response,
): Promise<SlackApiFailure> => {
const body = await response.json().catch(() => null);
const error = Array.isArray(body?.errors) ? body.errors[0] : null;
return {
status: response.status,
code: typeof error?.code === "string" ? error.code : null,
detail: typeof error?.detail === "string" ? error.detail : null,
retryAfterSeconds: retryAfterFrom(response),
};
};
+30 -2
View File
@@ -2,7 +2,15 @@ import { z } from "zod";
import type { TaskState } from "@/types/tasks";
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
export const INTEGRATION_TYPE = {
AMAZON_S3: "amazon_s3",
AWS_SECURITY_HUB: "aws_security_hub",
JIRA: "jira",
SLACK: "slack",
} as const;
export type IntegrationType =
(typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE];
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",
@@ -68,7 +76,10 @@ export interface IntegrationProps {
inserted_at: string;
updated_at: string;
enabled: boolean;
connected: boolean;
// `null` until a connection check has run: never verified, neither working
// nor broken. A Slack install starts here, and returns here on a channel
// change.
connected: boolean | null;
connection_last_checked_at: string | null;
integration_type: IntegrationType;
configuration: {
@@ -87,6 +98,13 @@ export interface IntegrationProps {
domain?: string;
projects?: { [key: string]: string };
issue_types?: { [key: string]: string[] };
// Slack specific configuration, server-owned. The channel keys are absent
// until one is chosen, not present and null: read them with `?? null`.
team_id?: string;
team_name?: string;
bot_user_id?: string;
channel_id?: string;
channel_name?: string;
[key: string]: unknown;
};
url?: string;
@@ -95,6 +113,16 @@ export interface IntegrationProps {
links: { self: string };
}
/**
* A channel Prowler can post to: every active public channel, plus the private
* ones `@Prowler` was invited to. `is_private` keeps the API's own naming.
*/
export interface SlackChannelOption {
id: string;
name: string;
is_private: boolean;
}
// Jira dispatch types
export interface JiraDispatchRequest {
data: {
+30 -8
View File
@@ -1,9 +1,34 @@
import react from "@vitejs/plugin-react";
import react, { type BabelOptions } from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import fs from "fs";
import path from "path";
import type { TestProjectConfiguration } from "vitest/config";
import { defineConfig } from "vitest/config";
/**
* Next runs the React Compiler on the client compilation only — its
* `getReactCompilerPlugins` returns nothing when `isServer` — so a Server
* Component ships uncompiled. Mirror that: compiled, it calls `useMemoCache`
* on the active dispatcher, which a harness invoking the component as a
* function has none of, and `react/compiler-runtime` reads the client
* internals the `react-server` build does not export anyway.
*/
const isServerModule = (id: string): boolean => {
const file = id.split("?")[0];
if (!file.includes("/app/")) return false;
try {
return !/^\s*["']use client["']/.test(fs.readFileSync(file, "utf8"));
} catch {
return false;
}
};
const reactCompilerBabel = (id: string): BabelOptions => ({
plugins: isServerModule(id)
? []
: [["babel-plugin-react-compiler", { target: "19" }]],
});
export default defineConfig(() => {
const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1";
@@ -57,13 +82,7 @@ export default defineConfig(() => {
},
{
extends: true,
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
},
}),
],
plugins: [react({ babel: reactCompilerBabel })],
test: {
name: "integration",
setupFiles: ["./vitest.integration.setup.ts"],
@@ -109,6 +128,9 @@ export default defineConfig(() => {
// React runtime (pre-bundle so a cold run doesn't re-optimize and
// reload mid-test — see the on-demand-reload note above).
"react-dom/client",
// What the compiler's output imports. `@vitejs/plugin-react` adds it
// itself only when `babel` is a plain object, and ours is a function.
"react/compiler-runtime",
// Next runtime
"next/headers",
+238
View File
@@ -0,0 +1,238 @@
"""Fail when a pinned or locked package version has been yanked from PyPI.
Exact pins (`==`) still install a yanked release: pip and uv both accept a yanked
version when it is the only candidate an exact specifier allows, printing at most a
warning. That is how zstd 1.5.7.3 (yanked as "buggy - not thread safe") stayed in
uv.lock for months. Yanks happen on PyPI's side after the pin lands, so this check
must run on a schedule, not only on pull requests.
For each project directory given (default: current directory) the script collects:
- exact `==` pins from pyproject.toml: [project] dependencies and optional
dependencies, [dependency-groups], and [tool.uv] constraint-dependencies and
override-dependencies
- every registry-sourced package in uv.lock
and asks the PyPI JSON API whether each (name, version) is yanked or gone.
Usage:
python util/check_yanked_pins.py # checks ./pyproject.toml and ./uv.lock
python util/check_yanked_pins.py . api mcp_server
Exit status is 1 when any pin is yanked or no longer exists on PyPI, 0 otherwise.
Network errors are retried; a persistent error also exits 1, because "unknown"
must not read as "clean".
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from time import sleep
from typing import Callable, Iterable
try:
import tomllib
except ModuleNotFoundError: # Python 3.10: tomllib arrived in 3.11
import tomli as tomllib
PYPI_JSON = "https://pypi.org/pypi/{name}/{version}/json"
USER_AGENT = "prowler-check-yanked-pins (+https://github.com/prowler-cloud/prowler)"
# PEP 508 requirement with an exact pin: "name[extras]==version ; markers"
_EXACT_PIN = re.compile(
r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*==\s*(?P<version>[^\s;,]+)"
)
def normalize(name: str) -> str:
"""PEP 503 name normalization: alibabacloud_tea_openapi == alibabacloud-tea-openapi."""
return re.sub(r"[-_.]+", "-", name).lower()
@dataclass(frozen=True, order=True)
class Pin:
"""One exact version requirement and the file/table it was read from."""
name: str
version: str
source: str
@dataclass(frozen=True)
class Verdict:
"""PyPI's answer for one pin: ok, yanked, missing (404) or error (unreachable)."""
pin: Pin
status: str # "ok" | "yanked" | "missing" | "error"
detail: str = ""
def pins_from_pyproject(text: str, source_prefix: str) -> set[Pin]:
"""Collect exact == pins from every dependency-bearing table in a pyproject.toml."""
data = tomllib.loads(text)
tables: list[tuple[str, Iterable[str]]] = []
project = data.get("project", {})
tables.append(("project.dependencies", project.get("dependencies", [])))
for extra, reqs in project.get("optional-dependencies", {}).items():
tables.append((f"project.optional-dependencies.{extra}", reqs))
for group, reqs in data.get("dependency-groups", {}).items():
# dependency-groups entries may be tables ({include-group = ...}); keep strings only
tables.append(
(f"dependency-groups.{group}", [r for r in reqs if isinstance(r, str)])
)
uv = data.get("tool", {}).get("uv", {})
tables.append(
("tool.uv.constraint-dependencies", uv.get("constraint-dependencies", []))
)
tables.append(
("tool.uv.override-dependencies", uv.get("override-dependencies", []))
)
pins: set[Pin] = set()
for table, requirements in tables:
for requirement in requirements:
match = _EXACT_PIN.match(requirement)
if match:
pins.add(
Pin(
normalize(match.group("name")),
match.group("version"),
f"{source_prefix}pyproject.toml [{table}]",
)
)
return pins
def pins_from_uv_lock(text: str, source_prefix: str) -> set[Pin]:
"""Collect every registry-sourced (name, version) from a uv.lock."""
data = tomllib.loads(text)
pins: set[Pin] = set()
for package in data.get("package", []):
source = package.get("source", {})
# git, path, editable and virtual sources are not on PyPI; skip them
if "registry" not in source:
continue
pins.add(
Pin(
normalize(package["name"]),
package["version"],
f"{source_prefix}uv.lock",
)
)
return pins
def collect_pins(project_dir: Path) -> set[Pin]:
"""Gather pins from a project's pyproject.toml and uv.lock, whichever exist."""
prefix = "" if project_dir == Path(".") else f"{project_dir.as_posix()}/"
pins: set[Pin] = set()
pyproject = project_dir / "pyproject.toml"
lock = project_dir / "uv.lock"
if not pyproject.is_file() and not lock.is_file():
raise FileNotFoundError(
f"{project_dir}: neither pyproject.toml nor uv.lock found"
)
if pyproject.is_file():
pins |= pins_from_pyproject(pyproject.read_text(encoding="utf-8"), prefix)
if lock.is_file():
pins |= pins_from_uv_lock(lock.read_text(encoding="utf-8"), prefix)
return pins
def fetch_release(name: str, version: str, retries: int = 3) -> tuple[str, str]:
"""Return (status, detail) for one release, where status is ok|yanked|missing|error."""
request = urllib.request.Request(
PYPI_JSON.format(name=name, version=version), headers={"User-Agent": USER_AGENT}
)
last_error = ""
for attempt in range(retries):
try:
with urllib.request.urlopen(request, timeout=20) as response:
info = json.load(response)["info"]
except urllib.error.HTTPError as exc:
if exc.code == 404:
return "missing", "not found on PyPI"
last_error = f"HTTP {exc.code}"
except (
urllib.error.URLError,
TimeoutError,
OSError,
ValueError,
KeyError,
) as exc:
last_error = repr(exc)
else:
if info.get("yanked"):
return "yanked", info.get("yanked_reason") or "no reason given"
return "ok", ""
sleep(2**attempt)
return "error", last_error
def evaluate(
pins: Iterable[Pin],
fetch: Callable[[str, str], tuple[str, str]] | None = None,
workers: int = 16,
) -> list[Verdict]:
"""Query each distinct (name, version) once and fan the answer out to every source."""
if fetch is None:
fetch = fetch_release
pins = sorted(set(pins))
releases = sorted({(pin.name, pin.version) for pin in pins})
with ThreadPoolExecutor(max_workers=workers) as pool:
results = dict(
zip(
releases,
pool.map(lambda release: fetch(*release), releases),
strict=True,
)
)
return [Verdict(pin, *results[(pin.name, pin.version)]) for pin in pins]
def main(argv: list[str] | None = None) -> int:
"""Check every project on the command line; return 1 if any pin is not ok."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"projects",
nargs="*",
default=["."],
help="project directories containing pyproject.toml and/or uv.lock (default: .)",
)
parser.add_argument(
"--workers", type=int, default=16, help="concurrent PyPI requests"
)
args = parser.parse_args(argv)
pins: set[Pin] = set()
for project in args.projects:
pins |= collect_pins(Path(project))
print(
f"Checking {len({(p.name, p.version) for p in pins})} pinned releases from {len(pins)} pins"
)
verdicts = evaluate(pins, workers=args.workers)
problems = [v for v in verdicts if v.status != "ok"]
for verdict in problems:
pin = verdict.pin
print(
f"::error::{pin.name}=={pin.version} is {verdict.status} ({verdict.detail}) in {pin.source}"
)
if problems:
print(f"{len(problems)} problem(s) found")
return 1
print("No yanked or missing releases")
return 0
if __name__ == "__main__":
sys.exit(main())
Generated
+62 -87
View File
@@ -39,7 +39,7 @@ constraints = [
{ name = "alibabacloud-sas20181203", specifier = "==6.1.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" },
@@ -140,7 +140,7 @@ constraints = [
{ name = "mock", specifier = "==5.2.0" },
{ name = "moto", specifier = "==5.1.11" },
{ name = "mpmath", specifier = "==1.3.0" },
{ name = "msal", specifier = "==1.36.0" },
{ name = "msal", specifier = "==1.37.0" },
{ name = "msal-extensions", specifier = "==1.3.1" },
{ name = "msgraph-core", specifier = "==1.3.8" },
{ name = "msrest", specifier = "==0.7.1" },
@@ -183,7 +183,7 @@ constraints = [
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pylint", specifier = "==3.3.4" },
{ 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 = "pytest", specifier = "==9.0.3" },
{ name = "pytest-cov", specifier = "==6.0.0" },
@@ -227,12 +227,9 @@ constraints = [
{ name = "xmltodict", specifier = "==1.0.4" },
{ name = "yarl", specifier = "==1.23.0" },
{ name = "zipp", specifier = "==3.23.1" },
{ name = "zstd", specifier = "==1.5.7.3" },
]
overrides = [
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "zstd", specifier = "==1.5.7.2" },
]
overrides = [{ name = "okta", specifier = "==3.4.2" }]
[[package]]
name = "about-time"
@@ -686,7 +683,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" },
@@ -695,9 +692,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]]
@@ -3045,16 +3042,16 @@ wheels = [
[[package]]
name = "msal"
version = "1.36.0"
version = "1.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" },
{ url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" },
]
[[package]]
@@ -3337,7 +3334,7 @@ wheels = [
[[package]]
name = "oci"
version = "2.183.0"
version = "2.184.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -3350,9 +3347,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]]
@@ -3889,7 +3886,7 @@ requires-dist = [
{ name = "alibabacloud-sas20181203", specifier = "==6.1.0" },
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
{ name = "alive-progress", specifier = "==3.3.0" },
{ name = "azure-identity", specifier = "==1.21.0" },
@@ -3950,7 +3947,7 @@ requires-dist = [
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
{ name = "msgraph-sdk", specifier = "==1.55.0" },
{ name = "numpy", specifier = "==2.2.6" },
{ name = "oci", specifier = "==2.183.0" },
{ name = "oci", specifier = "==2.184.1" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "openstacksdk", specifier = "==4.2.0" },
{ name = "pandas", specifier = "==2.2.3" },
@@ -4399,15 +4396,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", marker = "python_full_version < '3.13'" },
]
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]]
@@ -5520,71 +5517,49 @@ 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/8b/54/95fe3f714a4a0c2befc1f5734deb2706c635481feff4e5497ace0f307fef/zstd-1.5.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76f3535616887a1a38e8c6d0de693a23c5bb1f190651eb20d96bfc8e4ab706a0", size = 267642, upload-time = "2026-01-08T16:46:57.829Z" },
{ url = "https://files.pythonhosted.org/packages/0f/af/e88d733bf7dac8bcb0f90e90f9ea2163909e873e37aaf90617e7e5ed34d8/zstd-1.5.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67507937e8e4c2a8dfed8e7fa77f4043ec9e6e831a5faebf0f99138b1a25ccbd", size = 230964, upload-time = "2026-01-08T16:46:59.277Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7a/9e8b541b5799bb699e70d6f0c4fa5a0607c9229634209e9662f4a6a8a6ce/zstd-1.5.7.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd0a2309c524608ce7b940abcc9f8eb5447c6ea2c834a630e0081211ab9d40ec", size = 1540287, upload-time = "2026-01-08T17:39:27.789Z" },
{ url = "https://files.pythonhosted.org/packages/4b/1e/d0fe5f8e860c39f50831889c499fbf91a5bfdb8adcd148d35f1f7a3e7ea7/zstd-1.5.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2b497306580d544406b5414c8485c4037a9283ad2ca6ae4ccdf3732c9563141d", size = 1619041, upload-time = "2026-01-08T17:39:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/10/bd/b9b381edad8cfdca944cd15025932c6b0edacc5164ed67b603b4d8a38f84/zstd-1.5.7.3-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:e9939a98ea946d1f9e8f9fecc940ae939b8e9e5ef9d71b104f7843567d764f30", size = 300166, upload-time = "2026-01-10T11:12:23.088Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a8/9b6f65a3bd7fb54148bceadf9a5a9a869b64454c9e15b6f1362160594785/zstd-1.5.7.3-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:d32c0fe8f6b805b7cbeaade462b094a843e84d893d8c6f66ab705e8777cc1850", size = 304165, upload-time = "2026-01-10T11:22:59.825Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/2fb52f1d288bb5e8176108a4bdbc25484fc05cc902cdf5c99cf604aba979/zstd-1.5.7.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8aa33b1ef24602b2ef1e8aa67ea3c8f821854a4dbf70c3c8c46b96b54b6ceb5d", size = 1525903, upload-time = "2026-01-08T17:39:26.4Z" },
{ url = "https://files.pythonhosted.org/packages/b4/be/26451e696c2cc5f604eb872408a4e0ddc64478e45928897c755b2ab0330c/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1bd69fa9c4c97fd04206c919dedbf9f75f544ebb77880db51a13c1e3802cd655", size = 2095723, upload-time = "2026-01-08T17:39:30.473Z" },
{ url = "https://files.pythonhosted.org/packages/01/1a/43ee13d01e367eb5bb2dead554e2fb3931e4f2d4a45a7642601e44b138b1/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aee96742a64ede2e35dc0316ef0cd1e50089e889ce77e82ca8edf40174a1439c", size = 2132397, upload-time = "2026-01-08T17:39:24.739Z" },
{ url = "https://files.pythonhosted.org/packages/00/d7/5497d54dadb172ee148820aff1551cb189344522c207bc83f073f8a85a59/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ac207573d2815a51f4f4fd4e255408396491729a01f690b9f5fb672d39e5610", size = 2124660, upload-time = "2026-01-08T17:39:29.146Z" },
{ url = "https://files.pythonhosted.org/packages/98/89/665c5fac2da24c129ef403f65c2d8e9b08142b97b02ab7daa3a44cddeca7/zstd-1.5.7.3-cp310-cp310-win32.whl", hash = "sha256:04e62e4f9eba79699d072d3c96731ed4aff99f1d334eb967489b091186a6078f", size = 150362, upload-time = "2026-01-08T17:09:29.33Z" },
{ url = "https://files.pythonhosted.org/packages/53/2e/cae4878efd693ddfb712577eee4ac37dd4c0fe757054c4ee3479530b416f/zstd-1.5.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:0794b23b9950af240888087d2bd5943aa4be67273ba32cdafabdc5704778b90e", size = 167580, upload-time = "2026-01-08T17:09:30.639Z" },
{ url = "https://files.pythonhosted.org/packages/93/f9/9908234f86aafb48edd5fe630b41488c3acd8a4edbfbc921a7a6db1ab8b4/zstd-1.5.7.3-cp310-cp310-win_arm64.whl", hash = "sha256:7827fd4901f3e71a7a755d26719549658f08e04fdf0870a952ed08e71b484435", size = 157239, upload-time = "2026-01-08T16:43:01.449Z" },
{ 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/21/7c/f2fe6e09b9d064873ebd384f2692b9fcad3d8e9412298dfb09a935aec77d/zstd-1.5.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2455e56f1d265dacbd450510b8c2f632a5d8d92c23282e7723fb04af37001a2", size = 268133, upload-time = "2026-01-08T17:31:54.616Z" },
{ url = "https://files.pythonhosted.org/packages/14/8d/0a1e49844ed82c7ab0f66dce5e0dd822742fc7e9d04f147032db33740840/zstd-1.5.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3486dc4f1b4e52bb059f8eec1f31daa3e540062c0f522f221782cf132a8bc9a8", size = 231005, upload-time = "2026-01-08T17:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/3b/24/0ab682096da2411f83236a10c89423f26859a65431660e460e2d637b5628/zstd-1.5.7.3-cp313-cp313-manylinux_2_14_i686.whl", hash = "sha256:1cb47bf10ffcb6a782edacfe758da2c94879f7e89c6628feb3f1254daf8cc596", size = 300230, upload-time = "2026-01-08T16:30:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/36/2e/3ff0d28ea8d6b9bd931af7477fad082b99633cb7901cdd657dcb7ecfee11/zstd-1.5.7.3-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:07b1378d1230ddeea8773f99d7518a3060e6468c76edd502057cb795fe278d7e", size = 297097, upload-time = "2026-01-08T16:49:41.211Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ef/25c15570fb6b06a4a03bd054afa2d084df687ac10e336b703139acc77182/zstd-1.5.7.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ee34317f013e3405108f5baea53502159809cfc4510598d614257525500c70d", size = 2162274, upload-time = "2026-01-08T17:35:24.464Z" },
{ url = "https://files.pythonhosted.org/packages/f1/24/fd16ba9e9be877a2194f05462ae77dcb62c8f90c4ecc186d9ee71e9bdc9f/zstd-1.5.7.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c19127ca2c79855376a34a2d7a6969408094b25c1f44485b0373eba4be851b98", size = 2201877, upload-time = "2026-01-08T17:35:28.596Z" },
{ url = "https://files.pythonhosted.org/packages/85/ef/73c37a81ac36429bb1bbb69c8ac43f3a154cfab739dce6424d28f95301c3/zstd-1.5.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e79cae70dd08cb247391312463085c624c0302e8c860d13f87f4c76502d8202", size = 2096535, upload-time = "2026-01-08T17:35:30.001Z" },
{ url = "https://files.pythonhosted.org/packages/d3/1f/859a8049634e444feb6855347d5e558c1280d87b0bc6385f13cd3d95dbe6/zstd-1.5.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e83e91e5daf89037c737f5529da0f80da80a78a6ad0b1d70a09860eb267dea4", size = 2125473, upload-time = "2026-01-08T17:35:26.596Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5c/e7b8aa8eea46f032891ecef187f1d469e20f9ddf0d10607e9523b3d306a1/zstd-1.5.7.3-cp313-cp313-win32.whl", hash = "sha256:2283f3bb910c028e1b9fe76b834016012ab021025a0ea197e27a1333f85e3031", size = 150370, upload-time = "2026-01-08T17:18:12.899Z" },
{ url = "https://files.pythonhosted.org/packages/50/2b/ba558ff87ba7f6c29e3a9b1c3b3e95338146aee7250b10807229d412a9c4/zstd-1.5.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ad5fe4c36bab5dfa5a4b8d050bd07c50c1e69f94d381bc65337ab14cd69e5b1", size = 167602, upload-time = "2026-01-08T17:18:13.858Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7d/4a5c9813fafad2949d42deee3857d7ecc8caf369bbf82a88b60519200083/zstd-1.5.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e878172b0eb69ac2edc6576eb862e00747c7c25e638fb354630a1ea7cfddf49", size = 157239, upload-time = "2026-01-08T16:42:28.885Z" },
{ url = "https://files.pythonhosted.org/packages/0c/67/5fcec6bbf8aab4aeb26e3cbe8cc9fa2f323dd143d066e313b134b8c28dc8/zstd-1.5.7.3-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:7e0a7e94d5b63b4cacf2396079ca9584d11f49f87cb4e5aa21f126a8f6b83446", size = 297302, upload-time = "2026-01-08T16:36:49.027Z" },
{ url = "https://files.pythonhosted.org/packages/64/ad/f6588943c9fde34a28f1b0448a8ac824b2ebe341f7af56ff035d0489338d/zstd-1.5.7.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b9ec4d5ba8c170d3fdf21ae5da3c15eaea2beef9c419a5f3274a6f9e03c412a", size = 260091, upload-time = "2026-01-08T17:14:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/7a/6a/6d2d3b9b7bad0124c684b7b77621ee6bdc3fc220a580f002014cf0a8f558/zstd-1.5.7.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7ab69fc4d90eeb64b98a567751f8e48373f4bcf301597fca344b8e8342e1d5e", size = 221149, upload-time = "2026-01-08T17:14:17.168Z" },
{ url = "https://files.pythonhosted.org/packages/90/8d/ad4d3c24293c70d8ae9c80e06b2da2922048933f9a00f35d18df5166346a/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da70f0918bf739bc75d7770410c9b94ea0dcb6f02d7ef70598b464bd5fcb193a", size = 326792, upload-time = "2026-01-08T17:12:35.38Z" },
{ url = "https://files.pythonhosted.org/packages/ed/37/98c3dd075935b5a7a1806837db343c1a37e6726c53db93c27aa4d7e5e86c/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dd5c069d0409284f1963b0b6b119f21b1da9e22a503e88933eb0696249d87d3", size = 322283, upload-time = "2026-01-08T17:12:32.042Z" },
{ url = "https://files.pythonhosted.org/packages/97/96/20fd30bd330529b4ad8420f4ba9030b80b971499be75d37c39306cdeb038/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46ca4a075f36f118e2ce07ba07d9ece7aeda193cea6f50b82aaee635df7b5fc2", size = 311551, upload-time = "2026-01-08T17:12:33.683Z" },
{ url = "https://files.pythonhosted.org/packages/51/b5/001cc10b6a221e4e5fb4ec19ff49d6dcc028f97a5ce53a8ef5cee67ac409/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:4a521cb7615fc61bfe9514bea182e224894b5987fc7843b6d6da20a61206ef24", size = 317071, upload-time = "2026-01-08T16:30:35.427Z" },
{ url = "https://files.pythonhosted.org/packages/41/81/75bdf0e4515c74094adff7e5119f4d50bc9af20359b78c04f8f6cac3f59c/zstd-1.5.7.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71ea22c953a164f34eb4b8c2c3b97eaa22da6a75296ea80b3ba4473187f15046", size = 167655, upload-time = "2026-01-08T16:55:06.572Z" },
{ 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/18/76/825a002361bcfb4444d8ff0bd5c75d60e449158c5a9cd3b884971b3ecd1e/zstd-1.5.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3f14c5c405ea353b68fe105236780494eb67c756ecd346fd295498f5eab6d24", size = 269695, upload-time = "2025-06-23T12:54:29.916Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0a/a8c936edc431217186085276a37eba8e52c9bd4cd3025b38403baa2466a4/zstd-1.5.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07d2061df22a3efc06453089e6e8b96e58f5bb7a0c4074dcfd0b0ce243ddde72", size = 228243, upload-time = "2025-06-23T12:54:30.942Z" },
{ url = "https://files.pythonhosted.org/packages/ae/24/e81d1561ab3e32be2370de82e13d3c50b68a9fed6977b4d7d596d3ddd1b9/zstd-1.5.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:27e55aa2043ba7d8a08aba0978c652d4d5857338a8188aa84522569f3586c7bb", size = 1536535, upload-time = "2025-06-23T13:53:22.123Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/60d956dc3f457620997906bc4c220fad12b2ad1a3a5e2224d3b5dbf0a28e/zstd-1.5.7.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e97933addfd71ea9608306f18dc18e7d2a5e64212ba2bb9a4ccb6d714f9f280", size = 1616160, upload-time = "2025-06-23T13:53:16.221Z" },
{ url = "https://files.pythonhosted.org/packages/71/6a/49fc94a39f44994c5db20259d44a849e558af5232072580a7614cdb2058d/zstd-1.5.7.2-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:27e2ed58b64001c9ef0a8e028625477f1a6ed4ca949412ff6548544945cc59c2", size = 322186, upload-time = "2025-06-23T12:41:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/17/20/e1e06a7f39c7eb27a1fe1c0281970c840fcde539a2f8ad99bb3155dbf3ad/zstd-1.5.7.2-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:92f072819fc0c7e8445f51a232c9ad76642027c069d2f36470cdb5e663839cdb", size = 302736, upload-time = "2025-06-23T13:05:04.168Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c0/86bb2d8e556062edf663f8d08c315418fefb80cae7c786cf39957e10455f/zstd-1.5.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2a653cdd2c52d60c28e519d44bde8d759f2c1837f0ff8e8e1b0045ca62fcf70e", size = 1522689, upload-time = "2025-06-23T13:53:17.761Z" },
{ url = "https://files.pythonhosted.org/packages/80/24/60a125d82d64b4d2a823f490904d8b5861117771237e34bb02e2cc311572/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:047803d87d910f4905f48d99aeff1e0539ec2e4f4bf17d077701b5d0b2392a95", size = 2098532, upload-time = "2025-06-23T13:53:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/09/eb/3274ea05a788bbdb90e3de90bfa27dc9113ee0114011d28bfad6d9fd34d7/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0d8c1dc947e5ccea3bd81043080213685faf1d43886c27c51851fabf325f05c0", size = 2112079, upload-time = "2025-06-23T13:53:19.833Z" },
{ url = "https://files.pythonhosted.org/packages/93/c6/fa6898d55f8313e9649e2853ea3fede8b7301a5a1c40d8aa920252c31a52/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8291d393321fac30604c6bbf40067103fee315aa476647a5eaecf877ee53496f", size = 2109450, upload-time = "2025-06-23T13:53:13.806Z" },
{ url = "https://files.pythonhosted.org/packages/ad/56/4180cd24fdc468f4f0beae3d4f5e8690a16995a561b1926dfdde223ecc3d/zstd-1.5.7.2-cp310-cp310-win32.whl", hash = "sha256:6922ceac5f2d60bb57a7875168c8aa442477b83e8951f2206cf1e9be788b0a6e", size = 149448, upload-time = "2025-06-23T13:09:43.678Z" },
{ url = "https://files.pythonhosted.org/packages/d5/55/3b315dc894b9726c16e5d58f48a618e6e2670e93c0eacc03fd30330444ee/zstd-1.5.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:346d1e4774d89a77d67fc70d53964bfca57c0abecfd885a4e00f87fd7c71e074", size = 166591, upload-time = "2025-06-23T13:09:44.85Z" },
{ 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/a1/63/0d392a8ec2231dee9fc2290faea7a6642584686720d6b77899ad8b12e35a/zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06", size = 269438, upload-time = "2025-06-23T12:57:52.507Z" },
{ url = "https://files.pythonhosted.org/packages/be/1f/85aae095f92811bed3d2944bbed971fe07ec1dd2d82c9eb1395d69d2123c/zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb", size = 228179, upload-time = "2025-06-23T12:57:51.031Z" },
{ url = "https://files.pythonhosted.org/packages/31/4e/547949993ea347ac44f5908262ebe6e85edfa7b11a5df136319789be731d/zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71", size = 302763, upload-time = "2025-06-23T12:51:51.615Z" },
{ url = "https://files.pythonhosted.org/packages/25/ca/4a6882846e3049be249031f825251a9229ecad471e18e7fd27974540549c/zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174", size = 149452, upload-time = "2025-06-23T12:57:32.116Z" },
{ url = "https://files.pythonhosted.org/packages/e7/aa/89339605864c9803e4738f176932a6c9f1ad99d03c03ef2cb0634ddca680/zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a", size = 166625, upload-time = "2025-06-23T12:57:33.334Z" },
{ url = "https://files.pythonhosted.org/packages/07/e9/501291a2f9b300b2c73dcc6d086df778e895e71573df9575def54d9dbab2/zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6", size = 302906, upload-time = "2025-06-23T13:21:13.331Z" },
{ url = "https://files.pythonhosted.org/packages/56/b9/179ad7330e6ea33ce655b671ee6f961fbbf4714996aa7c5180ef08d1616a/zstd-1.5.7.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:426e5c6b7b3e2401b734bfd08050b071e17c15df5e3b31e63651d1fd9ba4c751", size = 262933, upload-time = "2025-06-23T13:03:44.34Z" },
{ url = "https://files.pythonhosted.org/packages/56/d9/f9b73abd3ccce44468ccbdc1ad48b8adb6eaffeacc556472a6e42331b2c3/zstd-1.5.7.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:53375b23f2f39359ade944169bbd88f8895eed91290ee608ccbc28810ac360ba", size = 218516, upload-time = "2025-06-23T13:19:05.55Z" },
{ url = "https://files.pythonhosted.org/packages/ca/3b/f6f6c4d009b5945bbe043e576a61a8adc71eba5e9adc7b1872c080508b26/zstd-1.5.7.2-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:1b301b2f9dbb0e848093127fb10cbe6334a697dc3aea6740f0bb726450ee9a34", size = 315543, upload-time = "2025-06-23T13:20:47.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/39/7edf76a442621d76dc18ee82dcce82f8a0df2fbc7b962ade42a833e30a32/zstd-1.5.7.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5414c9ae27069ab3ec8420fe8d005cb1b227806cbc874a7b4c73a96b4697a633", size = 166648, upload-time = "2025-06-23T13:11:55.47Z" },
{ 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" },
]