Compare commits

..
73 changed files with 1035 additions and 2064 deletions

No files matched your search

+1
View File
@@ -55,6 +55,7 @@
"getting-started/goto/prowler-api-reference"
]
},
"getting-started/products/prowler-private-cloud-aws-marketplace",
{
"group": "Prowler Lighthouse AI",
"pages": [
+1 -1
View File
@@ -15,7 +15,7 @@ Read the [public announcement of the Prowler product families](https://prowler-w
| Name | Description |
|------|-------------|
| [Prowler Cloud](/getting-started/products/prowler-cloud) | Managed cloud security platform operated by the Prowler team. See [pricing](https://prowler.com/pricing). |
| Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. See [pricing](https://prowler.com/pricing). |
| Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. Available [on AWS Marketplace](/getting-started/products/prowler-private-cloud-aws-marketplace). See [pricing](https://prowler.com/pricing). |
| [Prowler Hub](https://hub.prowler.com) | Free public library of versioned checks, cloud service artifacts, and compliance frameworks. |
| [Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) | AI security analyst capabilities within Prowler Cloud and Prowler Private Cloud. |
| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/user-guide/ai-agents/claude-code). |
@@ -0,0 +1,188 @@
---
title: "Prowler Private Cloud"
sidebarTitle: "Prowler Private Cloud"
description: "Choose a plan, install Prowler Private Cloud in your own AWS account from AWS Marketplace, and understand billing and infrastructure cost."
---
import { AwsMarketplaceBanner } from "/snippets/aws-marketplace-banner.mdx"
<AwsMarketplaceBanner />
Prowler Private Cloud is the full Prowler platform, installed in the customer's own AWS account from AWS Marketplace and billed on the AWS bill. Findings, resource data and Lighthouse AI prompts stay in that account and Region.
<Note>
For AWS GovCloud (US), an air-gapped environment, an on-premises data center, or a different bundle size or contract term, [contact us](https://prowler.com/contact) for a private offer.
</Note>
## Choose a Plan
Each plan is its own AWS Marketplace listing. All three install the same product and differ only in how many cloud accounts the price covers:
* **Paid accounts:** AWS accounts, Azure subscriptions and Microsoft 365 tenants.
* **Other accounts:** everything else Prowler scans, such as Google Cloud projects, Kubernetes clusters, GitHub and Okta.
| Plan | Price | Included every month | Extra accounts |
| --- | --- | --- | --- |
| [Pay as You Go](https://aws.amazon.com/marketplace/pp/prodview-cpfki233rkskw) | 125 USD per paid account per month, no minimum | 1 other account per paid account | 125 USD each per month |
| [10-Account Bundle](https://aws.amazon.com/marketplace/pp/prodview-2pgvyyakt34go) | 1,000 USD per month, or 9,600 USD for 12 months upfront | 10 paid and 15 other accounts | 125 USD each per month |
| [25-Account Bundle](https://aws.amazon.com/marketplace/pp/prodview-ugxkxhdxfrhse) | 2,000 USD per month, or 19,200 USD for 12 months upfront | 25 paid and 40 other accounts | 125 USD each per month |
### Example: Pay as You Go
A month with 4 AWS accounts and 6 Google Cloud projects scanned:
| Charge | Quantity × price | Subtotal |
| --- | --- | --- |
| Paid accounts (the 4 AWS accounts) | 4 × 125 USD | 500 USD |
| Included other accounts (4 Google Cloud projects, 1 per paid account) | 4 × 0 USD | 0 USD |
| Extra accounts (the other 2 Google Cloud projects) | 2 × 125 USD | 250 USD |
| **Total** | | **750 USD** |
### Infrastructure Cost
AWS bills the infrastructure the installation runs on separately, at AWS prices. With the default size in us-east-1, it costs about 360 USD a month before scans.
Scans add to that: each scan runs as its own task at about 0.12 USD for every hour it runs, and the database grows while scans run. A scan of one AWS account usually takes:
* **Up to 2,000 resources:** about 10 minutes.
* **2,000 to 10,000 resources:** about 25 minutes.
* **Over 10,000 resources:** about an hour.
For example, 10 AWS accounts scanned once a day, at about 20 minutes each:
| Item | Value |
| --- | --- |
| Scans per month | 10 accounts × 30 days = 300 scans |
| Scan time per month | 300 scans × 1/3 hour = 100 hours |
| **Scan cost per month** | **100 hours × 0.12 USD = about 12 USD** |
Other Regions cost more than us-east-1. Amazon Bedrock is billed per use, only when Lighthouse AI is on.
<Note>
These figures are estimates based on AWS public list prices. AWS bills the infrastructure directly to your account, and the final amount depends on your Region, usage and any discounts you have with AWS. Prowler does not set or charge these costs.
</Note>
<AccordionGroup>
<Accordion title="Infrastructure cost breakdown">
About 360 USD a month for the default size in us-east-1, at AWS list prices, before scans:
| Resource | About, per month |
| --- | --- |
| Amazon Elastic Container Service (ECS) on AWS Fargate (Prowler services) | 177 USD |
| Amazon Aurora PostgreSQL | 47 USD |
| Amazon ElastiCache | 37 USD |
| Network address translation (NAT) gateway | 37 USD |
| Application Load Balancer and AWS Web Application Firewall (WAF) | 28 USD |
| Amazon CloudWatch | 22 USD |
| Other AWS services | 10 USD |
</Accordion>
</AccordionGroup>
## How It Works
The installation runs in the customer's AWS account, in the chosen Region. Users reach it through an Amazon CloudFront address, or a [custom domain](#updates-and-settings). Everything else runs in private subnets that cannot be reached from the internet. Scans use read-only access to the connected cloud accounts, and Prowler has no access to the installation.
<Frame>
<img className="block dark:hidden" src="/images/products/architecture/prowler-private-cloud-aws-architecture-light.svg" alt="Prowler Private Cloud on AWS: users reach Amazon CloudFront, which connects to an internal load balancer in a private network; the Prowler services run on Amazon ECS, with Amazon Aurora and Amazon ElastiCache for data and other AWS services in the same account; scans reach the connected accounts with read-only access" />
<img className="hidden dark:block" src="/images/products/architecture/prowler-private-cloud-aws-architecture-dark.svg" alt="Prowler Private Cloud on AWS: users reach Amazon CloudFront, which connects to an internal load balancer in a private network; the Prowler services run on Amazon ECS, with Amazon Aurora and Amazon ElastiCache for data and other AWS services in the same account; scans reach the connected accounts with read-only access" />
</Frame>
## Install Prowler Private Cloud
<Steps>
<Step title="Subscribe on AWS Marketplace">
Open the plan's listing on AWS Marketplace (links in [Choose a Plan](#choose-a-plan)), click **View purchase options**, and subscribe. Then continue to the configuration and to launch. The launch page has a link that opens the AWS CloudFormation console with the Prowler template already loaded.
</Step>
<Step title="Choose a Supported Region">
Before creating the stack, switch the console to a supported Region at the top right. The template refuses the others.
<Accordion title="Supported Regions">
| Region code | Region name | Lighthouse AI |
| --- | --- | --- |
| us-east-1 | US East (N. Virginia) | Available |
| us-east-2 | US East (Ohio) | Available |
| us-west-1 | US West (N. California) | Not available |
| us-west-2 | US West (Oregon) | Available |
| ca-central-1 | Canada (Central) | Not available |
| sa-east-1 | South America (São Paulo) | Available |
| eu-west-1 | Europe (Ireland) | Available |
| eu-west-2 | Europe (London) | Available |
| eu-west-3 | Europe (Paris) | Not available |
| eu-central-1 | Europe (Frankfurt) | Available |
| eu-north-1 | Europe (Stockholm) | Available |
| ap-east-1 | Asia Pacific (Hong Kong) | Not available |
| ap-south-1 | Asia Pacific (Mumbai) | Available |
| ap-southeast-1 | Asia Pacific (Singapore) | Available |
| ap-southeast-2 | Asia Pacific (Sydney) | Available |
| ap-northeast-1 | Asia Pacific (Tokyo) | Available |
| ap-northeast-2 | Asia Pacific (Seoul) | Available |
</Accordion>
</Step>
<Step title="Fill In the Required Section">
Enter:
* **Admin password:** the password for the built-in account `admin@prowler.local`.
* **Notification email:** the address that receives update, alarm and subscription notices.
* **Lighthouse AI:** whether to turn it on. Leave it off in the Regions where it is not available.
* **Portal access:** who can open the portal, from anywhere or only from an IP range.
Leave the optional fields and the version as they are, acknowledge the Identity and Access Management (IAM) capabilities, and create the stack. The installation takes about 35 minutes.
</Step>
<Step title="Confirm the Notification Email">
AWS sends a subscription confirmation to the notification address. Confirm it: without it, no update, alarm or subscription notices arrive.
</Step>
<Step title="Sign In">
Open the address in the stack's **Outputs** tab (`UiUrl`), an Amazon CloudFront address until a [custom domain](#updates-and-settings) is set up, and sign in as `admin@prowler.local` with the admin password set in step 3. The AWS account Prowler is installed in is already connected: start its first scan from **Scans**, and invite the team from **Users**.
</Step>
<Step title="Add Cloud Accounts">
From **Cloud Providers** in the portal, add single AWS accounts, a whole AWS Organization, or other providers.
</Step>
</Steps>
## How Billing Works
* **Monthly count:** each cloud account Prowler scans in a calendar month (UTC) counts once that month, however many scans it has.
* **AWS bill:** the subscription appears on the AWS bill, line by line, including the accounts the plan includes at 0.00 USD.
* **Monthly statement:** at the start of each month, the installation emails last month's usage.
If the subscription ends, Prowler keeps working for 7 days and a daily email counts the days down. After that, new scans and premium features pause, but findings, history and reports stay. Subscribing again resumes everything within the hour.
## Updates and Settings
New Prowler releases install automatically every night. Settings chosen at launch can be changed later with a stack update:
<AccordionGroup>
<Accordion title="Use a Custom Domain">
Without a custom domain, Prowler runs on its Amazon CloudFront address.
1. Choose a subdomain that starts with `prowler`, such as `prowler.example.com`.
2. Request an AWS Certificate Manager certificate for `cloud.prowler.example.com`, `api.cloud.prowler.example.com` and `mcp.prowler.example.com` in us-east-1. Request a second one in the installation's Region, unless the domain is an Amazon Route 53 hosted zone in the same account.
3. Open the AWS CloudFormation console in the installation's Region, go to **Stacks**, select the Prowler stack, and click **Update**, then **Make a direct update**.
4. Choose **Use existing template** and click **Next**.
5. Fill in the **Your own domain** fields with the domain and the certificates, and leave every other parameter as it is. Click **Next** until the review page.
6. Acknowledge the IAM capabilities and click **Submit**.
</Accordion>
<Accordion title="Turn Lighthouse AI On or Off">
To turn Lighthouse AI on:
1. Open the AWS CloudFormation console in the installation's Region, go to **Stacks**, select the Prowler stack, and click **Update**, then **Make a direct update**.
2. Choose **Use existing template** and click **Next**.
3. Set **Lighthouse AI** to **Enabled**, and leave every other parameter as it is. Click **Next** until the review page.
4. Acknowledge the IAM capabilities and click **Submit**. Lighthouse AI is ready within minutes and needs no keys.
To turn it off, delete its provider in the portal's Lighthouse AI settings.
</Accordion>
</AccordionGroup>
## Uninstall
Contact [support@prowler.com](mailto:support@prowler.com) for the uninstall steps: the installation protects its data against accidental deletion, so deleting the stack on its own stops partway. Uninstalling does not cancel the subscription; cancel it in the AWS Marketplace console, under **Manage subscriptions**.
## Get Support
Write to [support@prowler.com](mailto:support@prowler.com), with the AWS account ID, the Region and the stack name.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1460 790" font-family="'Inter Diagram', Inter, 'Segoe UI', system-ui, -apple-system, sans-serif" role="img" aria-label="Prowler Private Cloud on AWS architecture"><title>Prowler Private Cloud on AWS architecture</title><defs><style>@font-face{font-family:"Inter Diagram";font-weight:400 700;src:url(data:font/woff2;base64,d09GMgABAAAAAL0wABQAAAAB4AgAALy3AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoZeG4KyRBzVcD9IVkFSi2k/TVZBUl4GYD9TVEFUgU4nJgCFNi9sEQgKgbtAgaEUC4gOADCCnD4BNgIkA5AYBCAFhi4HoQRbbM1xJ9pEvFeIqaVsuokIsO22SVU1fQk3aHa/1VxA501dSsJn5lSwbRr1bgcRJN0fNvv////PTSpjaFO2tAUBUdXp/H+hZmYeWbxMkksUg6gSjR4sJp1OluyFAZzHknNZFfcOC/jkm4TBTvWYFyAlzqJGm4RoBKtiTp9mkyGfCzYwC/BDbocpQqUSQIhMa7ygZvA0U75Y4LFTHnT/aGZJWR6pLqVniIDetGuXwgUe8/POsOsP1Q9VUiWTp72izbt5tKtLgWhoWrfh09M7RFpXf5QIFuBDxYUkcftGuBj4EcUodLrLBqOES28ulU2C4bvURfopCwxHk8sX8DGo2D5LQadmFhKB7no9F66EECKF6kb1LqY15mIYK93FNtnkyduuV/xNv9PH6x75G9MA70vGml6p8vX9jTf8AlMyEzuyoIG97E5cYX23aa9Hhfa/gh+rhMr0p6hG3V/wUX/4bg8BtusHxJhISTNsDu+T58G6+P7cJFXdA5Grx9UI3uHDWFqdI9Ktmb2SSyGEEKqU0AMiIERQ9DXQRVFERKTZsDREnyYqQuzAK/KWiqiIvCLvK49+s1SK7ysqomJ9xU6RIlJyVj8G+727t4KYRiKJ6Uw3iRAa2bRlQiZEItHsW/vD03T+u+jlcsnF5JJcVJs03iStI4UWK6LFbFo+M0LKTNmHjYkoait4BVakjq+l1KiQpkOgZ1ejQ422tKUtSiXWioidPSUhIkaCIEQiJCIRxIrZgS6Umqtf2qKtjv1Px/y9+msMz2/q/7lAgHjTWFWm1u39lwuknXfinuWlXXeFXDRAULlcJHiACBaVSpLG6zrP9pfj382aQdsZgpTumfvX7RNCulCnLRXfvMwJ+YXfUxXkTPjnn/182+cl+UV2VSRMdTuAwgDh8lHDRrIjMIL8qBEeMNcHvm3fH+/WjbYKuzHASXJESqJioBVY17svf6PqbzuK4d/N6iGtj+iaWFkz/5I7p6zJuXQ61p1ScwoePIQQgkYhEBIIwQMEAPR8+/VWPTOJIpIZnRPLCgkX7n+LqGKXJDBIYOoauuaHLayoaOIjGdjlV6MOfAKzjwhVNzQG8uRBYAVG2G770u2fvmQSp6bxI+T7xENyB+QBe8AeoEcZuQ+kAeuBNUAPWC5Q9GWfx1lOJY/yDoxbtnO9DPRjm33JS4DgNZ9ZFz3tVZR+Oa2onNy/uJXaQAMEgOdzqn9JzlyoZbnAjsVZJcsFBJQRPR7+3U9T2ylSxF9XdraQNyIXPyBJZMSM6/E1btLkwv/za5LnfprgMqmiIj2Tlm3ZuArXd/NTJInsditVbStcBuZmuh2o+5q/Le+s6Wn6HRGdCSs788v5/j+BxKuXa14CfoTIRDS2lRoVt+HWDIa+ViyxC8jxO7gBW/OIoGiLnaEaCsXU2aW+WOJz2VEo+nvGlPjd/j3RNKHmv5gaD0KBXUYZRsEehmEIuDF2TFNM/H2pWtf/Gh9kA5phEeCu2FpPaNrLnY+JkCacQbXkCSnd99xAEwa6AVINiJIBUAGkU4Myxx8UaYPksMwGFGjSKVLakO2sTfkDlGpalDzVJhVAOVGaqPJtYqA3hHCL8bS1p91biOe9nUK+7Snkw+m+x93rZXn+//fmbO5s6ij+etAlEhlh/sKqKS05REF/DrprzauscSBjvAD+v5+m+OmM5rkr2p9WEdv5sgNIAA7ga3fN3Y1SuiawdVQqlAsK8gQgT1AHpHVcAQpjISAAx/+cllRPT77x/4su6bQ1zlclIxd5i51a0HpTCwsK4UFHYADC+WmtX/2Le4iQCUnMG41QmMfyvu0XQ8yiv5WbO/8ijmop1ELNs4MdJLETVBI8H960wDp7QLiL8kmCA8CPYRj2l6qfLR6BNyR/5kVeCKEozyFWJ3cVsIvVYrFYEUzzQYI8pUskpR8yJQetwH8GeSGGyjFQF0LsfOXVrly5c1V6ehdFFXLfmIf+l+/s3iS0stDloIiOcN+q3Ucdhtaqa0U4MBYhP8Jx8ECgLqa7fnPxCKfgAxOkGc4hlEASFBLH+3xTZ9v8XOc6hmIrhE8c8ssqm9XruP+Qa2oewlIXHDK9wBLCOBOecPNd7o+pilS1stzLEdZ9OtYygqAgwlXJwr/bC6/nhumPhSelkyKhBBdk7HPWxMpSrcMGsGGGxQgzCCOEEI1ohBA6YRzfxPl9y2RNdvv3faPQ77zZeW6nHCQEEREJEkQknX0IdYqXva4nCasWr+lFjEq5Tuwb6+8dY7Yf8GuX1ae/5AEBBTmWNsEitkz2kJfKUKpQ4/X//dLPi8Gk3T2+ZZaNLIdSawnBKxJFQgjHOvM1lqqKI1Neth/ZVFpz6cNukFF/QZG7/ffQd4HvIswRhVCRs1CVR9Bjz6GX+qBBOhiCBDA0MoCRoAQwUtQHjHUEjEMGmOAIMOHRYQqrh9mTHmZ/RpiDWWBO5Yc5XQjm79IwF2uDqWkEldauwHTUgUp33SiIAKsDi0NsahpsqOGu1Q1hbkNQbkWDshFwCnAIsDI9iEDggQXOPtm7vQDyP9G7f7Y+9Uco+35f/hvvxaeBOeAe8BVQIAKkd761/u0pr4EC5AXBef4Jv3BX5YMrdv/fntv3rzjw+FMrbkKPvnKL8+Ss2yrvXH5X5d3L7x1/7/J7V967+t619y9/wFzrrI0/uPxhZX30kvNS5WOOf8ufeLisvQxMT3+SceW8K5VPLnly+VOO64broevxZ21NrqaspmBT5fO+oNGKHwAzshFCBHGhdDGLTbIkqN+rVUtPnanehoztbX4LDnwYRsg7nJ7e3O5O97OLD6X00c9wSAOP4vjSkvmmO/3zkzm43GqWP6fTWR0xUuSYKIYBa86TTyEiarHkbGOExU976TuJWFQmOdOY+BzOl0cycktZhDqgD46jsLEgZEEYhRYehAy2sjksbIUO+7URjkhEI5ahclSowhWtwk5PzjXn//QTtXTZ1kFMbNzlbxBNZ3pTSr99G7pYktK+O5qgBtbtcGNhHofHE+ydhAVbBIk4MqWyXWnKUu7JZKJUmtjOvegIivXu6zsL3wGPAwAEoE24SCKtUzsbQpFUh8Vm9/j8cBjDE8k0STFslsvxhdJgwvN5KGJMorKq6YZlZwViBaHRGsxWRyhcINEa7aFsDhAgHQYdFQRgbxoQYFERitD738bp4f14iY/B5WneH4XLC+5sAuZoQOcNUFAm1Cg7xs0ue5JPYOSkqf6Vi1C+GI3v2hwIJjg2ercBtaG5gnHXJxpf5uewRmhJsWSQCMIlSsXzzuJCgqHhwZExCaok7FnpBR+uQxWG6SBafD6UPwfUfNiMH3ni8C5UivfKiBb3hgy2AKpvJjiC/7WCaVABDXhcuSU8D0fg4hdIkmMcFh0GZ6/4Nx//0NL/Xgvdl/o/fPtXfAS+9ri+Oiv5yz/M4Z/3YVOo7H1x2cPOd9ZbLrnI2U/bComrutjZTqugctwTnkMe5bE9FGFX/sfFF9rlmTOue7U1n9iqja3nMt+DQZNpZStM37ahV14eNqdTW8WOpiRGfInEHq0zI42y2/K/TWbYIc7yifFGF0EQDrb9bPk6tzrgfOXrtltrqdTdwLeD3zXbRCNsqqD0sFkdS1ZO13RB+cpYQcqNSytCVSqQqFizFS+PTJIIZnopMHqFFnlsoMG0u83Q6RM0QotZQzlTmNZrOJlIwmkwm7EgL1ZZgpHoryUL19xpef5XpYpVq+qG6qiq4jqqq8wFSZ0uzQblYfjE45lscxbjjTWy8PN9C5rxfvRh7CjtSco3lsiCkD5z4ga0a9Su2BojujTIuVGO1aSioUr9emM1TEaEAnNAyLcAatm0GSMCYqu5ZIhYBZ0Yg4BEinZJlRlGUrIK4agtQinyvBsACEeSpeqAztqyiNe24NAk6E39qzMefI9uaAVcVbTyv9DevGWz/kU3hK0nNstO7Z+FwrRJWWLW9zLpWcR4Crd9E15Xz+OhLZ+9KVXx74Y2nOQMUF71midvXjG807SupKyoEX2RM2S43m3CkbgV8131NbICl2yOfDhPVePOWXFLFhn3Xza9foOTJzgQKjOcTZhWDYGp86oAef2Rp68ex54tWo7GuAdTr++81yZwExUeu0b7UOqZwCa+aWayiWrXLvwJf7uhEG30URWB+3bwRRkbDbvvaMvl4Ja1mtqZD3OI7J2DcIGh/SkX/MuaGYHD/bIvl2OzZSx6L+QlngLk1VeGNPOs7eYZlkr6enPTOaVYX246qNY5hIvuMNvMe7Xp/MY/PdufDHWu7DNZifO0Py8e7GdvMpHnFmMQCss4oiRcsmPT5PgitkzQP9ebG4+/UpPxh78OoYHMk6uy8BFZsNHm/4Q/GcJyfjhfo72Yx88Ds3DApGaFzw4mLOGro34kNfhwiLweyV4GloOmDWkLShB29CMIeF4SklM1HdexIkRC9h8jacvLa7bnRe3VEo+L1n7qOCFIGloxb258jez5Line truncated

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1460 790" font-family="'Inter Diagram', Inter, 'Segoe UI', system-ui, -apple-system, sans-serif" role="img" aria-label="Prowler Private Cloud on AWS architecture"><title>Prowler Private Cloud on AWS architecture</title><defs><style>@font-face{font-family:"Inter Diagram";font-weight:400 700;src:url(data:font/woff2;base64,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 truncated

After

Width:  |  Height:  |  Size: 104 KiB

+7
View File
@@ -0,0 +1,7 @@
export const AwsMarketplaceBanner = () => {
return (
<Info>
<b>Now available on AWS Marketplace.</b> Install Prowler Private Cloud in your own AWS account and pay on your AWS bill, with three plans: Pay as You Go, 10-Account Bundle and 25-Account Bundle. <a href="#choose-a-plan">Choose a plan</a>.
</Info>
);
};
+1
View File
@@ -80,6 +80,7 @@
nested spans of a tag pill such as Coming Soon.
Icon source: /images/icons/cloud-bold.svg. See AGENTS.md "Cloud Marker" convention. */
li[data-title="Prowler Cloud"] > button span:first-child::after,
li[id="/getting-started/products/prowler-private-cloud-aws-marketplace"] a > div > div > span:first-child::after,
li[data-title="Prowler Lighthouse AI"] > button span:first-child::after,
li[data-title="Providers"] > button span:first-child::after,
li[data-title="Scans"] > button span:first-child::after,
@@ -0,0 +1 @@
Zones without a challenge passage TTL are reported as non-compliant by `zone_challenge_passage_configured`
@@ -0,0 +1 @@
Cloudflare check mappings for the NCSC Cyber Essentials 3.3 compliance framework
+60 -26
View File
@@ -132,6 +132,11 @@
"network_ssh_internet_access_restricted",
"network_udp_internet_access_restricted",
"network_http_internet_access_restricted"
],
"cloudflare": [
"dns_record_proxied",
"zone_firewall_blocking_rules_configured",
"zone_waf_enabled"
]
}
},
@@ -147,7 +152,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -168,7 +174,8 @@
"keyvault_access_only_through_private_endpoints",
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted"
]
],
"cloudflare": []
}
},
{
@@ -189,7 +196,8 @@
"network_udp_internet_access_restricted",
"network_http_internet_access_restricted",
"storage_account_public_network_access_disabled"
]
],
"cloudflare": []
}
},
{
@@ -204,7 +212,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -219,7 +228,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -234,7 +244,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -252,7 +263,8 @@
"azure": [
"entra_policy_guest_users_access_restrictions",
"entra_policy_guest_invite_only_for_admin_roles"
]
],
"cloudflare": []
}
},
{
@@ -269,7 +281,8 @@
"checks": {
"azure": [
"entra_security_defaults_enabled"
]
],
"cloudflare": []
}
},
{
@@ -284,7 +297,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -299,7 +313,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -320,7 +335,8 @@
"storage_account_public_network_access_disabled",
"storage_ensure_minimum_tls_version_12",
"keyvault_rbac_enabled"
]
],
"cloudflare": []
}
},
{
@@ -335,7 +351,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -350,7 +367,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -365,7 +383,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -380,7 +399,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -398,7 +418,8 @@
"azure": [
"defender_ensure_system_updates_are_applied",
"defender_auto_provisioning_vulnerabilty_assessments_machines_on"
]
],
"cloudflare": []
}
},
{
@@ -413,7 +434,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -431,7 +453,8 @@
"azure": [
"entra_security_defaults_enabled",
"storage_default_to_entra_authorization_enabled"
]
],
"cloudflare": []
}
},
{
@@ -446,7 +469,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
},
{
@@ -467,7 +491,8 @@
"entra_conditional_access_policy_require_mfa_for_admin_portals",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_user_with_vm_access_has_mfa"
]
],
"cloudflare": []
}
},
{
@@ -486,7 +511,8 @@
"entra_global_admin_in_less_than_five_users",
"iam_role_user_access_admin_restricted",
"iam_subscription_roles_owner_custom_not_created"
]
],
"cloudflare": []
}
},
{
@@ -504,7 +530,8 @@
"azure": [
"entra_global_admin_in_less_than_five_users",
"iam_role_user_access_admin_restricted"
]
],
"cloudflare": []
}
},
{
@@ -522,6 +549,9 @@
"azure": [
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa"
],
"cloudflare": [
"zone_rate_limiting_enabled"
]
}
},
@@ -541,7 +571,8 @@
"entra_security_defaults_enabled",
"entra_privileged_user_has_mfa",
"entra_non_privileged_user_has_mfa"
]
],
"cloudflare": []
}
},
{
@@ -560,7 +591,8 @@
"defender_assessments_vm_endpoint_protection_installed",
"defender_ensure_wdatp_is_enabled",
"defender_ensure_defender_for_server_is_on"
]
],
"cloudflare": []
}
},
{
@@ -579,7 +611,8 @@
"defender_ensure_wdatp_is_enabled",
"defender_ensure_defender_for_server_is_on",
"defender_ensure_defender_for_storage_is_on"
]
],
"cloudflare": []
}
},
{
@@ -594,7 +627,8 @@
"References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E"
},
"checks": {
"azure": []
"azure": [],
"cloudflare": []
}
}
]
@@ -1176,7 +1176,6 @@
"eu-west-3",
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
@@ -3273,7 +3272,9 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
@@ -6248,24 +6249,6 @@
"aws-us-gov": []
}
},
"finspace-data": {
"regions": {
"aws": [
"ca-central-1",
"eu-west-1",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": []
}
},
"firehose": {
"regions": {
"aws": [
@@ -7543,6 +7526,7 @@
"interconnect": {
"regions": {
"aws": [
"eu-north-1",
"eu-south-1"
],
"aws-cn": [],
@@ -11147,6 +11131,7 @@
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-south-1",
@@ -13986,13 +13971,16 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
@@ -14031,13 +14019,16 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
@@ -29,17 +29,23 @@ class zone_challenge_passage_configured(Check):
metadata=self.metadata(),
resource=zone,
)
# API returns seconds, convert to minutes
challenge_ttl_minutes = zone.settings.challenge_ttl // 60
if min_minutes <= challenge_ttl_minutes <= max_minutes:
report.status = "PASS"
report.status_extended = f"Challenge Passage is set to {challenge_ttl_minutes} minutes for zone {zone.name}."
else:
challenge_ttl = zone.settings.challenge_ttl
if challenge_ttl is None:
report.status = "FAIL"
report.status_extended = (
f"Challenge Passage is set to {challenge_ttl_minutes} minutes for zone {zone.name} "
f"(recommended: between {min_minutes} and {max_minutes} minutes)."
f"Challenge Passage is not configured for zone {zone.name}."
)
else:
# API returns seconds, convert to minutes
challenge_ttl_minutes = challenge_ttl // 60
if min_minutes <= challenge_ttl_minutes <= max_minutes:
report.status = "PASS"
report.status_extended = f"Challenge Passage is set to {challenge_ttl_minutes} minutes for zone {zone.name}."
else:
report.status = "FAIL"
report.status_extended = (
f"Challenge Passage is set to {challenge_ttl_minutes} minutes for zone {zone.name} "
f"(recommended: between {min_minutes} and {max_minutes} minutes)."
)
findings.append(report)
return findings
@@ -29,6 +29,7 @@ from prowler.lib.check.compliance_models import (
get_universal_compliance_entry_point_dirs,
load_compliance_framework_universal,
)
from prowler.lib.check.models import CheckMetadata
from tests.lib.outputs.compliance.fixtures import (
CIS_1_4_AWS,
ENS_RD2022_AWS,
@@ -908,13 +909,38 @@ class TestCyberEssentialsFramework:
)
return os.path.normpath(base)
def test_loads_and_supports_azure(self):
def test_loads_and_supports_providers(self):
fw = load_compliance_framework_universal(self._path())
assert fw is not None
assert fw.framework == "Cyber-Essentials"
assert fw.version == "3.3"
assert fw.get_providers() == ["azure"]
assert fw.get_providers() == ["azure", "cloudflare"]
assert fw.supports_provider("azure")
assert fw.supports_provider("cloudflare")
def test_every_requirement_declares_cloudflare(self):
# get_providers() unions provider keys across requirements, so it stays
# green even if a single requirement carries the cloudflare key. Assert
# per requirement instead, including the ones that map to no Cloudflare
# check: an explicit empty list records "reviewed, nothing applies",
# whereas a missing key is an unreviewed gap.
fw = load_compliance_framework_universal(self._path())
missing = [req.id for req in fw.requirements if "cloudflare" not in req.checks]
assert missing == []
def test_cloudflare_check_ids_are_valid(self):
fw = load_compliance_framework_universal(self._path())
referenced = {
check
for req in fw.requirements
for check in req.checks.get("cloudflare", [])
}
assert referenced, "expected at least one Cloudflare check mapping"
available = set(CheckMetadata.get_bulk(provider="cloudflare"))
assert (
referenced <= available
), f"unknown Cloudflare CheckIDs: {sorted(referenced - available)}"
def test_covers_all_five_themes(self):
fw = load_compliance_framework_universal(self._path())
@@ -9,7 +9,6 @@ from prowler.providers.cloudflare.exceptions.exceptions import (
CloudflareInvalidAPIKeyError,
CloudflareInvalidAPITokenError,
CloudflareNoAccountsError,
CloudflareUserTokenRequiredError,
)
from prowler.providers.cloudflare.models import (
CloudflareAccount,
@@ -308,8 +307,8 @@ class TestCloudflareValidateCredentials:
CloudflareProvider.validate_credentials(session)
mock_client.user.get.assert_called_once()
def test_validate_credentials_user_token_required(self):
"""Test that user token required error is raised for Account tokens."""
def test_validate_credentials_account_token_without_accounts(self):
"""Test that account tokens fall back to account discovery."""
mock_client = MagicMock()
# Simulate error code 9109 - user-level authentication required
from cloudflare._exceptions import PermissionDeniedError
@@ -319,6 +318,7 @@ class TestCloudflareValidateCredentials:
response=MagicMock(status_code=403),
body=None,
)
mock_client.accounts.list.return_value = iter([])
session = CloudflareSession(
client=mock_client,
@@ -327,8 +327,10 @@ class TestCloudflareValidateCredentials:
api_email=None,
)
with pytest.raises(CloudflareUserTokenRequiredError):
with pytest.raises(CloudflareNoAccountsError):
CloudflareProvider.validate_credentials(session)
mock_client.user.get.assert_called_once()
mock_client.accounts.list.assert_called_once()
def test_validate_credentials_invalid_api_token(self):
"""Test that invalid API token error is raised."""
@@ -487,8 +489,8 @@ class TestCloudflareTestConnection:
assert connection.is_connected is False
assert isinstance(connection.error, CloudflareInvalidAPITokenError)
def test_test_connection_user_token_required(self):
"""Test that user token required error is properly returned."""
def test_test_connection_account_token_without_accounts(self):
"""Test that account tokens without accessible accounts return an error."""
mock_client = MagicMock()
from cloudflare._exceptions import PermissionDeniedError
@@ -497,6 +499,7 @@ class TestCloudflareTestConnection:
response=MagicMock(status_code=403),
body=None,
)
mock_client.accounts.list.return_value = iter([])
with patch(
"prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session",
@@ -512,9 +515,10 @@ class TestCloudflareTestConnection:
)
assert connection.is_connected is False
assert isinstance(connection.error, CloudflareUserTokenRequiredError)
# Verify the error message is user-friendly
assert "User-level API token required" in str(connection.error)
assert isinstance(connection.error, CloudflareNoAccountsError)
assert "No Cloudflare accounts found" in str(connection.error)
mock_client.user.get.assert_called_once()
mock_client.accounts.list.assert_called_once()
def test_test_connection_invalid_api_key(self):
"""Test that invalid API key error is properly returned."""
@@ -240,3 +240,7 @@ class Test_zone_challenge_passage_configured:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Challenge Passage is not configured for zone {ZONE_NAME}."
)
@@ -102,8 +102,10 @@ class Test_zone_development_mode_disabled:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Development mode is enabled" in result[0].status_extended
assert "bypasses" in result[0].status_extended
assert (
result[0].status_extended
== f"Development mode is enabled for zone {ZONE_NAME}."
)
def test_zone_development_mode_none(self):
zone_client = mock.MagicMock
@@ -18,7 +18,6 @@ import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import {
isSampleScanFilter,
pickSampleFindingFilters,
sampleFindingGroupResources,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -201,7 +200,6 @@ async function fetchFindingGroupResourcesEndpoint(
pageSize,
status: Array.isArray(status) ? status.join(",") : status,
search: Array.isArray(search) ? search[0] : search,
filters: pickSampleFindingFilters(filters),
});
}
@@ -1,4 +1,4 @@
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import { isSampleId } from "@/lib/sample-preview/sample-records";
import { FINDING_STATUS, type FindingStatus } from "@/types/components";
import {
FINDING_TRIAGE_BILLING_HREF,
+4 -16
View File
@@ -33,23 +33,11 @@ export const getFindings = async ({
filters = {},
}) => {
if (isSampleScanFilter(filters)) {
const findings = sampleFindingsQuery(filters);
const size = Number(pageSize) || 10;
const current = Math.max(1, Number(page) || 1);
const document = sampleFindingsDocument(
findings.slice((current - 1) * size, current * size),
return withFindingTriageSummaries(
sampleFindingsDocument(
sampleFindingsQuery(filters).slice(0, Number(pageSize) || 10),
),
);
return withFindingTriageSummaries({
...document,
meta: {
...document.meta,
pagination: {
page: current,
pages: Math.max(1, Math.ceil(findings.length / size)),
count: findings.length,
},
},
});
}
const headers = await getAuthHeaders({ contentType: false });
-5
View File
@@ -14,7 +14,6 @@ import {
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
isSampleId,
sampleResourceDrawerData,
sampleResourceEvents,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -296,10 +295,6 @@ export const getResourceDrawerData = async ({
pageSize?: number;
query?: string;
}) => {
if (isSampleId(resourceId)) {
return sampleResourceDrawerData(resourceId, query);
}
const isCloudEnv = isCloud();
const [resourceData, findingsResponse, organizationsResponse] =
@@ -25,14 +25,10 @@ function getScoreColor(score: number): string {
}
interface RiskPlotClientProps {
disableNavigation?: boolean;
data: RiskPlotPoint[];
}
export function RiskPlotClient({
data,
disableNavigation = false,
}: RiskPlotClientProps) {
export function RiskPlotClient({ data }: RiskPlotClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
@@ -135,7 +131,7 @@ export function RiskPlotClient({
</div>
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={disableNavigation ? undefined : handleBarClick}
onBarClick={handleBarClick}
/>
</div>
) : (
@@ -13,14 +13,10 @@ import { SEVERITY_FILTER_MAP } from "@/types/severities";
import { CategorySelector } from "./category-selector";
interface RiskRadarViewClientProps {
disableNavigation?: boolean;
data: RadarDataPoint[];
}
export function RiskRadarViewClient({
data,
disableNavigation = false,
}: RiskRadarViewClientProps) {
export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RadarDataPoint | null>(
@@ -103,7 +99,7 @@ export function RiskRadarViewClient({
</div>
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={disableNavigation ? undefined : handleBarClick}
onBarClick={handleBarClick}
/>
</div>
) : (
@@ -1,19 +1,13 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
SAMPLE_SERVICES_RESPONSE,
SAMPLE_STATUS,
} from "@/lib/sample-preview/sample-overview";
import { SAMPLE_SERVICES_RESPONSE } from "@/lib/sample-preview/sample-overview";
import { OverviewSamplePreview } from "./overview-sample-preview";
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
// Card links import server actions; their auth chain has no place in jsdom.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, refresh: vi.fn() }),
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
@@ -21,54 +15,39 @@ vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
// The graph tabs have their own test; here they would only add chart noise.
vi.mock("./sample-graphs-tabs", () => ({
SampleGraphsTabs: () => null,
SampleGraphsTabs: () => <div data-testid="sample-graphs-tabs" />,
}));
describe("OverviewSamplePreview", () => {
it("renders the demo account's passing and failing totals", () => {
it("fills the Overview with the demo account's numbers", () => {
render(<OverviewSamplePreview showIssues={false} />);
const card = screen
.getByText("Check Findings")
.closest('[data-slot="card"]')!;
expect(
within(card as HTMLElement).getByText(
SAMPLE_STATUS.fail.toLocaleString(),
),
).toBeVisible();
expect(
within(card as HTMLElement).getByText(
SAMPLE_STATUS.pass.toLocaleString(),
),
).toBeVisible();
expect(screen.getByText("Findings Severity Over Time")).toBeVisible();
expect(screen.getByText("Internet Exposed Resources")).toBeVisible();
expect(screen.getByTestId("sample-graphs-tabs")).toBeInTheDocument();
});
it("only leaves links live where the target page shows the sample", () => {
const { container } = render(<OverviewSamplePreview showIssues={false} />);
const links = Array.from(container.querySelectorAll("a"));
expect(links.length).toBeGreaterThan(0);
for (const link of links) {
expect(link.getAttribute("href")).toMatch(
/^\/(findings|issues|compliance|resources)(\?|$)/,
);
const live = links.filter((link) => !link.closest("[inert]"));
expect(live.length).toBeGreaterThan(0);
expect(live.length).toBeLessThan(links.length);
for (const link of live) {
expect(link.getAttribute("href")).toMatch(/^\/(findings|issues)(\?|$)/);
}
});
it("opens sample findings filtered by the selected service", async () => {
const user = userEvent.setup();
it("lists every sample service in the watchlist", () => {
render(<OverviewSamplePreview showIssues={false} />);
// The riskiest service of the demo account leads the watchlist.
const riskiest = [...SAMPLE_SERVICES_RESPONSE.data].sort(
(left, right) => right.attributes.fail - left.attributes.fail,
)[0];
await user.click(
screen.getByRole("button", { name: new RegExp(riskiest.id, "i") }),
);
const url = new URL(pushMock.mock.calls[0][0], "https://prowler.test");
expect(url.pathname).toBe("/findings");
expect(url.searchParams.get("filter[service__in]")).toBe(riskiest.id);
expect(url.searchParams.get("filter[status__in]")).toBe("FAIL");
expect(
screen.getAllByText(new RegExp(riskiest.id, "i")).length,
).toBeGreaterThan(0);
});
});
@@ -1,8 +1,9 @@
import { type ReactNode } from "react";
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { verdictCounts } from "@/lib/issues/verdicts";
@@ -34,24 +35,30 @@ import { ComplianceWatchlist, ServiceWatchlist } from "../watchlist";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
// `contents` keeps each card a direct item of the grid's flex rows, while
// `inert` stops links into pages with no sample data (compliance details,
// resources). Cards that link to Findings or Issues stay live: those pages
// show the sample and apply the link's filters.
const Inert = ({ children }: { children: ReactNode }) => (
<div inert className="contents">
{children}
</div>
);
const NO_SCOPE: Record<string, string> = {};
interface OverviewSamplePreviewProps {
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
showIssues: boolean;
/** Compliance totals derived from the sample requirements, when available. */
complianceWatchlist?: ComplianceWatchlistResponse["data"];
}
/** The Overview filled with the demo account, for tenants without a scan. */
export function OverviewSamplePreview({
showIssues,
complianceWatchlist = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data,
}: OverviewSamplePreviewProps) {
const complianceItems = adaptComplianceWatchlistResponse({
...SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
data: complianceWatchlist,
}).map((item) => ({
const complianceItems = adaptComplianceWatchlistResponse(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
).map((item) => ({
id: item.id,
framework: item.complianceId,
label: item.label,
@@ -62,12 +69,14 @@ export function OverviewSamplePreview({
return (
<OverviewGrid
threatScore={
<ThreatScore
score={SAMPLE_THREAT_SCORE.score}
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
/>
<Inert>
<ThreatScore
score={SAMPLE_THREAT_SCORE.score}
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
/>
</Inert>
}
exploitability={
showIssues ? (
@@ -97,13 +106,16 @@ export function OverviewSamplePreview({
}
riskSeverity={<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />}
resourcesInventory={
<ResourcesInventory
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
/>
<Inert>
<ResourcesInventory
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
/>
</Inert>
}
complianceWatchlist={
// The sample Compliance page is per scan: no multi-scan view to link.
<ComplianceWatchlist items={complianceItems} hasWatchlist={false} />
<Inert>
<ComplianceWatchlist items={complianceItems} hasWatchlist />
</Inert>
}
serviceWatchlist={
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
@@ -121,9 +133,18 @@ export function OverviewSamplePreview({
) : undefined
}
severityOverTime={
<FindingSeverityOverTimeCard>
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} readOnly />
</FindingSeverityOverTimeCard>
<Inert>
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Findings Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} />
</CardContent>
</Card>
</Inert>
}
graphsTabs={<SampleGraphsTabs />}
/>
@@ -1,43 +0,0 @@
import { expect, it } from "vitest";
import { render } from "@/__tests__/render-browser";
import {
SAMPLE_NEW_FINDINGS,
SAMPLE_RADAR_DATA,
} from "@/lib/sample-preview/sample-graphs";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
it("offers each latest new failed finding as a drawer trigger", async () => {
const screen = await render(<SampleGraphsTabs />);
const [finding] = SAMPLE_NEW_FINDINGS;
// The trigger opens the finding drawer, which resolves sample ids locally.
await expect
.element(
screen
.getByRole("button", {
name: finding.attributes.check_metadata.checktitle,
})
.first(),
)
.toBeEnabled();
});
it("lets users explore sample categories without opening live findings", async () => {
const screen = await render(<SampleGraphsTabs />);
const category = SAMPLE_RADAR_DATA[0];
await screen.getByRole("tab", { name: "Risk Radar" }).click();
await screen.getByRole("combobox").click();
await screen
.getByRole("option", { name: category.category, exact: true })
.click();
await expect
.element(screen.getByText(`${category.value} Total Findings`))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: /Critical/ }))
.not.toBeInTheDocument();
});
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SAMPLE_CHECKS } from "@/lib/sample-preview/sample-dataset";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
describe("SampleGraphsTabs", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
it("shows the sample new failed findings in the default tab without fetching", () => {
// Given
const firstNewCheck = SAMPLE_CHECKS.find((check) => check.newFailed > 0)!;
// When
render(<SampleGraphsTabs />);
// Then
expect(
screen.getByRole("tab", { name: "New Findings", selected: true }),
).toBeInTheDocument();
expect(screen.getAllByText(firstNewCheck.title).length).toBeGreaterThan(0);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -18,9 +18,16 @@ import type { TabId } from "../graphs-tabs/_config/graphs-tabs-config";
import { RiskPlotClient } from "../graphs-tabs/risk-plot/risk-plot-client";
import { RiskRadarViewClient } from "../graphs-tabs/risk-radar-view/risk-radar-view-client";
// Inert so sample rows and charts never open drill-downs or detail panels.
const Inert = ({ children }: { children: ReactNode }) => (
<div inert className="flex w-full flex-1">
{children}
</div>
);
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
findings: (
<div className="flex w-full flex-1">
<Inert>
<div className="flex w-full flex-col">
<LatestFindingsTable
data={SAMPLE_NEW_FINDINGS}
@@ -37,42 +44,37 @@ const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
}
/>
</div>
</div>
</Inert>
),
"threat-map": (
<div className="flex w-full flex-1">
<Inert>
<div className="w-full flex-1 overflow-hidden">
<ThreatMap
disableNavigation
data={SAMPLE_THREAT_MAP_DATA}
height={460}
/>
<ThreatMap data={SAMPLE_THREAT_MAP_DATA} height={460} />
</div>
</div>
</Inert>
),
"risk-radar": (
<div className="flex w-full flex-1">
<RiskRadarViewClient disableNavigation data={SAMPLE_RADAR_DATA} />
</div>
<Inert>
<RiskRadarViewClient data={SAMPLE_RADAR_DATA} />
</Inert>
),
"risk-pipeline": (
<div className="flex w-full flex-1">
<Inert>
<div className="w-full flex-1 overflow-visible">
<SankeyChart
disableNavigation
data={SAMPLE_SANKEY_DATA}
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
height={460}
/>
</div>
</div>
</Inert>
),
"risk-plot": (
<div className="flex w-full flex-1">
<Inert>
<div className="w-full flex-1 overflow-visible">
<RiskPlotClient disableNavigation data={SAMPLE_RISK_PLOT_POINTS} />
<RiskPlotClient data={SAMPLE_RISK_PLOT_POINTS} />
</div>
</div>
</Inert>
),
};
@@ -20,12 +20,10 @@ import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
data: LineDataPoint[];
readOnly?: boolean;
}
export const FindingSeverityOverTime = ({
data: initialData,
readOnly = false,
}: FindingSeverityOverTimeProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -133,15 +131,13 @@ export const FindingSeverityOverTime = ({
return (
<>
{!readOnly && (
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
)}
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
{error ? (
<div
role="alert"
@@ -156,7 +152,7 @@ export const FindingSeverityOverTime = ({
lines={lines}
height={400}
xAxisInterval={getXAxisInterval()}
onPointClick={readOnly ? undefined : handlePointClick}
onPointClick={handlePointClick}
/>
</div>
)}
@@ -1,5 +1,4 @@
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
@@ -43,8 +42,16 @@ export const FindingSeverityOverTimeSSR = async ({
}
return (
<FindingSeverityOverTimeCard>
<FindingSeverityOverTime data={result.data.data} />
</FindingSeverityOverTimeCard>
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Findings Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={result.data.data} />
</CardContent>
</Card>
);
};
@@ -29,7 +29,6 @@ interface ScanListTableProps {
scans: AttackPathScan[];
/** Overrides the `scanId` search param, e.g. for the sample scan. */
selectedScanId?: string;
readOnly?: boolean;
}
const DEFAULT_PAGE_SIZE = 10;
@@ -103,10 +102,8 @@ const buildMetadata = (
const getColumns = ({
selectedScanId,
readOnly,
}: {
selectedScanId: string | null;
readOnly: boolean;
}): ColumnDef<AttackPathScan>[] => [
{
id: "select",
@@ -120,11 +117,10 @@ const getColumns = ({
<RadioGroupItem
value={row.original.id}
checked={isSelected}
disabled={readOnly || !canSelect}
disabled={!canSelect}
className={cn(
"size-5",
!readOnly &&
canSelect &&
canSelect &&
!isSelected &&
"border-text-neutral-secondary cursor-pointer",
!canSelect && "disabled:opacity-70",
@@ -236,7 +232,6 @@ const getColumns = ({
export const ScanListTable = ({
scans,
selectedScanId: selectedScanIdOverride,
readOnly = false,
}: ScanListTableProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -288,32 +283,23 @@ export const ScanListTable = ({
return (
<RadioGroup
disabled={readOnly}
value={selectedScanId ?? ""}
onValueChange={handleSelectScan}
className="gap-0"
>
<DataTable
columns={getColumns({ selectedScanId, readOnly })}
columns={getColumns({ selectedScanId })}
data={paginatedScans}
metadata={
readOnly
? undefined
: buildMetadata(scans.length, currentPage, totalPages)
}
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={
readOnly
? undefined
: (row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}
}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "@/__tests__/render-browser";
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
import { failedSampleFindings } from "@/lib/sample-preview/sample-records";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
@@ -39,28 +38,18 @@ const SAMPLE_FIXTURE = {
queryResult: SAMPLE_ATTACK_PATHS.result,
};
const [BUCKET_FINDING] = failedSampleFindings("s3_bucket_public_access");
const [INSTANCE_FINDING] = failedSampleFindings("ec2_instance_imdsv2_enabled");
beforeEach(() => {
actions.getFindingById.mockReset();
useGraphStore.getState().reset();
resetLighthouseContextStore();
window.history.replaceState({}, "", "/attack-paths");
});
describe("Attack Paths sample preview", () => {
it("shows the sample graph, opens its findings and keeps it off the API and Lighthouse", async () => {
it("shows the sample graph and keeps it off the API and Lighthouse", async () => {
// Given
const screen = await render(
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />,
);
await render(<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />);
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
await expect
.element(screen.getByRole("radio", { name: "Selected scan" }))
.toBeDisabled();
// When
await vi.waitFor(
() => expect(graph.getNodeById("sample-s3-exports")).not.toBeNull(),
@@ -70,26 +59,20 @@ describe("Attack Paths sample preview", () => {
);
await graph.clickNode("sample-s3-exports");
await vi.waitFor(() =>
expect(graph.getNodeById(BUCKET_FINDING.id)).not.toBeNull(),
);
await graph.clickNode(BUCKET_FINDING.id);
// The finding drawer resolves the sample record through the read action.
await vi.waitFor(() =>
expect(actions.getFindingById).toHaveBeenCalledWith(
BUCKET_FINDING.id,
expect.anything(),
),
expect(graph.getNodeById("sample-finding-public-bucket")).not.toBeNull(),
);
await graph.clickNode("sample-finding-public-bucket");
await graph.executeQuery({ selectFirst: false });
// Then
expect(graph.showsNodeLabel(INSTANCE_FINDING.resource.name)).toBe(true);
expect(graph.showsNodeLabel("bastion-01")).toBe(true);
expect(
graph.showsNodeLabel(SAMPLE_ATTACK_PATHS.query.attributes.name),
).toBe(true);
expect(actions.getAttackPathScans).not.toHaveBeenCalled();
expect(actions.getAvailableQueries).not.toHaveBeenCalled();
expect(actions.executeQuery).not.toHaveBeenCalled();
expect(actions.getFindingById).not.toHaveBeenCalled();
expect(
useLighthouseContextStore.getState().contributions["attack-path-current"],
).toBeUndefined();
@@ -351,6 +351,9 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
// Open finding drawer directly, bypassing the node-details modal.
graphState.enterFilteredView(node.id);
graphState.selectNode(null); // clear so node-details modal doesn't open first
// Sample findings have no API record: focus the path, open no drawer.
if (sample) return;
findingNavigationInFlightRef.current = true;
void handleViewFinding(String(node.properties?.id || node.id));
return;
@@ -496,13 +499,11 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
) : (
<>
{/* The sample scan is the only one: nothing to pick or page. */}
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable
scans={scans}
selectedScanId={sample?.scan.id}
readOnly={Boolean(sample)}
/>
</Suspense>
<div inert={Boolean(sample)}>
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} selectedScanId={sample?.scan.id} />
</Suspense>
</div>
{isViewingPreviousCycleData && (
<StatusAlert
@@ -14,11 +14,9 @@ const {
getComplianceAttributesMock,
getComplianceOverviewMetadataInfoMock,
getComplianceRequirementsMock,
getSamplePreviewStateMock,
getScanMock,
mapComplianceDataMock,
} = vi.hoisted(() => ({
getSamplePreviewStateMock: vi.fn(),
getComplianceAttributesMock: vi.fn(),
getComplianceOverviewMetadataInfoMock: vi.fn(),
getComplianceRequirementsMock: vi.fn(),
@@ -114,14 +112,6 @@ vi.mock("@/lib/shared/env", () => ({
isCloud: () => false,
}));
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState: getSamplePreviewStateMock,
}));
vi.mock("../_components/compliance-detail-sample-preview", () => ({
ComplianceDetailSamplePreview: () => <div>Sample compliance detail</div>,
}));
vi.mock("../_components/cross-account-detail", () => ({
CrossAccountDetail: () => null,
}));
@@ -241,46 +231,3 @@ describe("Compliance detail task response", () => {
).toBeInTheDocument();
});
});
describe("Compliance detail for the sample scan", () => {
const renderSampleDetail = async () =>
ComplianceDetail({
params: Promise.resolve({ compliancetitle: "CIS" }),
searchParams: Promise.resolve({
complianceId: "cis_5.0_aws",
version: "5.0",
scanId: "sample-scan",
}),
});
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the sample detail without asking the API about the scan", async () => {
// Given
getSamplePreviewStateMock.mockResolvedValue({
active: true,
stage: "no_scan",
runningScan: null,
});
// When
render((await renderSampleDetail()) as ReactElement);
// Then
expect(screen.getByText("Sample compliance detail")).toBeInTheDocument();
expect(getScanMock).not.toHaveBeenCalled();
expect(getComplianceRequirementsMock).not.toHaveBeenCalled();
expect(getComplianceOverviewMetadataInfoMock).not.toHaveBeenCalled();
});
it("sends the sample scan back to Compliance once the preview is over", async () => {
// Given
getSamplePreviewStateMock.mockResolvedValue({ active: false });
// When / Then
await expect(renderSampleDetail()).rejects.toThrow("redirect");
expect(getScanMock).not.toHaveBeenCalled();
});
});
@@ -39,8 +39,6 @@ import {
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import type { SearchParamsProps } from "@/types";
@@ -52,7 +50,6 @@ import {
import { isKnownProviderType } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { ComplianceDetailSamplePreview } from "../_components/compliance-detail-sample-preview";
import { CrossAccountDetail } from "../_components/cross-account-detail";
import { CrossProviderDetail } from "../_components/cross-provider-detail";
import { buildSearchParamsKey } from "../_lib/search-params-key";
@@ -81,28 +78,6 @@ export default async function ComplianceDetail({
notFound();
}
// The sample scan never reaches the API: its detail is built from the catalog.
if (scanId && isSampleId(scanId)) {
const preview = await getSamplePreviewState();
if (!preview.active) redirect("/compliance");
return (
<ComplianceDetailSamplePreview
state={preview}
compliancetitle={compliancetitle}
complianceId={complianceId}
version={version}
filter={getSingleSearchParam(
resolvedSearchParams["filter[cis_profile_level]"],
)}
targetSection={section}
region={getSingleSearchParam(
resolvedSearchParams["filter[region__in]"],
)}
/>
);
}
const subscriptionOnlyPromise = isReportDownloadLocked();
// Cross-provider mode replaces the per-scan pipeline with the universal
@@ -1,136 +0,0 @@
import {
ClientAccordionWrapper,
ComplianceHeader,
ComplianceWarming,
RequirementsStatusCard,
TopFailedSectionsCard,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { SAMPLE_SCAN } from "@/lib/sample-preview/sample-compliance";
import {
getSampleComplianceDetail,
SAMPLE_CATALOG_STATUS,
} from "@/lib/sample-preview/sample-compliance-requirements";
import { SAMPLE_REGIONS } from "@/lib/sample-preview/sample-dataset";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import type { Framework, RequirementsTotals } from "@/types/compliance";
interface ComplianceDetailSamplePreviewProps {
state: SamplePreviewActive;
compliancetitle: string;
complianceId: string;
version?: string;
/** CIS profile level, applied by the mapper like on the real page. */
filter?: string;
/** `?section=`: the accordion section to open, like on the real page. */
targetSection?: string;
/** `filter[region__in]`: applied to statuses and requirement findings alike. */
region?: string;
}
/** A sample framework's detail: real catalog, sample statuses, no reports or Lighthouse. */
export async function ComplianceDetailSamplePreview({
state,
compliancetitle,
complianceId,
version,
filter,
targetSection,
region,
}: ComplianceDetailSamplePreviewProps) {
const detail = await getSampleComplianceDetail(complianceId, region);
const formattedTitle = compliancetitle.split("-").join(" ");
const title = version ? `${formattedTitle} - ${version}` : formattedTitle;
// The catalog is still warming: same state as the real page.
if (detail.status === SAMPLE_CATALOG_STATUS.WARMING) {
return (
<ContentLayout title={title}>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<ComplianceWarming />
</ContentLayout>
);
}
// Any other catalog failure renders an empty detail, like the real page.
const ready = detail.status === SAMPLE_CATALOG_STATUS.READY ? detail : null;
const mapper = getComplianceMapper(
ready?.attributesData.data[0]?.attributes.framework,
);
const data: Framework[] = ready
? mapper.mapComplianceData(
ready.attributesData,
{ data: ready.requirements },
filter,
)
: [];
const totals = data.reduce<RequirementsTotals>(
(acc, framework) => ({
pass: acc.pass + framework.pass,
fail: acc.fail + framework.fail,
manual: acc.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
const topFailed = mapper.getTopFailedSections(data);
const accordionItems = mapper.toAccordionItems(data, SAMPLE_SCAN.id);
// Mappers key sections as `${framework.name}-${section}`.
const sectionKeys = new Set(
data.map((framework) => `${framework.name}-${targetSection}`),
);
const initialExpandedKeys = targetSection
? accordionItems
.filter((item) => sectionKeys.has(item.key))
.map((item) => item.key)
.slice(0, 1)
: [];
return (
<ContentLayout title={title}>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<Card variant="base" className="mb-6 w-full gap-4 p-4 md:p-5">
<ComplianceHeader
scans={[]}
uniqueRegions={[...SAMPLE_REGIONS]}
showSearch={false}
framework={compliancetitle}
showProviders={false}
logoPath={getComplianceIcon(compliancetitle)}
complianceTitle={compliancetitle}
selectedScan={SAMPLE_SCAN}
/>
</Card>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCard
pass={totals.pass}
fail={totals.fail}
manual={totals.manual}
/>
<TopFailedSectionsCard
sections={topFailed.items}
dataType={topFailed.type}
prepopulated={topFailed.prepopulated}
/>
</div>
{/* Requirement findings come from the sample records via the sample scan id. */}
<ClientAccordionWrapper
items={accordionItems}
defaultExpandedKeys={initialExpandedKeys}
scrollToKey={initialExpandedKeys[0]}
/>
</div>
</ContentLayout>
);
}
@@ -1,36 +0,0 @@
import { expect, it } from "vitest";
import { render } from "@/__tests__/render-browser";
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
import { ComplianceSamplePreview } from "./compliance-sample-preview";
it("opens sample framework details without offering report exports", async () => {
// Given / When
const screen = await render(
<ComplianceSamplePreview
state={{
active: true,
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
runningScan: null,
}}
/>,
);
// Then: frameworks open their sample detail; reports stay unavailable.
await expect
.element(screen.getByRole("heading", { name: "CIS - 5.0", exact: true }))
.toBeVisible();
await expect
.element(
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
)
.toBeEnabled();
await expect
.element(screen.getByRole("progressbar", { name: "ThreatScore progress" }))
.toHaveAttribute("aria-valuenow", String(SAMPLE_THREAT_SCORE.score));
await expect
.element(screen.getByRole("button", { name: /export/i }))
.not.toBeInTheDocument();
});
@@ -0,0 +1,61 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
import { ComplianceSamplePreview } from "./compliance-sample-preview";
const { fetchMock, getScanMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
getScanMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/compliance",
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
const STATE = {
active: true as const,
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
runningScan: null,
};
describe("ComplianceSamplePreview", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
it("renders the Overview watchlist frameworks as inert cards without fetching", () => {
render(<ComplianceSamplePreview state={STATE} />);
expect(
screen.getByText("You are exploring sample data"),
).toBeInTheDocument();
const cards = screen.getAllByRole("button", {
name: /compliance details$/,
});
expect(cards).toHaveLength(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
);
const cis = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.find(
(item) => item.attributes.compliance_id === "cis_5.0_aws",
)!.attributes;
expect(
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
).toHaveTextContent(
`${cis.requirements_passed} / ${cis.total_requirements}`,
);
expect(cards[0].closest("[inert]")).not.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(getScanMock).not.toHaveBeenCalled();
});
});
@@ -9,20 +9,16 @@ import {
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import type { ComplianceOverviewData } from "@/types/compliance";
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
interface ComplianceSamplePreviewProps {
state: SamplePreviewActive;
/** Totals derived from the same requirements the detail page shows. */
overviews?: ComplianceOverviewData[];
}
/** Sample per-scan compliance: cards open the sample detail; no reports. */
/** Sample per-scan compliance: the Overview watchlist frameworks, inert. */
export function ComplianceSamplePreview({
state,
overviews = SAMPLE_COMPLIANCE_OVERVIEWS,
}: ComplianceSamplePreviewProps) {
return (
<>
@@ -30,37 +26,37 @@ export function ComplianceSamplePreview({
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<div className="mb-6">
<ThreatScoreBadge
readOnly
score={SAMPLE_THREAT_SCORE.score}
scanId={SAMPLE_SCAN.id}
provider={SAMPLE_SCAN.providerInfo.provider}
selectedScan={SAMPLE_SCAN}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
/>
<div inert>
<div className="mb-6">
<ThreatScoreBadge
score={SAMPLE_THREAT_SCORE.score}
scanId={SAMPLE_SCAN.id}
provider={SAMPLE_SCAN.providerInfo.provider}
selectedScan={SAMPLE_SCAN}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
/>
</div>
<ComplianceOverviewPanel>
<ComplianceFrameworkGrid>
{SAMPLE_COMPLIANCE_OVERVIEWS.map(({ id, attributes }) => (
<div key={id} className="h-full [&>*]:h-full">
<ComplianceCard
title={attributes.framework}
version={attributes.version}
passingRequirements={attributes.requirements_passed}
totalRequirements={attributes.total_requirements}
prevPassingRequirements={attributes.requirements_passed}
prevTotalRequirements={attributes.total_requirements}
scanId={SAMPLE_SCAN.id}
complianceId={id}
id={id}
selectedScan={SAMPLE_SCAN}
/>
</div>
))}
</ComplianceFrameworkGrid>
</ComplianceOverviewPanel>
</div>
<ComplianceOverviewPanel>
<ComplianceFrameworkGrid>
{overviews.map(({ id, attributes }) => (
<div key={id} className="h-full [&>*]:h-full">
<ComplianceCard
hideActions
title={attributes.framework}
version={attributes.version}
passingRequirements={attributes.requirements_passed}
totalRequirements={attributes.total_requirements}
prevPassingRequirements={attributes.requirements_passed}
prevTotalRequirements={attributes.total_requirements}
scanId={SAMPLE_SCAN.id}
complianceId={id}
id={id}
selectedScan={SAMPLE_SCAN}
/>
</div>
))}
</ComplianceFrameworkGrid>
</ComplianceOverviewPanel>
</>
);
}
@@ -65,10 +65,6 @@ vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState: getSamplePreviewStateMock,
}));
vi.mock("@/lib/sample-preview/sample-compliance-requirements", () => ({
getSampleComplianceTotals: async () => [],
}));
vi.mock("./_components/compliance-sample-preview", () => ({
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
}));
+1 -8
View File
@@ -21,8 +21,6 @@ import { ContentLayout } from "@/components/shadcn/content-layout";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { toSampleComplianceOverviews } from "@/lib/sample-preview/sample-compliance";
import { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
@@ -80,12 +78,7 @@ export default async function Compliance({
if (preview.active) {
return (
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
<ComplianceSamplePreview
state={preview}
overviews={toSampleComplianceOverviews(
await getSampleComplianceTotals(),
)}
/>
<ComplianceSamplePreview state={preview} />
</ContentLayout>
);
}
@@ -8,18 +8,15 @@ import { SAMPLE_FINDINGS } from "@/lib/sample-preview/sample-records";
import { FindingsSamplePreview } from "./findings-sample-preview";
const { fetchMock, getScanMock, resolveFindingIdsMock, searchParams } =
vi.hoisted(() => ({
// One instance, like Next's hook: a new one per render loops the filter bar.
searchParams: new URLSearchParams(),
fetchMock: vi.fn(),
getScanMock: vi.fn(),
resolveFindingIdsMock: vi.fn(),
}));
const { fetchMock, getScanMock, resolveFindingIdsMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
getScanMock: vi.fn(),
resolveFindingIdsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
useSearchParams: () => searchParams,
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/findings",
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
@@ -40,8 +37,6 @@ const STATE = {
describe("FindingsSamplePreview", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
// jsdom has no scrollIntoView; the filter bar's command lists call it.
Element.prototype.scrollIntoView = vi.fn();
// jsdom has no IntersectionObserver; the drill-down's infinite scroll needs one.
vi.stubGlobal(
"IntersectionObserver",
@@ -119,7 +114,7 @@ describe("FindingsSamplePreview", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("applies the severity an Overview link carried", () => {
it("applies the severity an Overview link carried, with a way back to all", () => {
render(
<FindingsSamplePreview
state={STATE}
@@ -140,17 +135,9 @@ describe("FindingsSamplePreview", () => {
expect(screen.getByText(check.title)).toBeInTheDocument();
}
expect(screen.queryByText(high.title)).not.toBeInTheDocument();
});
it("offers the real filter bar without fetching its options", () => {
render(<FindingsSamplePreview state={STATE} />);
expect(
screen.getByRole("combobox", { name: /^Filter by Provider\./ }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "More Filters" }),
).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.getByRole("link", { name: "Show all" })).toHaveAttribute(
"href",
"/findings",
);
});
});
@@ -1,13 +1,10 @@
import { FindingsFilters } from "@/components/findings/findings-filters";
import Link from "next/link";
import { FindingsGroupTable } from "@/components/findings/table";
import { Button } from "@/components/shadcn";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { FilterTransitionWrapper } from "@/contexts";
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
import {
filterSampleFindingGroups,
SAMPLE_FILTER_PROVIDER,
SAMPLE_FINDING_FILTER_OPTIONS,
} from "@/lib/sample-preview/sample-findings";
import { filterSampleFindingGroups } from "@/lib/sample-preview/sample-findings";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import type { SearchParamsProps } from "@/types/components";
@@ -20,43 +17,36 @@ const SAMPLE_SCAN_FILTERS: Record<string, string> = {
interface FindingsSamplePreviewProps {
state: SamplePreviewActive;
/** The filter bar, Overview drill-downs and the table's search and sorting. */
/** Overview drill-downs and the table's search and sorting controls. */
searchParams?: SearchParamsProps;
}
/** Sample finding groups with the real filter bar; options come from the records. */
/** Sample finding groups: no filter controls, no metadata (so no Lighthouse context). */
export function FindingsSamplePreview({
state,
searchParams = {},
}: FindingsSamplePreviewProps) {
const { groups, resourceFilters } = filterSampleFindingGroups(searchParams);
const { groups, filtered, resourceFilters } =
filterSampleFindingGroups(searchParams);
return (
<>
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
<FilterTransitionWrapper>
<div className="mb-6">
{/* No scans or groups: only the sample account and its own values. */}
<FindingsFilters
providers={[SAMPLE_FILTER_PROVIDER]}
providerGroups={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={SAMPLE_FINDING_FILTER_OPTIONS.regions}
uniqueServices={SAMPLE_FINDING_FILTER_OPTIONS.services}
uniqueResourceTypes={SAMPLE_FINDING_FILTER_OPTIONS.resourceTypes}
uniqueCategories={SAMPLE_FINDING_FILTER_OPTIONS.categories}
uniqueGroups={SAMPLE_FINDING_FILTER_OPTIONS.groups}
/>
</div>
<FindingsGroupTable
// Key resets client state (selection, drill-down) when data or filters change.
key={`${groups.map((group) => group.id).join(",")}|${JSON.stringify(resourceFilters)}`}
data={groups}
resolvedFilters={{ ...SAMPLE_SCAN_FILTERS, ...resourceFilters }}
hasHistoricalData
/>
</FilterTransitionWrapper>
{filtered && (
<p className="text-text-neutral-tertiary mb-4 text-sm">
Showing {groups.length} sample checks matching your filters.{" "}
<Button variant="link" size="link-sm" asChild>
<Link href="/findings">Show all</Link>
</Button>
</p>
)}
<FindingsGroupTable
// Key resets client state (selection, drill-down) when data changes.
key={groups.map((group) => group.id).join(",")}
data={groups}
resolvedFilters={{ ...SAMPLE_SCAN_FILTERS, ...resourceFilters }}
hasHistoricalData
/>
</>
);
}
@@ -1,34 +0,0 @@
import { expect, it, vi } from "vitest";
import { render } from "@/__tests__/render-browser";
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
import { IssuesList } from "./issues-list";
it("opens a sample issue and its findings without fetching tenant data", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const issue = SAMPLE_ISSUES[0];
const content = await IssuesList({ searchParams: {}, sample: true });
const screen = await render(
<>
{content}
<GlobalSidePanel />
</>,
);
await screen
.getByRole("button", { name: issue.attributes.title, exact: true })
.click();
await screen.getByRole("tab", { name: "Findings", exact: true }).click();
await expect
.element(
screen.getByRole("button", {
name: "Ensure S3 buckets are not publicly accessible",
exact: true,
}),
)
.toBeVisible();
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -52,7 +52,7 @@ describe("IssuesList sample preview", () => {
);
});
it("filters sample issues by verdict without calling the API", async () => {
it("renders the sample issues clickable, filtered by verdict, without the API", async () => {
// When
render(
await IssuesList({
@@ -62,11 +62,10 @@ describe("IssuesList sample preview", () => {
);
// Then
expect(
screen.getByRole("button", {
name: exploitable.attributes.title,
}),
).toBeVisible();
const title = screen.getByRole("button", {
name: exploitable.attributes.title,
});
expect(title.closest("[inert]")).toBeNull();
expect(
screen.queryByText(notExploitable.attributes.title),
).not.toBeInTheDocument();
+1 -5
View File
@@ -16,7 +16,6 @@ import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-p
import { DOCS_URLS } from "@/lib/external-urls";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -88,10 +87,7 @@ export default async function Home({
state={samplePreview}
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
/>
<OverviewSamplePreview
showIssues={isCloud()}
complianceWatchlist={await getSampleComplianceTotals()}
/>
<OverviewSamplePreview showIssues={isCloud()} />
</ContentLayout>
);
}
@@ -1,55 +0,0 @@
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { ResourcesTableWithSelection } from "@/components/resources/table";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { FilterTransitionWrapper } from "@/contexts";
import { SAMPLE_FILTER_PROVIDER } from "@/lib/sample-preview/sample-findings";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import {
filterSampleResources,
getSampleResourceRow,
SAMPLE_RESOURCE_FILTER_OPTIONS,
} from "@/lib/sample-preview/sample-resources";
import type { SearchParamsProps } from "@/types";
interface ResourcesSamplePreviewProps {
state: SamplePreviewActive;
searchParams: SearchParamsProps;
}
/** Sample resources with the real filter bar, table and resource drawer. */
export function ResourcesSamplePreview({
state,
searchParams,
}: ResourcesSamplePreviewProps) {
const { data, metadata } = filterSampleResources(searchParams);
return (
<>
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.RESOURCES} />
<FilterTransitionWrapper>
<div className="mb-6">
<ResourcesFilters
providers={[SAMPLE_FILTER_PROVIDER]}
providerGroups={[]}
uniqueRegions={SAMPLE_RESOURCE_FILTER_OPTIONS.regions}
uniqueServices={SAMPLE_RESOURCE_FILTER_OPTIONS.services}
uniqueResourceTypes={SAMPLE_RESOURCE_FILTER_OPTIONS.types}
uniqueGroups={SAMPLE_RESOURCE_FILTER_OPTIONS.groups}
/>
</div>
<ResourcesTableWithSelection
// The drawer reads initialResource on mount: remount on a new link.
key={searchParams.resourceId?.toString() ?? "none"}
data={data}
metadata={metadata}
// A copied resource link reopens its drawer, as on the real page.
initialResource={getSampleResourceRow(
searchParams.resourceId?.toString(),
)}
contributeLighthouseContext={false}
/>
</FilterTransitionWrapper>
</>
);
}
-17
View File
@@ -22,31 +22,14 @@ import {
hasDateOrScanFilter,
replaceFieldKey,
} from "@/lib";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { ResourceProps, SearchParamsProps } from "@/types";
import { ResourcesSamplePreview } from "./_components/resources-sample-preview";
export default async function Resources({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
// No completed scan yet: the demo account stands in for the tenant's resources.
const preview = await getSamplePreviewState();
if (preview.active) {
return (
<ContentLayout title="Resources" icon={<Warehouse />}>
<ResourcesSamplePreview
state={preview}
searchParams={resolvedSearchParams}
/>
</ContentLayout>
);
}
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
+22 -27
View File
@@ -52,8 +52,6 @@ interface ComplianceCardProps {
watchlistAction?: ReactNode;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
/** Hides report downloads and watchlist changes, e.g. for sample scans. */
hideActions?: boolean;
}
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
@@ -67,7 +65,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
isLatestCisForProvider = false,
watchlistAction,
subscriptionOnly = false,
hideActions = false,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -162,30 +159,28 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
</div>
</CardContent>
</button>
{!hideActions && (
<CardAction
className="absolute top-2 right-2 z-10 flex items-center gap-1"
role="group"
aria-label="Compliance actions"
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
subscriptionOnly={subscriptionOnly}
/>
{watchlistAction}
</CardAction>
)}
<CardAction
className="absolute top-2 right-2 z-10 flex items-center gap-1"
role="group"
aria-label="Compliance actions"
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
subscriptionOnly={subscriptionOnly}
/>
{watchlistAction}
</CardAction>
</Card>
);
};
+41 -52
View File
@@ -38,8 +38,6 @@ interface ThreatScoreBadgeProps {
sectionScores?: SectionScores;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
/** Display scores without detail navigation or report downloads. */
readOnly?: boolean;
}
export const ThreatScoreBadge = ({
@@ -48,9 +46,7 @@ export const ThreatScoreBadge = ({
provider,
sectionScores,
subscriptionOnly = false,
readOnly = false,
}: ThreatScoreBadgeProps) => {
const DetailContainer = readOnly ? "div" : "button";
const router = useRouter();
const runReportDownload = useReportDownload(subscriptionOnly);
const searchParams = useSearchParams();
@@ -121,11 +117,11 @@ export const ThreatScoreBadge = ({
return (
<Card variant="base" padding="md" className="relative gap-4">
<CardContent className="flex flex-col gap-4 p-0 pr-14 lg:flex-row lg:items-start lg:gap-6">
{/* ThreatScore summary, with detail navigation when available. */}
<DetailContainer
className="border-border-neutral-secondary bg-bg-neutral-tertiary enabled:hover:border-border-neutral-primary enabled:hover:bg-bg-neutral-secondary flex shrink-0 flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors enabled:cursor-pointer lg:pr-3"
onClick={readOnly ? undefined : handleCardClick}
type={readOnly ? undefined : "button"}
{/* Clickable ThreatScore button */}
<button
className="border-border-neutral-secondary bg-bg-neutral-tertiary hover:border-border-neutral-primary hover:bg-bg-neutral-secondary flex shrink-0 cursor-pointer flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors lg:pr-3"
onClick={handleCardClick}
type="button"
>
<ThreatScoreLogo />
@@ -140,29 +136,24 @@ export const ThreatScoreBadge = ({
indicatorClassName={getScoreIndicatorClass(getScoreColor(score))}
/>
</div>
</DetailContainer>
</button>
{/* Pillar breakdown — always visible, in canonical order */}
{pillars.length > 0 && (
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
{pillars.map(({ name, score: sectionScore, hasData }) => (
<DetailContainer
<button
key={name}
type={readOnly ? undefined : "button"}
onClick={
readOnly
? undefined
: () => hasData && handlePillarClick(name)
}
disabled={readOnly ? undefined : !hasData}
type="button"
onClick={() => hasData && handlePillarClick(name)}
disabled={!hasData}
aria-disabled={!hasData}
aria-label={
readOnly
? undefined
: hasData
? `Open ${name} details`
: `${name} (no data for this scan)`
hasData
? `Open ${name} details`
: `${name} (no data for this scan)`
}
className="enabled:hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
className="hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
>
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
{name}
@@ -184,40 +175,38 @@ export const ThreatScoreBadge = ({
>
{hasData ? `${sectionScore.toFixed(1)}%` : "—"}
</span>
</DetailContainer>
</button>
))}
</div>
)}
</CardContent>
{/* ActionDropdown for downloads — top-right */}
{!readOnly && (
<div className="absolute top-3 right-4">
<ActionDropdown
variant="bordered"
ariaLabel="Open compliance export actions"
>
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
/>
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
/>
</ActionDropdown>
</div>
)}
<div className="absolute top-3 right-4">
<ActionDropdown
variant="bordered"
ariaLabel="Open compliance export actions"
>
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
/>
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
/>
</ActionDropdown>
</div>
</Card>
);
};
@@ -55,7 +55,15 @@ describe("RecheckResourceIconButton", () => {
expect(onRowClick).not.toHaveBeenCalled();
});
it("disables sample re-checks without opening the confirmation or triggering the row", async () => {
it("is always green and pulsing", () => {
render(<RecheckResourceIconButton target={target} />);
const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL });
expect(button.className).toContain("text-button-primary");
expect(button.className).toContain("animate-pulse");
});
it("blocks sample re-checks and removes the pulse", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(
@@ -67,7 +75,8 @@ describe("RecheckResourceIconButton", () => {
</div>,
);
const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button.className).not.toContain("animate-pulse");
await user.click(button);
expect(usePartialScanStore.getState().activeTarget).toBeNull();
expect(onRowClick).not.toHaveBeenCalled();
@@ -10,6 +10,7 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { usePartialScanTarget } from "@/hooks/use-partial-scan-target";
import { cn } from "@/lib/utils";
import { usePartialScanStore } from "@/store";
import type { PartialScanTarget } from "@/types/partial-scans";
@@ -21,7 +22,8 @@ interface RecheckResourceIconButtonProps {
disabledReason?: string;
}
// Beside "last seen", using the shared icon-button and disabled states.
// Beside "last seen": green and pulsing when a re-check is available.
// It is an action, not a notification, so it never settles into a seen state.
export function RecheckResourceIconButton({
target,
className,
@@ -42,22 +44,23 @@ export function RecheckResourceIconButton({
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={className}
tabIndex={disabledReason ? 0 : undefined}
onClick={(event) => event.stopPropagation()}
<Button
type="button"
variant="bare"
size="icon"
aria-label={RECHECK_RESOURCE_LABEL}
aria-disabled={Boolean(disabledReason)}
onClick={handleClick}
className={cn(
"size-5 shrink-0 rounded-md",
disabledReason
? "text-text-neutral-tertiary cursor-not-allowed"
: "text-button-primary hover:text-button-primary active:text-button-primary hover:[animation-play-state:paused] focus-visible:[animation-play-state:paused] motion-safe:animate-pulse",
className,
)}
>
<Button
type="button"
variant="bare"
size="icon-xs"
aria-label={RECHECK_RESOURCE_LABEL}
disabled={Boolean(disabledReason)}
onClick={handleClick}
>
<RefreshCw aria-hidden />
</Button>
</span>
<RefreshCw className="size-3.5" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>
{disabledReason ?? RECHECK_RESOURCE_LABEL}
+3 -5
View File
@@ -289,11 +289,9 @@ export function LineChart({
</ChartContainer>
<div className="mt-4 flex flex-col items-start gap-2">
{onPointClick && (
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity
</p>
)}
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity
</p>
<ChartLegend
items={legendItems}
selectedItem={selectedLine}
+4 -10
View File
@@ -43,7 +43,6 @@ interface ZeroDataProvider {
}
interface SankeyChartProps {
disableNavigation?: boolean;
data: {
nodes: SankeyNode[];
links: SankeyLink[];
@@ -169,8 +168,7 @@ export const CustomNode = ({
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
const isClickable =
Boolean(onNodeClick) && SEVERITY_FILTER_MAP[nodeName] !== undefined;
const isClickable = SEVERITY_FILTER_MAP[nodeName] !== undefined;
const handleMouseEnter = (e: React.MouseEvent) => {
if (!hasTooltip) return;
@@ -389,10 +387,7 @@ const CustomLink = ({
fill={color}
fillOpacity={getOpacity()}
stroke="none"
style={{
cursor: onLinkClick ? "pointer" : "default",
transition: "fill-opacity 0.2s",
}}
style={{ cursor: "pointer", transition: "fill-opacity 0.2s" }}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
@@ -406,7 +401,6 @@ export function SankeyChart({
data,
zeroDataProviders = [],
height = 400,
disableNavigation = false,
}: SankeyChartProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -516,7 +510,7 @@ export function SankeyChart({
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
onNodeClick={disableNavigation ? undefined : handleNodeClick}
onNodeClick={handleNodeClick}
/>
);
@@ -538,7 +532,7 @@ export function SankeyChart({
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
onLinkClick={disableNavigation ? undefined : handleLinkClick}
onLinkClick={handleLinkClick}
/>
);
+17 -26
View File
@@ -79,7 +79,7 @@ function LocationDetails({
onBarClick,
}: {
location: Pick<LocationPoint, "name" | "totalFindings" | "severityData">;
onBarClick?: (dataPoint: BarDataPoint) => void;
onBarClick: (dataPoint: BarDataPoint) => void;
}) {
return (
<div className="flex w-full flex-col">
@@ -109,7 +109,6 @@ function LocationDetails({
export function ThreatMap({
data,
height = MAP_CONFIG.defaultHeight,
disableNavigation = false,
}: ThreatMapProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -490,34 +489,26 @@ export function ThreatMap({
{selectedLocation ? (
<LocationDetails
location={selectedLocation}
onBarClick={
disableNavigation
? undefined
: (dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status && selectedLocation.providerType) {
navigateToFindings(
status,
selectedLocation.regionCode,
selectedLocation.providerType,
);
}
}
}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status && selectedLocation.providerType) {
navigateToFindings(
status,
selectedLocation.regionCode,
selectedLocation.providerType,
);
}
}}
/>
) : isGlobalSelected && globalAggregatedData ? (
<LocationDetails
location={globalAggregatedData}
onBarClick={
disableNavigation
? undefined
: (dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status) {
navigateToFindings(status, "global");
}
}
}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status) {
navigateToFindings(status, "global");
}
}}
/>
) : (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
-1
View File
@@ -36,7 +36,6 @@ export interface ThreatMapData {
}
export interface ThreatMapProps {
disableNavigation?: boolean;
data: ThreatMapData;
height?: number;
onLocationSelect?: (location: LocationPoint | null) => void;
@@ -1,22 +0,0 @@
import type { ReactNode } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
export function FindingSeverityOverTimeCard({
children,
}: {
children: ReactNode;
}) {
return (
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Findings Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
{children}
</CardContent>
</Card>
);
}
@@ -14,15 +14,12 @@ interface ResourcesTableWithSelectionProps {
data: ResourceProps[];
metadata?: MetaDataProps;
initialResource?: ResourceProps | null;
/** Sample rows must not reach Lighthouse, not even as a count. */
contributeLighthouseContext?: boolean;
}
export function ResourcesTableWithSelection({
data,
metadata,
initialResource = null,
contributeLighthouseContext = true,
}: ResourcesTableWithSelectionProps) {
const safeData = data ?? [];
@@ -39,14 +36,13 @@ export function ResourcesTableWithSelection({
return (
<>
{contributeLighthouseContext &&
metadata?.pagination.count !== undefined && (
<LighthouseContextContributor
key={`resources-summary-${metadata.pagination.count}`}
contributorId="resources-summary"
item={buildResourceSummaryContext(metadata.pagination.count)}
/>
)}
{metadata?.pagination.count !== undefined && (
<LighthouseContextContributor
key={`resources-summary-${metadata.pagination.count}`}
contributorId="resources-summary"
item={buildResourceSummaryContext(metadata.pagination.count)}
/>
)}
<DataTable
columns={columns}
data={safeData}
@@ -14,16 +14,12 @@ import {
SamplePreviewBanner,
} from "./sample-preview-banner";
const { getScanMock, getCompletedScansMock, refreshMock } = vi.hoisted(() => ({
const { getScanMock, refreshMock } = vi.hoisted(() => ({
getScanMock: vi.fn(),
getCompletedScansMock: vi.fn(),
refreshMock: vi.fn(),
}));
vi.mock("@/actions/scans", () => ({
getScan: getScanMock,
getScansByState: getCompletedScansMock,
}));
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock }),
}));
@@ -46,7 +42,6 @@ const advancePoll = () =>
describe("SamplePreviewBanner", () => {
beforeEach(() => {
vi.useFakeTimers();
getCompletedScansMock.mockResolvedValue({ data: [] });
});
afterEach(() => {
@@ -113,26 +108,6 @@ describe("SamplePreviewBanner", () => {
window.removeEventListener(SAMPLE_PREVIEW_EVENT, listener);
});
it("exits when another scan completes before the tracked scan", async () => {
// Given: the newest scan is still running, but an earlier scan finished.
getScanMock.mockResolvedValue(scanResponse("executing", 30));
getCompletedScansMock.mockResolvedValue({ data: [{ id: "earlier-scan" }] });
render(
<SamplePreviewBanner
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
state={RUNNING}
/>,
);
// When
await advancePoll();
await advancePoll();
// Then: refresh once and stop polling the unfinished scan.
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(getScanMock).toHaveBeenCalledTimes(1);
});
it("re-resolves the page when the tracked scan is gone", async () => {
getScanMock.mockResolvedValue({ error: "Not found", status: 404 });
render(
@@ -242,26 +217,6 @@ describe("SamplePreviewBanner", () => {
expect(screen.getByText("70%")).toBeVisible();
});
it("keeps progress updates and completion working when the completed-scan lookup fails", async () => {
getCompletedScansMock.mockRejectedValue(new Error("Network error"));
getScanMock
.mockResolvedValueOnce(scanResponse("executing", 70))
.mockResolvedValue(scanResponse("completed", 100));
render(
<SamplePreviewBanner
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
state={RUNNING}
/>,
);
await advancePoll();
expect(screen.getByText("70%")).toBeVisible();
expect(refreshMock).not.toHaveBeenCalled();
await advancePoll();
expect(refreshMock).toHaveBeenCalledTimes(1);
});
it("tells a running scan apart and opens the in-progress scans tab", () => {
getScanMock.mockReturnValue(new Promise(() => {}));
render(
@@ -278,26 +233,4 @@ describe("SamplePreviewBanner", () => {
screen.getByRole("link", { name: "View Running Scans" }),
).toHaveAttribute("href", "/scans?tab=active");
});
it("updates the tracked scan without waiting for the completed-scan lookup", async () => {
// Given: the completed-scan lookup never answers.
getCompletedScansMock.mockReturnValue(new Promise(() => {}));
getScanMock.mockResolvedValue(scanResponse("executing", 45));
render(
<SamplePreviewBanner
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
state={RUNNING}
/>,
);
// When
await advancePoll();
getScanMock.mockResolvedValue(scanResponse("executing", 60));
await advancePoll();
// Then: later polls keep tracking the scan while the lookup is pending.
expect(screen.getByText("60%")).toBeVisible();
expect(getScanMock).toHaveBeenCalledTimes(2);
expect(getCompletedScansMock).toHaveBeenCalledTimes(1);
});
});
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getScan, getScansByState } from "@/actions/scans";
import { getScan } from "@/actions/scans";
import {
Badge,
Button,
@@ -100,58 +100,50 @@ function SamplePreviewBannerContent({ state, page }: SamplePreviewBannerProps) {
let stopped = false;
let inFlight = false;
// Its own flag: a slow lookup must not hold back the tracked scan's polls.
let lookupInFlight = false;
const exit = () => {
stop();
dispatchSamplePreview({ step: SAMPLE_PREVIEW_STEP.EXITED, page, stage });
toast({
title: "Your scan results are ready",
description: "The sample data has been replaced with your own.",
});
router.refresh();
};
const poll = async () => {
// A slow response must not overwrite a newer one.
if (inFlight) return;
inFlight = true;
try {
// A failed request is a transient empty response: keep polling.
const response = await getScan(runningScan.id).catch(() => undefined);
if (stopped) return;
// A failed request is a transient empty response: keep polling.
const response = await getScan(runningScan.id)
.catch(() => undefined)
.finally(() => {
inFlight = false;
});
if (stopped) return;
const attributes = response?.data?.attributes;
if (attributes?.state === SCAN_STATES.COMPLETED) {
exit();
return;
}
// Failed/deleted scans need a new stage (or a different running scan).
if (
response?.status === 404 ||
attributes?.state === SCAN_STATES.FAILED
) {
stop();
router.refresh();
return;
}
if (attributes) setProgress(attributes.progress ?? 0);
// Another provider's scan may finish before the one whose progress we show.
if (!lookupInFlight) {
lookupInFlight = true;
void getScansByState()
.catch(() => undefined)
.then((completedScans) => {
lookupInFlight = false;
if (!stopped && completedScans?.data?.length > 0) exit();
});
}
} finally {
inFlight = false;
// The scan is gone (e.g. its provider was deleted): re-resolve the page.
if (response?.status === 404) {
stop();
router.refresh();
return;
}
const attributes = response?.data?.attributes;
if (!attributes) return;
if (attributes.state === SCAN_STATES.COMPLETED) {
stop();
dispatchSamplePreview({
step: SAMPLE_PREVIEW_STEP.EXITED,
page,
stage,
});
toast({
title: "Your scan results are ready",
description: "The sample data has been replaced with your own.",
});
router.refresh();
return;
}
if (attributes.state === SCAN_STATES.FAILED) {
stop();
router.refresh();
return;
}
setProgress(attributes.progress ?? 0);
};
const intervalId = setInterval(poll, SAMPLE_PREVIEW_POLL_INTERVAL_MS);
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { SAMPLE_ATTACK_PATHS } from "./sample-attack-paths";
import { getSampleFinding } from "./sample-records";
const { nodes, relationships = [] } = SAMPLE_ATTACK_PATHS.result;
const findingNodes = nodes.filter((node) =>
node.labels.includes("ProwlerFinding"),
);
describe("sample attack paths", () => {
it("opens a sample finding record from every finding node", () => {
expect(findingNodes.length).toBeGreaterThan(0);
for (const node of findingNodes) {
expect(getSampleFinding(node.id)?.status).toBe("FAIL");
}
});
it("hangs each finding off the resource that record names", () => {
for (const node of findingNodes) {
const edge = relationships.find((rel) => rel.target === node.id);
const resource = nodes.find((candidate) => candidate.id === edge?.source);
expect(resource?.properties.name).toBe(
getSampleFinding(node.id)?.resource.name,
);
}
});
});
+53 -49
View File
@@ -6,8 +6,7 @@ import type {
QueryResultAttributes,
} from "@/types/attack-paths";
import { SAMPLE_ACCOUNT } from "./sample-dataset";
import { failedSampleFindings, type SampleFinding } from "./sample-records";
import { SAMPLE_ACCOUNT, SAMPLE_CHECKS } from "./sample-dataset";
export interface SampleAttackPaths {
scan: AttackPathScan;
@@ -15,48 +14,35 @@ export interface SampleAttackPaths {
result: QueryResultAttributes;
}
const SAMPLE_REGION = "us-east-1";
const SAMPLE_SCAN_COMPLETED_AT = SAMPLE_ACCOUNT.scannedAt;
const SAMPLE_SCAN_STARTED_AT = "2026-10-28T09:04:00Z";
const arn = (service: string, resource: string, region = SAMPLE_REGION) =>
`arn:aws:${service}:${region}:${SAMPLE_ACCOUNT.uid}:${resource}`;
const resource = (
id: string,
label: string,
properties: GraphNode["properties"],
): GraphNode => ({ id, labels: [label], properties: { id, ...properties } });
// Each finding node is a sample record, so the finding drawer opens it.
const failingRecord = (checkId: string) => {
const [record] = failedSampleFindings(checkId);
if (!record) throw new Error(`No failing sample finding for ${checkId}`);
return record;
// Finding titles and severities come from SAMPLE_CHECKS, like the other pages.
const finding = (id: string, checkId: string): GraphNode => {
const check = SAMPLE_CHECKS.find((sample) => sample.checkId === checkId);
return {
id,
labels: ["ProwlerFinding"],
properties: {
id,
check_id: checkId,
check_title: check?.title ?? checkId,
severity: check?.severity ?? "high",
status: "FAIL",
},
};
};
const SSH_FINDING = failingRecord(
"ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
);
const IMDSV2_FINDING = failingRecord("ec2_instance_imdsv2_enabled");
const PRIVESC_FINDING = failingRecord("iam_policy_allows_privilege_escalation");
const PUBLIC_BUCKET_FINDING = failingRecord("s3_bucket_public_access");
const finding = (record: SampleFinding): GraphNode => ({
id: record.id,
labels: ["ProwlerFinding"],
properties: {
id: record.id,
check_id: record.check.checkId,
check_title: record.check.title,
severity: record.check.severity,
status: "FAIL",
},
});
// Resource nodes reuse the records' names and ARNs, so graph and drawer agree.
const recordProperties = ({ resource }: SampleFinding) => ({
name: resource.name,
arn: resource.uid,
region: resource.region,
});
const rel = (
id: string,
source: string,
@@ -72,31 +58,40 @@ const SAMPLE_NODES: GraphNode[] = [
properties: { id: "internet", name: "Internet" },
},
resource("sample-sg-bastion", "EC2SecurityGroup", {
...recordProperties(SSH_FINDING),
groupid: SSH_FINDING.resource.name,
name: "bastion-ssh",
groupid: "sg-0a1b2c3d4e5f60718",
arn: arn("ec2", "security-group/sg-0a1b2c3d4e5f60718"),
region: SAMPLE_REGION,
}),
resource("sample-ec2-bastion", "EC2Instance", {
...recordProperties(IMDSV2_FINDING),
instanceid: IMDSV2_FINDING.resource.name,
name: "bastion-01",
instanceid: "i-0f1e2d3c4b5a69788",
arn: arn("ec2", "instance/i-0f1e2d3c4b5a69788"),
exposed_internet: true,
publicipaddress: "203.0.113.24",
region: SAMPLE_REGION,
}),
resource("sample-role-admin", "AWSRole", {
name: "app-admin-role",
arn: `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:role/app-admin-role`,
}),
resource("sample-policy-admin", "AWSPolicy", {
name: PRIVESC_FINDING.resource.name,
arn: PRIVESC_FINDING.resource.uid,
name: "app-admin-inline",
arn: `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:policy/app-admin-inline`,
}),
resource("sample-s3-exports", "S3Bucket", {
...recordProperties(PUBLIC_BUCKET_FINDING),
name: "acme-customer-exports",
arn: "arn:aws:s3:::acme-customer-exports",
region: SAMPLE_REGION,
anonymous_access: true,
}),
finding(SSH_FINDING),
finding(IMDSV2_FINDING),
finding(PRIVESC_FINDING),
finding(PUBLIC_BUCKET_FINDING),
finding(
"sample-finding-ssh",
"ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
),
finding("sample-finding-imdsv2", "ec2_instance_imdsv2_enabled"),
finding("sample-finding-privesc", "iam_policy_allows_privilege_escalation"),
finding("sample-finding-public-bucket", "s3_bucket_public_access"),
];
const SAMPLE_RELATIONSHIPS: GraphRelationship[] = [
@@ -111,14 +106,23 @@ const SAMPLE_RELATIONSHIPS: GraphRelationship[] = [
),
rel("sample-r4", "sample-role-admin", "sample-policy-admin", "POLICY"),
rel("sample-r5", "sample-policy-admin", "sample-s3-exports", "CAN_ACCESS"),
rel("sample-r6", "sample-sg-bastion", SSH_FINDING.id, "HAS_FINDING"),
rel("sample-r7", "sample-ec2-bastion", IMDSV2_FINDING.id, "HAS_FINDING"),
// The record's failing resource is the policy the role carries.
rel("sample-r8", "sample-policy-admin", PRIVESC_FINDING.id, "HAS_FINDING"),
rel("sample-r6", "sample-sg-bastion", "sample-finding-ssh", "HAS_FINDING"),
rel(
"sample-r7",
"sample-ec2-bastion",
"sample-finding-imdsv2",
"HAS_FINDING",
),
rel(
"sample-r8",
"sample-role-admin",
"sample-finding-privesc",
"HAS_FINDING",
),
rel(
"sample-r9",
"sample-s3-exports",
PUBLIC_BUCKET_FINDING.id,
"sample-finding-public-bucket",
"HAS_FINDING",
),
];
@@ -1,180 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getComplianceAttributesMock } = vi.hoisted(() => ({
getComplianceAttributesMock: vi.fn(),
}));
vi.mock("@/actions/compliances", () => ({
getComplianceAttributes: getComplianceAttributesMock,
}));
import { REQUIREMENT_STATUS } from "@/types/compliance";
import {
getSampleComplianceDetail,
getSampleComplianceTotals,
SAMPLE_CATALOG_STATUS,
} from "./sample-compliance-requirements";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "./sample-overview";
import {
isSampleScanFilter,
SAMPLE_FINDINGS,
sampleFindingsQuery,
} from "./sample-records";
const FAILING = SAMPLE_FINDINGS.find((finding) => finding.status === "FAIL")!;
const requirement = (id: string, checkIds: string[]) => ({
type: "compliance-requirements-attributes",
id,
attributes: {
framework_description: "",
framework: "CIS",
version: "5.0",
description: `Requirement ${id}`,
attributes: { metadata: [], check_ids: checkIds },
},
});
describe("sample compliance requirements", () => {
beforeEach(() => {
getComplianceAttributesMock.mockReset();
});
it("derives each requirement's status from the sample findings", async () => {
// Given
getComplianceAttributesMock.mockResolvedValue({
data: [
requirement("1.1", [FAILING.check.checkId]),
requirement("1.2", ["check_outside_the_sample"]),
requirement("1.3", []),
],
});
// When
const detail = await getSampleComplianceDetail("cis_5.0_aws");
// Then: the catalog is read without a scan, so no tenant data is involved.
expect(getComplianceAttributesMock).toHaveBeenCalledWith("cis_5.0_aws");
expect(detail.status).toBe(SAMPLE_CATALOG_STATUS.READY);
if (detail.status !== SAMPLE_CATALOG_STATUS.READY) return;
expect(
detail.requirements.map((item) => [item.id, item.attributes.status]),
).toEqual([
["1.1", REQUIREMENT_STATUS.FAIL],
["1.2", REQUIREMENT_STATUS.PASS],
["1.3", REQUIREMENT_STATUS.MANUAL],
]);
});
it("counts the cards from the same requirements the detail shows", async () => {
// Given
getComplianceAttributesMock.mockResolvedValue({
data: [
requirement("1.1", [FAILING.check.checkId]),
requirement("1.2", []),
],
});
// When
const totals = await getSampleComplianceTotals();
// Then
expect(totals).toHaveLength(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
);
expect(totals[0].attributes).toMatchObject({
requirements_passed: 0,
requirements_failed: 1,
requirements_manual: 1,
total_requirements: 2,
});
});
it("keeps the fixed totals when the catalog is unavailable", async () => {
// Given
getComplianceAttributesMock.mockResolvedValue(undefined);
// When
const totals = await getSampleComplianceTotals();
// Then
expect(totals).toEqual(SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data);
});
it("answers a requirement's findings for the sample scan", () => {
// Given
const filters = {
"filter[scan__in]": "sample-scan",
"filter[check_id__in]": FAILING.check.checkId,
"filter[status__in]": "FAIL",
};
// When / Then
expect(isSampleScanFilter(filters)).toBe(true);
const findings = sampleFindingsQuery(filters);
expect(findings.length).toBeGreaterThan(0);
expect(
findings.every(
(finding) =>
finding.check.checkId === FAILING.check.checkId &&
finding.status === "FAIL",
),
).toBe(true);
});
it("evaluates statuses only on the selected regions' findings", async () => {
// Given: a region where the failing check has no failing resource.
const failingRegions = new Set(
SAMPLE_FINDINGS.filter(
(finding) =>
finding.check.checkId === FAILING.check.checkId &&
finding.status === "FAIL",
).map((finding) => finding.resource.region),
);
const cleanRegion = [
"us-east-1",
"us-west-2",
"eu-west-1",
"eu-central-1",
].find((region) => !failingRegions.has(region));
getComplianceAttributesMock.mockResolvedValue({
data: [requirement("1.1", [FAILING.check.checkId])],
});
// When
const inFailingRegion = await getSampleComplianceDetail(
"cis_5.0_aws",
FAILING.resource.region,
);
const inCleanRegion = cleanRegion
? await getSampleComplianceDetail("cis_5.0_aws", cleanRegion)
: null;
// Then
const status = (detail: typeof inFailingRegion | null) =>
detail?.status === SAMPLE_CATALOG_STATUS.READY
? detail.requirements[0].attributes.status
: undefined;
expect(status(inFailingRegion)).toBe(REQUIREMENT_STATUS.FAIL);
if (cleanRegion)
expect(status(inCleanRegion)).toBe(REQUIREMENT_STATUS.PASS);
});
it("tells a warming catalog apart from other failures", async () => {
getComplianceAttributesMock.mockResolvedValueOnce({
warming: true,
status: 503,
});
expect((await getSampleComplianceDetail("cis_5.0_aws")).status).toBe(
SAMPLE_CATALOG_STATUS.WARMING,
);
getComplianceAttributesMock.mockResolvedValueOnce({
error: "Not found",
status: 404,
});
expect((await getSampleComplianceDetail("unknown")).status).toBe(
SAMPLE_CATALOG_STATUS.UNAVAILABLE,
);
});
});
@@ -1,134 +0,0 @@
import { cache } from "react";
import { getComplianceAttributes } from "@/actions/compliances";
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
import {
type AttributesData,
REQUIREMENT_STATUS,
type RequirementItemData,
type RequirementStatus,
} from "@/types/compliance";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "./sample-overview";
import { SAMPLE_FINDINGS } from "./sample-records";
// Requirement statuses for the sample scan: the framework catalog (no tenant
// data) evaluated against the sample findings, the way a scan would.
// Failing checks of the sample findings in the selected regions (all if none).
const failingChecks = (regions: string[]) =>
new Set(
SAMPLE_FINDINGS.filter(
(finding) =>
finding.status === "FAIL" &&
(regions.length === 0 || regions.includes(finding.resource.region)),
).map((finding) => finding.check.checkId),
);
// Checks the sample does not evaluate count as passing: a clean account.
const requirementStatus = (
checkIds: string[],
failing: Set<string>,
): RequirementStatus => {
if (checkIds.length === 0) return REQUIREMENT_STATUS.MANUAL;
return checkIds.some((checkId) => failing.has(checkId))
? REQUIREMENT_STATUS.FAIL
: REQUIREMENT_STATUS.PASS;
};
export const SAMPLE_CATALOG_STATUS = {
READY: "ready",
// The API is still loading the catalog (HTTP 503), like the real page.
WARMING: "warming",
UNAVAILABLE: "unavailable",
} as const;
interface SampleComplianceDetailReady {
status: typeof SAMPLE_CATALOG_STATUS.READY;
attributesData: AttributesData;
requirements: RequirementItemData[];
}
interface SampleComplianceDetailPending {
status:
| typeof SAMPLE_CATALOG_STATUS.WARMING
| typeof SAMPLE_CATALOG_STATUS.UNAVAILABLE;
}
export type SampleComplianceDetail =
| SampleComplianceDetailReady
| SampleComplianceDetailPending;
const getSampleCatalog = cache((complianceId: string) =>
// No scan id: the catalog of the provider that declares the framework.
getComplianceAttributes(complianceId),
);
/** The framework's requirements with sample statuses for `region` (comma-separated). */
export const getSampleComplianceDetail = cache(
async (
complianceId: string,
region?: string,
): Promise<SampleComplianceDetail> => {
const attributesData = await getSampleCatalog(complianceId);
if (attributesData && "warming" in attributesData) {
return { status: SAMPLE_CATALOG_STATUS.WARMING };
}
if (
!attributesData ||
!("data" in attributesData) ||
!Array.isArray(attributesData.data)
) {
return { status: SAMPLE_CATALOG_STATUS.UNAVAILABLE };
}
const typed = attributesData as AttributesData;
const failing = failingChecks(region ? region.split(",") : []);
return {
status: SAMPLE_CATALOG_STATUS.READY,
attributesData: typed,
requirements: typed.data.map((item) => ({
type: "compliance-requirements-details",
id: item.id,
attributes: {
framework: item.attributes.framework,
version: item.attributes.version,
description: item.attributes.description,
status: requirementStatus(
item.attributes.attributes.check_ids ?? [],
failing,
),
},
})),
};
},
);
type WatchlistItem = ComplianceWatchlistResponse["data"][number];
/** Card totals from the same requirements; the fixed figures if a catalog is missing. */
export const getSampleComplianceTotals = async (): Promise<WatchlistItem[]> =>
Promise.all(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.map(async (item) => {
const detail = await getSampleComplianceDetail(item.id);
if (detail.status !== SAMPLE_CATALOG_STATUS.READY) return item;
const count = (status: RequirementStatus) =>
detail.requirements.filter(
(requirement) => requirement.attributes.status === status,
).length;
const passed = count(REQUIREMENT_STATUS.PASS);
const failed = count(REQUIREMENT_STATUS.FAIL);
const manual = count(REQUIREMENT_STATUS.MANUAL);
return {
...item,
attributes: {
...item.attributes,
requirements_passed: passed,
requirements_failed: failed,
requirements_manual: manual,
total_requirements: passed + failed + manual,
},
};
}),
);
+2 -12
View File
@@ -1,4 +1,3 @@
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
import {
COMPLIANCE_OVERVIEW_TYPE,
ComplianceOverviewData,
@@ -26,13 +25,8 @@ const SAMPLE_FRAMEWORK_NAMES: Record<
soc2_aws: { framework: "SOC2", version: "" },
};
type WatchlistItem = ComplianceWatchlistResponse["data"][number];
/** Per-scan compliance overviews for the given watchlist totals. */
export const toSampleComplianceOverviews = (
items: WatchlistItem[],
): ComplianceOverviewData[] =>
items.map(({ attributes }) => ({
export const SAMPLE_COMPLIANCE_OVERVIEWS: ComplianceOverviewData[] =
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.map(({ attributes }) => ({
type: COMPLIANCE_OVERVIEW_TYPE.OVERVIEW,
id: attributes.compliance_id,
attributes: {
@@ -47,10 +41,6 @@ export const toSampleComplianceOverviews = (
},
}));
export const SAMPLE_COMPLIANCE_OVERVIEWS = toSampleComplianceOverviews(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data,
);
export const SAMPLE_SCAN: ScanEntity = {
id: SAMPLE_ACCOUNT.scanId,
providerInfo: {
@@ -7,7 +7,6 @@ import { getFindingGroupResources } from "@/actions/finding-groups";
import { SAMPLE_ACCOUNT, SAMPLE_CHECKS } from "./sample-dataset";
import { filterSampleFindingGroups } from "./sample-findings";
import { SAMPLE_STATUS } from "./sample-overview";
import { SAMPLE_FINDINGS } from "./sample-records";
describe("sample finding filters", () => {
it("shows passing sample findings reached from the Overview PASS segment", () => {
@@ -95,100 +94,4 @@ describe("sample finding filters", () => {
expect(result.data).toHaveLength(1);
expect(result.meta.pagination.count).toBe(1);
});
it("narrows to new failures when the Overview link asks for them", () => {
// Given
const newChecks = SAMPLE_CHECKS.filter((check) => check.newFailed > 0);
// When
const result = filterSampleFindingGroups({
"filter[status__in]": "FAIL",
"filter[delta]": "new",
});
// Then
expect(result.groups.map((group) => group.checkId).sort()).toEqual(
newChecks.map((check) => check.checkId).sort(),
);
expect(result.resourceFilters).toEqual({
"filter[status__in]": "FAIL",
"filter[delta]": "new",
});
});
it("counts only the resources in the selected region and type", () => {
// Given
const [{ resource }] = SAMPLE_FINDINGS;
const matching = SAMPLE_FINDINGS.filter(
(finding) =>
finding.resource.region === resource.region &&
finding.resource.type === resource.type,
);
// When
const result = filterSampleFindingGroups({
"filter[region__in]": resource.region,
"filter[resource_type__in]": resource.type,
});
// Then
expect(
result.groups.reduce(
(total, group) =>
total + (group.failCount ?? 0) + (group.passCount ?? 0),
0,
),
).toBe(matching.length);
});
it("keeps the sample account for its provider and drops muted-only views", () => {
expect(
filterSampleFindingGroups({ "filter[provider_type__in]": "aws" }).groups,
).toHaveLength(SAMPLE_CHECKS.length);
expect(
filterSampleFindingGroups({ "filter[provider_type__in]": "azure" })
.groups,
).toHaveLength(0);
expect(
filterSampleFindingGroups({ "filter[muted]": "true" }).groups,
).toHaveLength(0);
});
it("applies the group's region filter to its expanded resources", async () => {
// Given
const check = "s3_bucket_public_access";
const region = SAMPLE_FINDINGS.find(
(finding) => finding.check.checkId === check,
)!.resource.region;
// When
const result = await getFindingGroupResources({
checkId: check,
pageSize: 100,
filters: {
"filter[scan]": SAMPLE_ACCOUNT.scanId,
"filter[region__in]": region,
},
});
// Then
expect(result.data.length).toBeGreaterThan(0);
expect(
result.data.every(
(row: { attributes: { resource: { region: string } } }) =>
row.attributes.resource.region === region,
),
).toBe(true);
});
it("matches the date filter against the sample scan's day", () => {
const scanDay = SAMPLE_ACCOUNT.scannedAt.slice(0, 10);
expect(
filterSampleFindingGroups({ "filter[inserted_at]": scanDay }).groups,
).toHaveLength(SAMPLE_CHECKS.length);
expect(
filterSampleFindingGroups({ "filter[inserted_at]": "2020-01-01" }).groups,
).toHaveLength(0);
});
});
+18 -85
View File
@@ -6,20 +6,13 @@ import {
type FindingStatus,
SearchParamsProps,
} from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import {
SAMPLE_ACCOUNT,
SAMPLE_CHECKS,
type SampleCheck,
} from "./sample-dataset";
import {
matchesSampleFindingFilters,
pickSampleFindingFilters,
SAMPLE_FINDINGS,
SAMPLE_ID_PREFIX,
type SampleFinding,
} from "./sample-records";
import { SAMPLE_ID_PREFIX } from "./sample-records";
// Finding groups derived from SAMPLE_CHECKS, in the API response shape so they
// run through the same adapter as real data.
@@ -51,19 +44,14 @@ interface SampleFindingGroupItem {
attributes: SampleFindingGroupAttributes;
}
// Counts come from the matching records, so a filtered group adds up.
const toFindingGroupItem = (
check: SampleCheck,
findings: SampleFinding[],
statuses: Set<FindingStatus> | null,
): SampleFindingGroupItem => {
const failed = findings.filter(
(finding) => finding.status === FINDING_STATUS.FAIL,
).length;
const passed = findings.length - failed;
const newFailed = findings.filter(
(finding) =>
finding.status === FINDING_STATUS.FAIL && finding.delta === "new",
).length;
const failed =
!statuses || statuses.has(FINDING_STATUS.FAIL) ? check.failed : 0;
const passed =
!statuses || statuses.has(FINDING_STATUS.PASS) ? check.passed : 0;
return {
type: "finding-groups",
// The sample prefix keeps the group out of Lighthouse context.
@@ -76,14 +64,14 @@ const toFindingGroupItem = (
status: failed > 0 ? FINDING_STATUS.FAIL : FINDING_STATUS.PASS,
muted: false,
impacted_providers: ["aws"],
resources_total: findings.length,
resources_total: failed + passed,
resources_fail: failed,
pass_count: passed,
fail_count: failed,
muted_count: 0,
new_count: newFailed,
new_count: failed > 0 ? check.newFailed : 0,
changed_count: 0,
new_fail_count: newFailed,
new_fail_count: failed > 0 ? check.newFailed : 0,
first_seen_at: SAMPLE_ACCOUNT.scannedAt,
last_seen_at: SAMPLE_ACCOUNT.scannedAt,
failing_since: SAMPLE_ACCOUNT.scannedAt,
@@ -100,10 +88,9 @@ const SAMPLE_GROUP_FILTERS: Record<
"filter[service__in]": (check) => check.service,
"filter[category__in]": (check) => check.attackSurface,
"filter[check_id__in]": (check) => check.checkId,
"filter[resource_groups__in]": (check) => check.resourceGroup,
};
/** The sample groups the URL filters ask for, and the filters for their drill-downs. */
/** The sample groups an Overview link asked for, and whether any filter applied. */
export const filterSampleFindingGroups = (searchParams: SearchParamsProps) => {
const statuses = getActiveStatusFilter(searchParams);
const query = searchParams["filter[search]"];
@@ -116,18 +103,11 @@ export const filterSampleFindingGroups = (searchParams: SearchParamsProps) => {
: [];
},
);
const findingFilters = pickSampleFindingFilters(searchParams);
const findingsByCheck = new Map<string, SampleFinding[]>();
for (const finding of SAMPLE_FINDINGS) {
if (statuses && !statuses.has(finding.status)) continue;
if (!matchesSampleFindingFilters(finding, findingFilters)) continue;
const checkFindings = findingsByCheck.get(finding.check.checkId) ?? [];
checkFindings.push(finding);
findingsByCheck.set(finding.check.checkId, checkFindings);
}
const checks = SAMPLE_CHECKS.filter(
(check) =>
findingsByCheck.has(check.checkId) &&
(!statuses ||
(statuses.has(FINDING_STATUS.FAIL) && check.failed > 0) ||
(statuses.has(FINDING_STATUS.PASS) && check.passed > 0)) &&
(!search ||
`${check.checkId} ${check.title}`.toLowerCase().includes(search)) &&
active.every(({ field, accepted }) => {
@@ -151,61 +131,14 @@ export const filterSampleFindingGroups = (searchParams: SearchParamsProps) => {
});
}
}
const resourceFilters: Record<string, string> = {
...(statuses
? { "filter[status__in]": Array.from(statuses).join(",") }
: {}),
...findingFilters,
};
const resourceFilters: Record<string, string> = statuses
? { "filter[status__in]": Array.from(statuses).join(",") }
: {};
return {
groups: adaptFindingGroupsResponse({
data: checks.map((check) =>
toFindingGroupItem(check, findingsByCheck.get(check.checkId) ?? []),
),
data: checks.map((check) => toFindingGroupItem(check, statuses)),
}),
filtered: active.length > 0 || statuses !== null || Boolean(search),
resourceFilters,
};
};
const unique = (values: (string | undefined)[]) =>
Array.from(
new Set(values.filter((value): value is string => Boolean(value))),
).sort();
/** Filter bar options, derived from the records the sample filters match. */
export const SAMPLE_FINDING_FILTER_OPTIONS = {
regions: unique(SAMPLE_FINDINGS.map((finding) => finding.resource.region)),
services: unique(SAMPLE_CHECKS.map((check) => check.service)),
resourceTypes: unique(
SAMPLE_FINDINGS.map((finding) => finding.resource.type),
),
categories: unique(SAMPLE_CHECKS.map((check) => check.attackSurface)),
groups: unique(SAMPLE_CHECKS.map((check) => check.resourceGroup)),
};
/** The sample account, so the provider filter can select it. */
export const SAMPLE_FILTER_PROVIDER: ProviderProps = {
id: SAMPLE_ACCOUNT.providerId,
type: "providers",
attributes: {
provider: "aws",
is_dynamic: false,
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
status: "completed",
resources: SAMPLE_FINDINGS.length,
connection: { connected: true, last_checked_at: SAMPLE_ACCOUNT.scannedAt },
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
created_by: { object: "user", id: `${SAMPLE_ID_PREFIX}user` },
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
};
@@ -7,7 +7,6 @@ import {
SAMPLE_THREAT_MAP_DATA,
} from "./sample-graphs";
import { SAMPLE_STATUS } from "./sample-overview";
import { getSampleFinding } from "./sample-records";
describe("sample graphs", () => {
it("agrees with the Overview failed and new totals", () => {
@@ -27,17 +26,4 @@ describe("sample graphs", () => {
Math.min(SAMPLE_STATUS.failNew, 10),
);
});
it("lists new failed findings that open the same sample record", () => {
for (const finding of SAMPLE_NEW_FINDINGS) {
const record = getSampleFinding(finding.id);
expect(record?.status).toBe("FAIL");
expect(record?.delta).toBe("new");
expect(finding.relationships.resource.attributes.name).toBe(
record?.resource.name,
);
expect(finding.relationships.provider.attributes.alias).toBeTruthy();
}
});
});
+153 -14
View File
@@ -12,7 +12,12 @@ import type { RiskPlotPoint } from "@/actions/overview/risk-plot/types/risk-plot
import { adaptCategoryOverviewToRadarData } from "@/actions/overview/risk-radar/risk-radar.adapter";
import type { CategoryOverviewResponse } from "@/actions/overview/risk-radar/types";
import type { RadarDataPoint } from "@/components/graphs/types";
import { type FindingProps, SEVERITY } from "@/types/components";
import {
FINDING_DELTA,
FINDING_STATUS,
type FindingProps,
SEVERITY,
} from "@/types/components";
import {
SAMPLE_ACCOUNT,
@@ -24,7 +29,6 @@ import {
SAMPLE_FAILED_BY_SEVERITY,
SAMPLE_THREAT_SCORE,
} from "./sample-overview";
import { SAMPLE_FINDINGS, sampleExpandedFindings } from "./sample-records";
// Graphs-tab payloads derived from SAMPLE_CHECKS, run through the real adapters.
@@ -43,6 +47,9 @@ const splitAcrossRegions = (count: number, offset: number): number[] =>
return base + (slot < count % SAMPLE_REGIONS.length ? 1 : 0);
});
const regionFor = (checkIndex: number, ordinal: number) =>
SAMPLE_REGIONS[(checkIndex + ordinal) % SAMPLE_REGIONS.length];
export const SAMPLE_SANKEY_DATA: SankeyData = adaptToSankeyData({
[PROVIDER_TYPE]: SAMPLE_FAILED_BY_SEVERITY,
});
@@ -160,6 +167,138 @@ const SAMPLE_CATEGORIES_RESPONSE: CategoryOverviewResponse = {
export const SAMPLE_RADAR_DATA: RadarDataPoint[] =
adaptCategoryOverviewToRadarData(SAMPLE_CATEGORIES_RESPONSE);
interface SampleResourceKind {
type: string;
prefix: string;
}
const RESOURCE_BY_SERVICE: Record<string, SampleResourceKind> = {
s3: { type: "AwsS3Bucket", prefix: "sample-exports-bucket" },
rds: { type: "AwsRdsDbInstance", prefix: "sample-orders-db" },
ec2: { type: "AwsEc2Instance", prefix: "sample-web-instance" },
iam: { type: "AwsIamUser", prefix: "sample-iam-user" },
};
const buildNewFinding = (
check: SampleCheck,
checkIndex: number,
ordinal: number,
): FindingProps => {
const region = regionFor(checkIndex, ordinal);
const kind = RESOURCE_BY_SERVICE[check.service] ?? {
type: "Other",
prefix: `sample-${check.service}-resource`,
};
const id = `sample-finding-${check.checkId}-${ordinal + 1}`;
const resourceId = `sample-resource-${check.checkId}-${ordinal + 1}`;
const resourceName = `${kind.prefix}-${ordinal + 1}`;
const timestamp = SAMPLE_ACCOUNT.scannedAt;
return {
type: "findings",
id,
attributes: {
uid: id,
delta: FINDING_DELTA.NEW,
status: FINDING_STATUS.FAIL,
status_extended: `${check.title}: ${resourceName} fails this check.`,
severity: check.severity,
check_id: check.checkId,
muted: false,
check_metadata: {
risk: "",
notes: "",
checkid: check.checkId,
provider: PROVIDER_TYPE,
severity: check.severity,
checktype: [],
dependson: [],
relatedto: [],
categories: categoriesOf(check),
checktitle: check.title,
compliance: null,
relatedurl: "",
description: check.title,
remediation: {
code: { cli: "", other: "", nativeiac: "", terraform: "" },
recommendation: { url: "", text: "" },
},
servicename: check.service,
checkaliases: [],
resourcetype: kind.type,
subservicename: "",
resourceidtemplate: "",
},
raw_result: null,
inserted_at: timestamp,
updated_at: timestamp,
first_seen_at: timestamp,
},
relationships: {
resources: { data: [{ type: "resources", id: resourceId }] },
scan: {
data: { type: "scans", id: SAMPLE_ACCOUNT.scanId },
attributes: {
name: "Sample scan",
trigger: "manual",
state: "completed",
unique_resource_count: 0,
progress: 100,
scanner_args: { checks_to_execute: [] },
duration: 0,
started_at: timestamp,
inserted_at: timestamp,
completed_at: timestamp,
scheduled_at: null,
next_scan_at: timestamp,
},
},
resource: {
data: [{ type: "resources", id: resourceId }],
id: resourceId,
attributes: {
uid: `arn:aws:${check.service}:${region}:${SAMPLE_ACCOUNT.uid}:${resourceName}`,
name: resourceName,
region,
service: check.service,
tags: {},
type: kind.type,
inserted_at: timestamp,
updated_at: timestamp,
details: null,
partition: "aws",
},
relationships: {
provider: {
data: { type: "providers", id: SAMPLE_ACCOUNT.providerId },
},
findings: {
meta: { count: 1 },
data: [{ type: "findings", id }],
},
},
links: { self: "" },
},
provider: {
data: { type: "providers", id: SAMPLE_ACCOUNT.providerId },
attributes: {
provider: PROVIDER_TYPE,
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
connection: { connected: true, last_checked_at: timestamp },
inserted_at: timestamp,
updated_at: timestamp,
},
relationships: {
secret: { data: { type: "provider-secrets", id: "sample-secret" } },
},
links: { self: "" },
},
},
links: { self: "" },
};
};
const SEVERITY_RANK = [
SEVERITY.CRITICAL,
SEVERITY.HIGH,
@@ -168,16 +307,16 @@ const SEVERITY_RANK = [
SEVERITY.INFORMATIONAL,
];
// Latest new failed findings, most severe first, like the real tab. They are
// the sample records, so a row opens the same finding the drawer resolves.
export const SAMPLE_NEW_FINDINGS = sampleExpandedFindings(
SAMPLE_FINDINGS.filter(
(finding) => finding.status === "FAIL" && finding.delta === "new",
// Latest new failed findings, most severe first, like the real tab.
export const SAMPLE_NEW_FINDINGS: FindingProps[] = SAMPLE_CHECKS.flatMap(
(check, checkIndex) =>
Array.from({ length: check.newFailed }, (_, ordinal) =>
buildNewFinding(check, checkIndex, ordinal),
),
)
.sort(
(a, b) =>
SEVERITY_RANK.indexOf(a.attributes.severity) -
SEVERITY_RANK.indexOf(b.attributes.severity),
)
.sort(
(a, b) =>
SEVERITY_RANK.indexOf(a.check.severity) -
SEVERITY_RANK.indexOf(b.check.severity),
)
.slice(0, NEW_FINDINGS_LIMIT),
) as unknown as FindingProps[];
.slice(0, NEW_FINDINGS_LIMIT);
@@ -18,7 +18,6 @@ export const SAMPLE_PREVIEW_PAGE = {
COMPLIANCE: "compliance",
ISSUES: "issues",
ATTACK_PATHS: "attack_paths",
RESOURCES: "resources",
} as const;
export type SamplePreviewPage =
+6 -96
View File
@@ -437,17 +437,6 @@ export const sampleFindingsDocument = (findings: SampleFinding[]) => {
};
};
/** Findings with their resource, scan and provider inlined, like the Overview tables. */
export const sampleExpandedFindings = (findings: SampleFinding[]) =>
findings.map((finding) => ({
...findingItem(finding),
relationships: {
scan: SCAN_ITEM,
resource: resourceItem(finding.resource),
provider: PROVIDER_ITEM,
},
}));
/** /findings?include=resources: the issue panel asks for nothing else. */
export const sampleIssueFindingsDocument = (findings: SampleFinding[]) => ({
data: findings.map(findingItem),
@@ -468,53 +457,6 @@ const paginate = <T>(items: T[], page: number, pageSize: number) => ({
},
});
type SampleFilterParams = Record<string, string | string[] | undefined>;
// Resource-level filters the Findings page sets, matched per sample finding.
const SAMPLE_FINDING_FILTERS: Record<
string,
(finding: SampleFinding) => string | null
> = {
"filter[region__in]": (finding) => finding.resource.region,
"filter[resource_type__in]": (finding) => finding.resource.type,
"filter[delta]": (finding) => finding.delta,
"filter[delta__in]": (finding) => finding.delta,
"filter[provider_type__in]": () => "aws",
"filter[provider_id__in]": () => SAMPLE_ACCOUNT.providerId,
// The date picker sends YYYY-MM-DD: every sample finding is from one scan.
"filter[inserted_at]": () => SAMPLE_ACCOUNT.scannedAt.slice(0, 10),
};
const MUTED_FILTER = "filter[muted]";
/** The resource-level filters present in a request, as plain strings. */
export const pickSampleFindingFilters = (
filters: SampleFilterParams,
): Record<string, string> =>
Object.fromEntries(
[...Object.keys(SAMPLE_FINDING_FILTERS), MUTED_FILTER].flatMap((key) => {
const raw = filters[key];
const value = Array.isArray(raw) ? raw.join(",") : raw;
// "false" is the page default: only asking for muted findings narrows.
if (key === MUTED_FILTER && value !== "true") return [];
return value ? [[key, value]] : [];
}),
);
/** Whether a sample finding passes the picked resource-level filters. */
export const matchesSampleFindingFilters = (
finding: SampleFinding,
filters: Record<string, string>,
) =>
// Sample findings are never muted.
filters[MUTED_FILTER] !== "true" &&
Object.entries(filters).every(([key, value]) => {
const field = SAMPLE_FINDING_FILTERS[key];
if (!field) return true;
const actual = field(finding);
return actual !== null && value.split(",").includes(actual);
});
/** /finding-groups/{check_id}/resources: failed first, like the API's sort. */
export const sampleFindingGroupResources = ({
checkId,
@@ -522,20 +464,17 @@ export const sampleFindingGroupResources = ({
pageSize,
status,
search,
filters = {},
}: {
checkId: string;
page: number;
pageSize: number;
status?: string;
search?: string;
filters?: Record<string, string>;
}) => {
const findings = SAMPLE_FINDINGS.filter(
(finding) =>
finding.check.checkId === checkId &&
(!status || status.split(",").includes(finding.status)) &&
matchesSampleFindingFilters(finding, filters) &&
(!search ||
finding.resource.name.toLowerCase().includes(search.toLowerCase())),
);
@@ -582,52 +521,23 @@ export const sampleFindingsQuery = (
const raw = filters[key];
return Array.isArray(raw) ? raw.join(",") : raw;
};
const list = (key: string) => value(key)?.split(",");
const resourceUid = value("filter[resource_uid]");
const checkIds = list("filter[check_id]") ?? list("filter[check_id__in]");
const statuses = list("filter[status]") ?? list("filter[status__in]");
const regions = list("filter[region__in]");
const uids = list("filter[uid__in]");
const checkId = value("filter[check_id]");
const status = value("filter[status]");
const uids = value("filter[uid__in]")?.split(",");
return SAMPLE_FINDINGS.filter(
(finding) =>
(!resourceUid || finding.resource.uid === resourceUid) &&
(!checkIds || checkIds.includes(finding.check.checkId)) &&
(!statuses || statuses.includes(finding.status)) &&
(!regions || regions.includes(finding.resource.region)) &&
(!checkId || finding.check.checkId === checkId) &&
(!status || finding.status === status) &&
(!uids || uids.includes(finding.uid)),
);
};
/** The resource drawer's data: the resource's failed findings, no API calls. */
export const sampleResourceDrawerData = (resourceId: string, query = "") => {
const search = query.trim().toLowerCase();
const findings = SAMPLE_FINDINGS.filter(
(finding) =>
finding.resource.id === resourceId &&
finding.status === "FAIL" &&
(!search ||
`${finding.check.checkId} ${finding.check.title}`
.toLowerCase()
.includes(search)),
);
return {
findings: findings.map(findingItem),
findingsMeta: {
pagination: { page: 1, pages: 1, count: findings.length },
version: "v1",
},
providerOrg: null,
resourceTags: {},
};
};
/** Whether a request is scoped to the sample scan. */
export const isSampleScanFilter = (
filters: Record<string, string | string[] | undefined> | undefined,
) =>
filters?.["filter[scan]"] === SAMPLE_ACCOUNT.scanId ||
// Compliance requirements ask by scan list, with the sample scan alone.
filters?.["filter[scan__in]"] === SAMPLE_ACCOUNT.scanId;
) => filters?.["filter[scan]"] === SAMPLE_ACCOUNT.scanId;
/** /findings/{id}/compliance-frameworks: the check's frameworks, all watched. */
export const sampleComplianceFrameworks = (
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
import { getResourceDrawerData } from "@/actions/resources";
import { SAMPLE_RESOURCE_GROUPS_RESPONSE } from "./sample-overview";
import { SAMPLE_FINDINGS, SAMPLE_RESOURCES } from "./sample-records";
import {
filterSampleResources,
getSampleResourceRow,
SAMPLE_RESOURCE_FILTER_OPTIONS,
} from "./sample-resources";
describe("sample resources", () => {
it("lists every sample resource with its failed findings count", () => {
// When
const { data, metadata } = filterSampleResources({ pageSize: "500" });
// Then
expect(metadata.pagination.count).toBe(SAMPLE_RESOURCES.length);
expect(
data.reduce(
(total, row) => total + row.attributes.failed_findings_count,
0,
),
).toBe(
SAMPLE_FINDINGS.filter((finding) => finding.status === "FAIL").length,
);
});
it("agrees with the Overview resource group counts", () => {
for (const group of SAMPLE_RESOURCE_GROUPS_RESPONSE.data) {
const { metadata } = filterSampleResources({
"filter[groups__in]": group.id,
});
expect(metadata.pagination.count).toBe(group.attributes.resources_count);
}
});
it("applies the filter bar, search, sort and paging", () => {
// Given
const [region] = SAMPLE_RESOURCE_FILTER_OPTIONS.regions;
const inRegion = SAMPLE_RESOURCES.filter(
(resource) => resource.region === region,
);
// When
const filtered = filterSampleResources({
"filter[region__in]": region,
sort: "-type",
pageSize: "500",
});
const searched = filterSampleResources({
"filter[search]": SAMPLE_RESOURCES[0].name.toUpperCase(),
});
const secondPage = filterSampleResources({ page: "2", pageSize: "5" });
// Then
expect(filtered.data).toHaveLength(inRegion.length);
const types = filtered.data.map((row) => row.attributes.type);
expect(types).toEqual([...types].sort().reverse());
expect(searched.data.map((row) => row.attributes.name)).toContain(
SAMPLE_RESOURCES[0].name,
);
expect(secondPage.data).toHaveLength(5);
expect(secondPage.metadata.pagination.page).toBe(2);
});
it("opens a sample resource drawer from its records, without the API", async () => {
// Given
const failing = SAMPLE_FINDINGS.find(
(finding) => finding.status === "FAIL",
)!;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// When
const drawer = await getResourceDrawerData({
resourceId: failing.resource.id,
resourceUid: failing.resource.uid,
providerId: "sample-provider",
providerType: "aws",
});
// Then
expect(
drawer.findings.map((finding: { id: string }) => finding.id),
).toContain(failing.id);
expect(drawer.providerOrg).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("falls back to the default page size for invalid values", () => {
for (const pageSize of ["-1", "0", "2.5", "abc"]) {
const { data } = filterSampleResources({ pageSize });
expect(data).toHaveLength(10);
}
});
it("reopens a copied sample resource link", () => {
const [resource] = SAMPLE_RESOURCES;
expect(getSampleResourceRow(resource.id)?.attributes.name).toBe(
resource.name,
);
expect(getSampleResourceRow("not-a-sample")).toBeNull();
});
it("searches the sample resource drawer's findings", async () => {
// Given
const failing = SAMPLE_FINDINGS.find(
(finding) => finding.status === "FAIL",
)!;
const request = {
resourceId: failing.resource.id,
resourceUid: failing.resource.uid,
providerId: "sample-provider",
providerType: "aws",
};
// When
const matching = await getResourceDrawerData({
...request,
query: failing.check.title.toUpperCase(),
});
const none = await getResourceDrawerData({ ...request, query: "zzz" });
// Then
expect(
matching.findings.map((finding: { id: string }) => finding.id),
).toContain(failing.id);
expect(none.findings).toHaveLength(0);
});
});
-157
View File
@@ -1,157 +0,0 @@
import type { MetaDataProps, ResourceProps, SearchParamsProps } from "@/types";
import { SAMPLE_ACCOUNT } from "./sample-dataset";
import {
SAMPLE_FINDINGS,
SAMPLE_RESOURCES,
type SampleResource,
} from "./sample-records";
// /resources rows built from the sample records, so counts, filters and the
// resource drawer agree with the Findings and Overview samples.
const DEFAULT_PAGE_SIZE = 10;
const PROVIDER_DATA: ResourceProps["relationships"]["provider"]["data"] = {
type: "providers",
id: SAMPLE_ACCOUNT.providerId,
attributes: {
inserted_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
provider: "aws",
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
connection: { connected: true, last_checked_at: SAMPLE_ACCOUNT.scannedAt },
},
relationships: {
secret: { data: { type: "provider-secrets", id: "sample-secret" } },
},
links: { self: "" },
};
const toRow = (resource: SampleResource): ResourceProps => {
const findings = SAMPLE_FINDINGS.filter(
(finding) => finding.resource.id === resource.id,
);
const failed = findings.filter((finding) => finding.status === "FAIL");
return {
type: "resources",
id: resource.id,
attributes: {
inserted_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
uid: resource.uid,
name: resource.name,
region: resource.region,
service: resource.service,
tags: {},
type: resource.type,
groups: Array.from(
new Set(findings.map((finding) => finding.check.resourceGroup)),
),
failed_findings_count: failed.length,
details: null,
partition: "aws",
metadata: null,
},
relationships: {
provider: { data: PROVIDER_DATA },
findings: { meta: { count: failed.length }, data: [] },
},
links: { self: "" },
};
};
const SAMPLE_RESOURCE_ROWS = SAMPLE_RESOURCES.map(toRow);
const unique = (values: string[]) => Array.from(new Set(values)).sort();
/** Filter bar options, derived from the rows the sample filters match. */
export const SAMPLE_RESOURCE_FILTER_OPTIONS = {
regions: unique(SAMPLE_RESOURCES.map((resource) => resource.region)),
services: unique(SAMPLE_RESOURCES.map((resource) => resource.service)),
types: unique(SAMPLE_RESOURCES.map((resource) => resource.type)),
groups: unique(
SAMPLE_RESOURCE_ROWS.flatMap((row) => row.attributes.groups ?? []),
),
};
// The filters the /resources filter bar and the Overview cards set.
const RESOURCE_FILTERS: Record<string, (row: ResourceProps) => string[]> = {
"filter[region__in]": (row) => [row.attributes.region],
"filter[service__in]": (row) => [row.attributes.service],
"filter[type__in]": (row) => [row.attributes.type],
"filter[groups__in]": (row) => row.attributes.groups ?? [],
"filter[provider_type__in]": () => ["aws"],
"filter[provider_id__in]": () => [SAMPLE_ACCOUNT.providerId],
"filter[provider__in]": () => [SAMPLE_ACCOUNT.providerId],
};
const SORTABLE_FIELDS = ["type", "region", "service"] as const;
type SortableField = (typeof SORTABLE_FIELDS)[number];
const isSortableField = (field: string): field is SortableField =>
(SORTABLE_FIELDS as readonly string[]).includes(field);
const param = (searchParams: SearchParamsProps, key: string) => {
const value = searchParams[key];
return Array.isArray(value) ? value.join(",") : value;
};
const positiveInteger = (value: string | undefined, fallback: number) => {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
};
/** The sample row a `?resourceId=` link opens, if it is one. */
export const getSampleResourceRow = (resourceId: string | undefined) =>
SAMPLE_RESOURCE_ROWS.find((row) => row.id === resourceId) ?? null;
/** One page of sample resources for the URL's filters, search and sort. */
export const filterSampleResources = (
searchParams: SearchParamsProps,
): { data: ResourceProps[]; metadata: MetaDataProps } => {
const search = param(searchParams, "filter[search]")?.trim().toLowerCase();
const active = Object.entries(RESOURCE_FILTERS).flatMap(([key, field]) => {
const value = param(searchParams, key);
return value ? [{ field, accepted: value.split(",") }] : [];
});
const rows = SAMPLE_RESOURCE_ROWS.filter(
(row) =>
(!search ||
`${row.attributes.name} ${row.attributes.uid}`
.toLowerCase()
.includes(search)) &&
active.every(({ field, accepted }) =>
field(row).some((value) => accepted.includes(value)),
),
);
const sort = param(searchParams, "sort");
const descending = sort?.startsWith("-") ?? false;
const sortField = sort?.replace(/^-/, "") ?? "";
if (isSortableField(sortField)) {
rows.sort((a, b) => {
const comparison = a.attributes[sortField].localeCompare(
b.attributes[sortField],
);
return descending ? -comparison : comparison;
});
}
const pageSize = positiveInteger(
param(searchParams, "pageSize"),
DEFAULT_PAGE_SIZE,
);
const pages = Math.max(1, Math.ceil(rows.length / pageSize));
const page = Math.min(positiveInteger(param(searchParams, "page"), 1), pages);
return {
data: rows.slice((page - 1) * pageSize, page * pageSize),
metadata: {
pagination: { page, pages, count: rows.length },
version: "v1",
},
};
};
-5
View File
@@ -179,11 +179,6 @@ export default defineConfig(() => {
"@xyflow/react",
"@dagrejs/dagre",
// Charts (the Overview sample's graph tabs)
"recharts",
"d3",
"topojson-client",
// Forms / state
"react-hook-form",
"@hookform/resolvers/zod",