diff --git a/examples/entities.yaml b/examples/entities.yaml index 359a1da..1e2413e 100644 --- a/examples/entities.yaml +++ b/examples/entities.yaml @@ -12,11 +12,13 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: example-website + namespace: default annotations: aws.amazon.com/aws-codepipeline-tags: component=example-website aws.amazon.com/aws-codebuild-project-tags: component=example-website aws.amazon.com/amazon-ecs-service-tags: component=example-website aws.amazon.com/cost-insights-tags: component=example-website + aws.amazon.com/aws-ecr-repository-arn: 1234567890.dkr.ecr.us-east-1.amazonaws.com/example-website spec: type: website lifecycle: experimental diff --git a/packages/app/package.json b/packages/app/package.json index 23f8c7c..27bfc6b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -18,6 +18,7 @@ "@aws/aws-codebuild-plugin-for-backstage": "workspace:^", "@aws/aws-codepipeline-plugin-for-backstage": "workspace:^", "@aws/cost-insights-plugin-for-backstage": "workspace:^", + "@aws/ecr-plugin-for-backstage": "workspace:^", "@backstage-community/plugin-cost-insights": "^0.12.25", "@backstage-community/plugin-github-actions": "^0.6.16", "@backstage-community/plugin-tech-radar": "^0.7.4", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index efc0bc6..4b84883 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -57,6 +57,11 @@ import { } from '@aws/aws-codepipeline-plugin-for-backstage'; import { EntityAwsCodeBuildCard } from '@aws/aws-codebuild-plugin-for-backstage'; import { EntityAmazonEcsServicesContent } from '@aws/amazon-ecs-plugin-for-backstage'; +import { + isAwsEcrScanResultsAvailable, + EntityEcrScanResultsContent, +} from '@aws/ecr-plugin-for-backstage'; + import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib'; @@ -216,6 +221,10 @@ const websiteEntityPage = ( + + + + diff --git a/packages/backend/package.json b/packages/backend/package.json index ef339fc..43c8002 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -21,6 +21,7 @@ "@aws/aws-codepipeline-plugin-for-backstage-backend": "workspace:^", "@aws/aws-core-plugin-for-backstage-scaffolder-actions": "workspace:^", "@aws/cost-insights-plugin-for-backstage-backend": "workspace:^", + "@aws/ecr-plugin-for-backstage-backend": "workspace:^", "@backstage/backend-defaults": "^0.5.3", "@backstage/backend-plugin-api": "^1.0.2", "@backstage/catalog-client": "^1.8.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 26c7929..eb6664b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -17,6 +17,7 @@ backend.add(import('@aws/amazon-ecs-plugin-for-backstage-backend')); backend.add(import('@aws/aws-codebuild-plugin-for-backstage-backend')); backend.add(import('@aws/aws-codepipeline-plugin-for-backstage-backend')); backend.add(import('@aws/aws-core-plugin-for-backstage-scaffolder-actions')); +backend.add(import('@aws/ecr-plugin-for-backstage-backend')); backend.add(import('@aws/cost-insights-plugin-for-backstage-backend')); diff --git a/plugins/ecr/README.md b/plugins/ecr/README.md new file mode 100644 index 0000000..7a4722b --- /dev/null +++ b/plugins/ecr/README.md @@ -0,0 +1,116 @@ +# ECR AWS plugin for Backstage + +This plugin is meant to allow you to view ECR Scan Results for a specific entity within your Backstage UI. + +This requires that where you run Backstage has AWS Credentials that has IAM Permissions to describe images and get ECR scan findings (through enviornment variables, IRSA, etc;). + +The plugin consists of the following packages: + +- `frontend`: The frontend plugin package installed in Backstage +- `backend`: The backend plugin package installed in Backstage +- `common`: Types and utilities shared between the packages + +## Installing + +This guide assumes that you are familiar with the general [Getting Started](../../docs/getting-started.md) documentation and have assumes you have an existing Backstage application. + +### Permissions + +The IAM role(s) used by Backstage will require the following permissions: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ecr:DescribeImages", + "ecr:DescribeImageScanFindings" + ], + "Resource": "*" + } + ] +} +``` + +Note: This policy does not reflect least privilege and you should further limit the policy to the appropriate AWS resources. + +### Backend package + +Install the backend package in your Backstage app: + +```shell +yarn workspace backend add @aws/ecr-plugin-for-backstage-backend +``` + +Add the plugin to the `packages/backend/src/index.ts`: + +```typescript +const backend = createBackend(); +// ... +backend.add(import('@aws/ecr-plugin-for-backstage-backend')); +// ... +backend.start(); +``` + +### Frontend package + +Install the frontend packages in your Backstage app: + +```shell +yarn workspace app add @aws/ecr-plugin-for-backstage +``` +Edit the `packages/app/src/components/catalog/EntityPage.tsx` and add the imports + +```typescript jsx +import { + EntityEcrScanResultsContent, + isAwsEcrScanResultsAvailable +} from 'plugin-aws-ecr-scan'; +``` + +Then add the following components: + +```typescript jsx + + + +``` + +## Entity annotations + +The plugin uses entity annotations to determine what queries to make for a given entity. The `aws.amazon.com/aws-ecr-repository-arn` annotation can be added to any catalog entity to attach an ECR Repository to the entity. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + aws.amazon.com/aws-ecr-repository-arn: 1234567890.dkr.ecr.us-east-1.amazonaws.com/example-website +spec: + type: service + # ... +``` + +## Configuration + +There are configuration options available to control the behavior of the plugin. + +```yaml +aws: + ecr: + accountId: '1111111111' # (Optional) Use the specified AWS account ID + region: 'us-west-2' # (Optional) Use the specified AWS region + maxImages: 1000 # (Optional) The maximum amount of images grabbed for a repository. + maxScanFindings: 1000 # (Optional) The maximum amount of scan findings grabbed for an individual image. + cache: + enable: true # (Optional) Control is caching is enabled, defaults to true + defaultTtl: 1000 # (Optional) How long responses are cached for in milliseconds, defaults to 1 day + readTime: 1000 # (Optional) Read timeout when operating with cache in milliseconds, defaults to 1000 ms +``` + +### Caching + +By default the responses from the backend plugin are cached for 1 day in order to improve performance to the user for querying the ECR API. The cache TTL can be configured to a different value or alternatively it can be disabled entirely if needed. diff --git a/plugins/ecr/backend/.eslintrc.js b/plugins/ecr/backend/.eslintrc.js new file mode 100644 index 0000000..e2a53a6 --- /dev/null +++ b/plugins/ecr/backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/ecr/backend/README.md b/plugins/ecr/backend/README.md new file mode 100644 index 0000000..cb3e080 --- /dev/null +++ b/plugins/ecr/backend/README.md @@ -0,0 +1,3 @@ +# ECR AWS plugin for Backstage - Backend + +This a backend plugin package for Backstage related to ECR for AWS. For more information see the [documentation](../README.md). diff --git a/plugins/ecr/backend/dev/index.ts b/plugins/ecr/backend/dev/index.ts new file mode 100644 index 0000000..40908dd --- /dev/null +++ b/plugins/ecr/backend/dev/index.ts @@ -0,0 +1,8 @@ +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); +backend.add(import('@backstage/plugin-auth-backend')); +backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); +backend.add(import('../src')); + +backend.start(); diff --git a/plugins/ecr/backend/package.json b/plugins/ecr/backend/package.json new file mode 100644 index 0000000..cc4f105 --- /dev/null +++ b/plugins/ecr/backend/package.json @@ -0,0 +1,73 @@ +{ + "name": "@aws/ecr-plugin-for-backstage-backend", + "version": "0.1.0", + "repository": { + "type": "git", + "url": "github:awslabs/backstage-plugins-for-aws", + "directory": "plugins/ecs/backend" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin", + "pluginId": "ecr-aws", + "pluginPackages": [ + "@aws/ecr-plugin-for-backstage", + "@aws/ecr-plugin-for-backstage-backend" + ] + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@aws-sdk/client-ecr": "^3.568.0", + "@aws-sdk/middleware-sdk-sts": "^3.523.0", + "@aws-sdk/types": "^3.609.0", + "@aws-sdk/util-arn-parser": "^3.495.0", + "@aws/aws-core-plugin-for-backstage-common": "workspace:^", + "@backstage/backend-common": "^0.25.0", + "@backstage/backend-plugin-api": "^1.0.2", + "@backstage/catalog-client": "^1.8.0", + "@backstage/catalog-model": "^1.7.1", + "@backstage/config": "^1.3.0", + "@backstage/errors": "^1.2.5", + "@backstage/integration-aws-node": "^0.1.13", + "@backstage/plugin-catalog-node": "^1.14.0", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "luxon": "^3.4.4", + "node-fetch": "^2.6.7", + "regression": "^2.0.1", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/backend-defaults": "^0.5.3", + "@backstage/backend-test-utils": "^1.1.0", + "@backstage/cli": "^0.29.2", + "@backstage/plugin-auth-backend": "^0.24.0", + "@backstage/plugin-auth-backend-module-guest-provider": "^0.2.2", + "@types/luxon": "^3", + "@types/regression": "^2", + "@types/supertest": "^2.0.12", + "aws-sdk-client-mock": "^4.0.0", + "msw": "^1.0.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/ecr/backend/src/cache/ECRCache.ts b/plugins/ecr/backend/src/cache/ECRCache.ts new file mode 100644 index 0000000..f67ef99 --- /dev/null +++ b/plugins/ecr/backend/src/cache/ECRCache.ts @@ -0,0 +1,78 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assertError } from '@backstage/errors'; +import { CacheService, LoggerService } from '@backstage/backend-plugin-api'; +import { EcrConfig } from '../config'; + +const KEY_PREFIX = 'aws-ecr-provider:'; + +export class EcrCache { + protected readonly cache: CacheService; + protected readonly logger: LoggerService; + protected readonly readTimeout: number; + + private constructor({ + cache, + logger, + readTimeout, + }: { + cache: CacheService; + logger: LoggerService; + readTimeout: number; + }) { + this.cache = cache; + this.logger = logger; + this.readTimeout = readTimeout; + } + + static fromConfig( + config: EcrConfig, + { cache, logger }: { cache: CacheService; logger: LoggerService }, + ) { + return new EcrCache({ + cache: cache.withOptions({ defaultTtl: config.cache.defaultTtl }), + logger, + readTimeout: config.cache.readTimeout, + }); + } + + async get(path: string): Promise { + try { + const response = (await Promise.race([ + this.cache.get(`${KEY_PREFIX}${path}`), + new Promise(cancelAfter => setTimeout(cancelAfter, this.readTimeout)), + ])) as string | undefined; + + if (response !== undefined) { + this.logger.debug(`Cache hit: ${path}`); + return response; + } + + this.logger.debug(`Cache miss: ${path}`); + return response; + } catch (e) { + assertError(e); + this.logger.warn(`Error getting cache entry ${path}: ${e.message}`); + this.logger.debug(e.stack || '-'); + return undefined; + } + } + + async set(path: string, data: string): Promise { + this.logger.debug(`Writing cache entry for ${path}`); + this.cache + .set(`${KEY_PREFIX}${path}`, data) + .catch(e => this.logger.error('write error', e)); + } +} diff --git a/plugins/ecr/backend/src/cache/index.ts b/plugins/ecr/backend/src/cache/index.ts new file mode 100644 index 0000000..9113051 --- /dev/null +++ b/plugins/ecr/backend/src/cache/index.ts @@ -0,0 +1,14 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './ECRCache'; diff --git a/plugins/ecr/backend/src/config/config.ts b/plugins/ecr/backend/src/config/config.ts new file mode 100644 index 0000000..343b0fc --- /dev/null +++ b/plugins/ecr/backend/src/config/config.ts @@ -0,0 +1,54 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { + EcrAwsConfig, + EcrConfig, + EcrAwsConfigCache, +} from './types'; + +export function readEcrConfig( + config: Config, +): EcrConfig { + const root = config.getOptionalConfig('aws.ecr'); + + return { + ecr: readEcrAwsConfig(root), + cache: readEcrAwsConfigCache(root), + }; +} + +function readEcrAwsConfig( + config: Config | undefined, +): EcrAwsConfig { + + return { + accountId: config?.getOptionalString('accountId'), + region: config?.getOptionalString('region'), + maxImages: config?.getOptionalNumber('maxImages'), + maxScanFindings: config?.getOptionalNumber('maxScanFindings'), + }; +} + +function readEcrAwsConfigCache( + config: Config | undefined, +): EcrAwsConfigCache { + const root = config?.getOptionalConfig('cache'); + + return { + enable: root?.getOptionalBoolean('enable') || true, + defaultTtl: root?.getOptionalNumber('defaultTtl') || 86400000, + readTimeout: root?.getOptionalNumber('readTimeout') || 1000, + }; +} diff --git a/plugins/ecr/backend/src/config/index.ts b/plugins/ecr/backend/src/config/index.ts new file mode 100644 index 0000000..34a4b7c --- /dev/null +++ b/plugins/ecr/backend/src/config/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './config'; +export * from './types'; diff --git a/plugins/ecr/backend/src/config/types.ts b/plugins/ecr/backend/src/config/types.ts new file mode 100644 index 0000000..c9c4f0c --- /dev/null +++ b/plugins/ecr/backend/src/config/types.ts @@ -0,0 +1,30 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type EcrConfig = { + cache: EcrAwsConfigCache; + ecr: EcrAwsConfig; +}; + +export type EcrAwsConfigCache = { + enable: boolean; + defaultTtl: number; + readTimeout: number; +}; + +export type EcrAwsConfig = { + accountId: string | undefined; + region: string | undefined; + maxImages: number | undefined; + maxScanFindings: number | undefined; +}; diff --git a/plugins/ecr/backend/src/index.ts b/plugins/ecr/backend/src/index.ts new file mode 100644 index 0000000..0a0c8c9 --- /dev/null +++ b/plugins/ecr/backend/src/index.ts @@ -0,0 +1,16 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './service/router'; +export { ecrAwsPlugin as default } from './plugin'; +export * from "./service/types"; \ No newline at end of file diff --git a/plugins/ecr/backend/src/plugin.ts b/plugins/ecr/backend/src/plugin.ts new file mode 100644 index 0000000..854ed49 --- /dev/null +++ b/plugins/ecr/backend/src/plugin.ts @@ -0,0 +1,67 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createBackendPlugin, + coreServices, +} from '@backstage/backend-plugin-api'; +import { createRouter, ecrAwsServiceRef } from './service/router'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha'; +import { readEcrConfig } from './config'; + +export const ecrAwsPlugin = createBackendPlugin({ + pluginId: 'ecr-aws', + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + httpRouter: coreServices.httpRouter, + config: coreServices.rootConfig, + catalogApi: catalogServiceRef, + auth: coreServices.auth, + discovery: coreServices.discovery, + httpAuth: coreServices.httpAuth, + cache: coreServices.cache, + ecrAwsService: ecrAwsServiceRef, + }, + async init({ + logger, + httpRouter, + config, + auth, + httpAuth, + discovery, + cache, + ecrAwsService, + }) { + const pluginConfig = readEcrConfig(config); + + httpRouter.use( + await createRouter({ + logger, + ecrAwsService, + discovery, + auth, + httpAuth, + cache, + config: pluginConfig, + }), + ); + httpRouter.addAuthPolicy({ + path: '/health', + allow: 'unauthenticated', + }); + }, + }); + }, +}); diff --git a/plugins/ecr/backend/src/service/EcrAwsService.ts b/plugins/ecr/backend/src/service/EcrAwsService.ts new file mode 100644 index 0000000..de37e77 --- /dev/null +++ b/plugins/ecr/backend/src/service/EcrAwsService.ts @@ -0,0 +1,233 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ECRClient, + DescribeImageScanFindingsCommand, + ImageScanFindings, + DescribeImagesCommand, + ImageDetail, +} from '@aws-sdk/client-ecr'; +import { CatalogApi } from '@backstage/catalog-client'; +import { + AWS_SDK_CUSTOM_USER_AGENT, + getOneOfEntityAnnotations, +} from '@aws/aws-core-plugin-for-backstage-common'; +import { + AwsEcrListImagesRequest, + AwsEcrListImagesResponse, + AwsEcrListScanResultsRequest, + AwsEcrListScanResultsResponse, + EcrScanAwsService, +} from './types'; +import { + AwsCredentialsManager, + DefaultAwsCredentialsManager, +} from '@backstage/integration-aws-node'; +import { + AuthService, + BackstageCredentials, + coreServices, + createServiceFactory, + createServiceRef, + DiscoveryService, + HttpAuthService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { createLegacyAuthAdapters } from '@backstage/backend-common'; +import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; +import { EcrConfig, readEcrConfig } from '../config'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha'; +import { stringifyEntityRef } from '@backstage/catalog-model'; + +export const ECR_ARN_ANNOTATION = 'aws.amazon.com/aws-ecr-repository-arn'; + +export class EcrAwsService + implements EcrScanAwsService +{ + public constructor( + private readonly logger: LoggerService, + private readonly auth: AuthService, + private readonly catalogApi: CatalogApi, + private readonly ecrClient: ECRClient, + private readonly config: EcrConfig, + ) {} + + static async fromConfig( + config: EcrConfig, + options: { + catalogApi: CatalogApi; + discovery: DiscoveryService; + auth?: AuthService; + httpAuth?: HttpAuthService; + logger: LoggerService; + credentialsManager: AwsCredentialsManager; + }, + ) { + const { auth } = createLegacyAuthAdapters(options); + + const { region, accountId } = config.ecr; + + const { credentialsManager } = options; + + let credentialProvider: AwsCredentialIdentityProvider; + + if (accountId) { + credentialProvider = ( + await credentialsManager.getCredentialProvider({ accountId }) + ).sdkCredentialProvider; + } else { + credentialProvider = (await credentialsManager.getCredentialProvider()) + .sdkCredentialProvider; + } + + const ecrClient = new ECRClient({ + region: region, + customUserAgent: AWS_SDK_CUSTOM_USER_AGENT, + credentialDefaultProvider: () => credentialProvider, + }); + + return new EcrAwsService( + options.logger, + auth, + options.catalogApi, + ecrClient, + config, + ); + } + async listEcrImages( + req: AwsEcrListImagesRequest, + ): Promise { + const entity = await this.catalogApi.getEntityByRef( + req.entityRef, + req.credentials && + (await this.auth.getPluginRequestToken({ + onBehalfOf: req.credentials, + targetPluginId: 'catalog', + })), + ); + + if (!entity) { + throw new Error( + `Couldn't find entity with name: ${stringifyEntityRef( + req.entityRef, + )}`, + ); + } + + const arnAnnotation = getOneOfEntityAnnotations(entity, [ + ECR_ARN_ANNOTATION, + ]); + + const images = await this.ecrClient.send( + new DescribeImagesCommand({ + repositoryName: this.extractRepoName(arnAnnotation?.value as string), + maxResults: 1000, + }), + ); + + return { + items: images.imageDetails as ImageDetail[], + }; + } + + async listScanResults( + req: AwsEcrListScanResultsRequest, + ): Promise { + const entity = await this.catalogApi.getEntityByRef( + req.entityRef, + req.credentials && + (await this.auth.getPluginRequestToken({ + onBehalfOf: req.credentials, + targetPluginId: 'catalog', + })), + ); + + if (!entity) { + throw new Error( + `Couldn't find entity with name: ${stringifyEntityRef( + req.entityRef, + )}`, + ); + } + + const arnAnnotation = getOneOfEntityAnnotations(entity, [ + ECR_ARN_ANNOTATION, + ]); + const results = await this.ecrClient.send( + new DescribeImageScanFindingsCommand({ + imageId: { + imageDigest: req.imageDigest, + imageTag: req.imageTag, + }, + repositoryName: this.extractRepoName(arnAnnotation?.value as string), + maxResults: this.config.ecr.maxScanFindings, + }), + ); + return { + results: results.imageScanFindings as ImageScanFindings + } + } + + extractRepoName(ecrArn: string): string { + // Match the part after the last slash '/' in the arn + const match = ecrArn.match(/(?:\.amazonaws\.com\/)(.*)$/); + if (match) { + return match[1]; // This will return repository name + } + return ''; // If no match is found + } + +} + +export const ecrAwsServiceRef = + createServiceRef({ + id: 'ecr-aws.api', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + logger: coreServices.logger, + config: coreServices.rootConfig, + catalogApi: catalogServiceRef, + auth: coreServices.auth, + discovery: coreServices.discovery, + httpAuth: coreServices.httpAuth, + }, + async factory({ + logger, + config, + catalogApi, + auth, + httpAuth, + discovery, + }) { + const pluginConfig = readEcrConfig(config); + + const impl = await EcrAwsService.fromConfig( + pluginConfig, + { + catalogApi, + auth, + httpAuth, + discovery, + logger, + credentialsManager: + DefaultAwsCredentialsManager.fromConfig(config), + }, + ); + + return impl; + }, + }), + }); diff --git a/plugins/ecr/backend/src/service/index.ts b/plugins/ecr/backend/src/service/index.ts new file mode 100644 index 0000000..b4e9a9c --- /dev/null +++ b/plugins/ecr/backend/src/service/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EcrAwsService } from './EcrAwsService'; +export * from './types'; diff --git a/plugins/ecr/backend/src/service/router.ts b/plugins/ecr/backend/src/service/router.ts new file mode 100644 index 0000000..557c41f --- /dev/null +++ b/plugins/ecr/backend/src/service/router.ts @@ -0,0 +1,182 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createLegacyAuthAdapters, + errorHandler, +} from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { EcrScanAwsService } from './types'; +import { + AuthService, + CacheService, + coreServices, + createServiceFactory, + createServiceRef, + DiscoveryService, + HttpAuthService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { EcrCache } from '../cache'; +import { EcrConfig, readEcrConfig } from '../config'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha'; +import { EcrAwsService } from './EcrAwsService'; +import { DefaultAwsCredentialsManager } from '@backstage/integration-aws-node'; + +export interface RouterOptions { + logger: LoggerService; + ecrAwsService: EcrScanAwsService; + discovery: DiscoveryService; + auth?: AuthService; + httpAuth?: HttpAuthService; + cache: CacheService; + config: EcrConfig; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, ecrAwsService, config, cache } = options; + + const router = Router(); + router.use(express.json()); + + const { httpAuth } = createLegacyAuthAdapters(options); + + let cacheClient: EcrCache | undefined; + if (config.cache.enable) { + cacheClient = EcrCache.fromConfig(config, { cache, logger }); + + router.use((req, res, next) => { + const cacheKey = req.originalUrl; + + logger.debug(`Cache key ${cacheKey}`); + + if (cacheClient) { + cacheClient.get(cacheKey).then(e => { + if (e) { + res.send(JSON.parse(e)); + } else { + const originalJson = res.json; + res.json = data => { + if (cacheClient) { + cacheClient.set(cacheKey, JSON.stringify(data)); + } + return originalJson.call(res, data); + }; + next(); + } + }); + } + }); + } + + router.get('/v1/entity/:namespace/:kind/:name/images', async (req, res) => { + try { + const { namespace, kind, name } = req.params; + logger.debug(`Grabbing Images for ${namespace}:${kind}:${name}`) + const images = await ecrAwsService.listEcrImages( + { + entityRef: { + namespace, + kind, + name, + }, + credentials: await httpAuth.credentials(req), + }) + res.json(images); + } catch (error) { + logger.error(error) + res.json({ + error: error, + }) + } + }) + + router.get('/v1/entity/:namespace/:kind/:name/results', async (req, res) => { + try { + const imageTag = req.query?.imageTag as string; + const { namespace, kind, name } = req.params; + logger.debug(`Grabbing Scan Results for ${namespace}:${kind}:${name}:${imageTag}`) + const scanResults = await ecrAwsService.listScanResults( + { + entityRef: { + namespace, + kind, + name, + }, + credentials: await httpAuth.credentials(req), + imageTag, + }) + res.json(scanResults); + } catch (error) { + logger.error(error) + res.json({ + error: error, + }) + } + }) + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); + }); + router.use(errorHandler()); + return router; +} + +export const ecrAwsServiceRef = + createServiceRef({ + id: 'ecr-aws.api', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + logger: coreServices.logger, + config: coreServices.rootConfig, + catalogApi: catalogServiceRef, + auth: coreServices.auth, + discovery: coreServices.discovery, + httpAuth: coreServices.httpAuth, + }, + async factory({ + logger, + config, + catalogApi, + auth, + httpAuth, + discovery, + }) { + const pluginConfig = readEcrConfig(config); + + const impl = await EcrAwsService.fromConfig( + pluginConfig, + { + catalogApi, + auth, + httpAuth, + discovery, + logger, + credentialsManager: + DefaultAwsCredentialsManager.fromConfig(config), + }, + ); + + return impl; + }, + }), + }); + + +export * from './EcrAwsService'; diff --git a/plugins/ecr/backend/src/service/types.ts b/plugins/ecr/backend/src/service/types.ts new file mode 100644 index 0000000..156f284 --- /dev/null +++ b/plugins/ecr/backend/src/service/types.ts @@ -0,0 +1,52 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import { + ImageDetail, + ImageScanFindings, +} from "@aws-sdk/client-ecr" +import { BackstageCredentials } from "@backstage/backend-plugin-api/index"; +import { CompoundEntityRef } from '@backstage/catalog-model'; + +/** @public */ +export type AwsEcrListImagesRequest = { + entityRef: CompoundEntityRef; + credentials?: BackstageCredentials; +}; + +/** @public */ +export type AwsEcrListImagesResponse = { + items: ImageDetail[]; +}; + +export type AwsEcrListScanResultsRequest = { + entityRef: CompoundEntityRef; + credentials?: BackstageCredentials; + imageTag?: string; + imageDigest?: string; +}; + +export type AwsEcrListScanResultsResponse = { + results: ImageScanFindings +}; + +/** @public */ +export interface EcrScanAwsService { + listEcrImages( + req: AwsEcrListImagesRequest, + ): Promise; + listScanResults( + req: AwsEcrListScanResultsRequest, + ): Promise; +} \ No newline at end of file diff --git a/plugins/ecr/backend/src/setupTests.ts b/plugins/ecr/backend/src/setupTests.ts new file mode 100644 index 0000000..19d0e85 --- /dev/null +++ b/plugins/ecr/backend/src/setupTests.ts @@ -0,0 +1,14 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/plugins/ecr/frontend/.eslintrc.js b/plugins/ecr/frontend/.eslintrc.js new file mode 100644 index 0000000..e2a53a6 --- /dev/null +++ b/plugins/ecr/frontend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/ecr/frontend/README.md b/plugins/ecr/frontend/README.md new file mode 100644 index 0000000..07fb735 --- /dev/null +++ b/plugins/ecr/frontend/README.md @@ -0,0 +1,3 @@ +# ECR AWS plugin for Backstage - Frontend + +This a frontend plugin package for Backstage related to ECR for AWS. For more information see the [documentation](../README.md). diff --git a/plugins/ecr/frontend/dev/index.tsx b/plugins/ecr/frontend/dev/index.tsx new file mode 100644 index 0000000..35878ce --- /dev/null +++ b/plugins/ecr/frontend/dev/index.tsx @@ -0,0 +1,17 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { costInsightsAwsPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(costInsightsAwsPlugin).render(); diff --git a/plugins/ecr/frontend/package.json b/plugins/ecr/frontend/package.json new file mode 100644 index 0000000..3bfc3d4 --- /dev/null +++ b/plugins/ecr/frontend/package.json @@ -0,0 +1,76 @@ +{ + "name": "@aws/ecr-plugin-for-backstage", + "version": "0.2.0", + "repository": { + "type": "git", + "url": "github:awslabs/backstage-plugins-for-aws", + "directory": "plugins/ecs/frontend" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin", + "pluginId": "ecr-aws", + "pluginPackages": [ + "@aws/ecr-plugin-for-backstage", + "@aws/ecr-plugin-for-backstage-backend" + ] + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@aws-sdk/client-ecr": "^3.568.0", + "@aws-sdk/util-arn-parser": "^3.495.0", + "@aws/aws-core-plugin-for-backstage-common": "workspace:^", + "@aws/aws-core-plugin-for-backstage-react": "workspace:^", + "@aws/ecr-plugin-for-backstage-backend": "workspace:^", + "@backstage/catalog-model": "^1.7.1", + "@backstage/core-components": "^0.16.1", + "@backstage/core-plugin-api": "^1.10.1", + "@backstage/errors": "^1.2.5", + "@backstage/plugin-catalog-react": "^1.14.2", + "@backstage/theme": "^0.6.2", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.61", + "dateformat": "^5.0.3", + "humanize-duration": "^3.31.0", + "react-use": "^17.2.4", + "recharts": "^2.12.7" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "^0.29.2", + "@backstage/core-app-api": "^1.15.2", + "@backstage/dev-utils": "^1.1.4", + "@backstage/test-utils": "^1.7.2", + "@testing-library/dom": "^9.0.0", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.0.0", + "@types/dateformat": "^5", + "@types/humanize-duration": "^3", + "msw": "^1.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/ecr/frontend/src/api/AwsEcrApi.ts b/plugins/ecr/frontend/src/api/AwsEcrApi.ts new file mode 100644 index 0000000..419a12e --- /dev/null +++ b/plugins/ecr/frontend/src/api/AwsEcrApi.ts @@ -0,0 +1,17 @@ +import { createApiRef } from '@backstage/core-plugin-api'; +import { AwsEcrListImagesRequest, AwsEcrListImagesResponse, AwsEcrListScanResultsRequest, AwsEcrListScanResultsResponse } from '@aws/ecr-plugin-for-backstage-backend'; + +/** @public */ +export const awsEcrScanApiRef = createApiRef({ + id: 'plugin.aws-ecr-scan.service', +}); + +/** @public */ +export interface AwsEcrScanApi { + listEcrImages( + req: AwsEcrListImagesRequest, + ): Promise; + listScanResults( + req: AwsEcrListScanResultsRequest, + ): Promise; +} \ No newline at end of file diff --git a/plugins/ecr/frontend/src/api/AwsEcrClient.ts b/plugins/ecr/frontend/src/api/AwsEcrClient.ts new file mode 100644 index 0000000..11590c2 --- /dev/null +++ b/plugins/ecr/frontend/src/api/AwsEcrClient.ts @@ -0,0 +1,75 @@ +import { DiscoveryApi, IdentityApi } from "@backstage/core-plugin-api"; +import { AwsEcrScanApi } from "./AwsEcrApi"; + +export class AwsEcrClient implements AwsEcrScanApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + + public constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + } + public async listEcrImages(req: any): Promise { + try { + const urlSegment = `v1/entity/${encodeURIComponent( + req.entityRef.namespace, + )}/${encodeURIComponent(req.entityRef.kind)}/${encodeURIComponent( + req.entityRef.name, + )}/images`; + + + const items = await this.get(urlSegment); + if (!items.items) { + return { items: [] }; + } + return { + items: items.items + }; + } catch (error) { + console.error(error); + return { items: [] }; + } + } + public async listScanResults(req: any): Promise { + try { + const queryString = new URLSearchParams(); + queryString.append("imageTag", req.imageTag as string) + const urlSegment = `v1/entity/${encodeURIComponent( + req.entityRef.namespace, + )}/${encodeURIComponent(req.entityRef.kind)}/${encodeURIComponent( + req.entityRef.name, + )}/results?${queryString}`; + + const results = await this.get(urlSegment); + if (!results.results) { + return { results: {} }; + } + return { + results: results.results + }; + } catch (error) { + console.error(error); + return { results: {} }; + } + } + + private async get(path: string): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('ecr-aws')}/`; + const url = new URL(path, baseUrl); + + const { token: idToken } = await this.identityApi.getCredentials(); + const response = await fetch(url.toString(), { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }); + + if (!response.ok) { + throw Error(response.statusText); + } + + return response.json() as Promise; + } + +} \ No newline at end of file diff --git a/plugins/ecr/frontend/src/api/index.ts b/plugins/ecr/frontend/src/api/index.ts new file mode 100644 index 0000000..ad7b6bb --- /dev/null +++ b/plugins/ecr/frontend/src/api/index.ts @@ -0,0 +1,2 @@ +export * from "./AwsEcrApi" +export * from "./AwsEcrClient" \ No newline at end of file diff --git a/plugins/ecr/frontend/src/components/EntityEcrScanResultsContent/EntityEcrScanResultsContent.tsx b/plugins/ecr/frontend/src/components/EntityEcrScanResultsContent/EntityEcrScanResultsContent.tsx new file mode 100644 index 0000000..dc7280c --- /dev/null +++ b/plugins/ecr/frontend/src/components/EntityEcrScanResultsContent/EntityEcrScanResultsContent.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useState } from 'react'; +import { Grid, FormControl, InputLabel, MenuItem, Select, Divider } from '@material-ui/core'; +import { + Page, + Content, + ContentHeader, +} from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useImages } from '../../hooks/useImages'; +import { PieChart } from '../PieChart'; +import { ResultsTable } from '../ResultsTable'; +import { ImageDetail } from '@aws-sdk/client-ecr'; +import { useSearchParams } from 'react-router-dom'; + + +export const EntityEcrScanResultsContent = () => { + const { entity } = useEntity(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const [imageTag, setImageTag] = useState("") + + const handleChange = (event: any) => { + setSearchParams({ + imageTag: event.target.value, + }) + } + + const { images } = useImages(entity) + + useEffect(() => { + setImageTag(searchParams.get("imageTag") as string) + }, [searchParams]) + + + + return ( + + + + + + Images + + + + + + + + + + ) +} diff --git a/plugins/ecr/frontend/src/components/EntityEcrScanResultsContent/index.ts b/plugins/ecr/frontend/src/components/EntityEcrScanResultsContent/index.ts new file mode 100644 index 0000000..354a760 --- /dev/null +++ b/plugins/ecr/frontend/src/components/EntityEcrScanResultsContent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityEcrScanResultsContent } from './EntityEcrScanResultsContent'; diff --git a/plugins/ecr/frontend/src/components/PieChart/PieChart.tsx b/plugins/ecr/frontend/src/components/PieChart/PieChart.tsx new file mode 100644 index 0000000..3961aa9 --- /dev/null +++ b/plugins/ecr/frontend/src/components/PieChart/PieChart.tsx @@ -0,0 +1,59 @@ +/* eslint-disable guard-for-in */ +import React, { useState } from 'react'; +import { Content, GaugeCard } from "@backstage/core-components" +import { Grid } from "@material-ui/core"; +import { Entity, getCompoundEntityRef } from "@backstage/catalog-model"; +import { useApi } from "@backstage/core-plugin-api"; +import { awsEcrScanApiRef } from "../../api"; + +export const PieChart = (props: { entity: Entity, imageTag: string }) => { + const api = useApi(awsEcrScanApiRef); + + const [results, setResults] = useState() + + React.useEffect(() => { + async function getRes() { + const scanResults = await api.listScanResults({ + entityRef: getCompoundEntityRef(props.entity), + imageTag: props.imageTag, + }); + + return scanResults + + } + if (props.imageTag !== "") { + getRes().then(res => setResults(res)) + } + }, [api, props.entity, props.imageTag]) + + const data: {severity: string, count: number}[] = [] + let total = 0 + for (const severity in results?.results?.findingSeverityCounts) { + const count = results?.results.findingSeverityCounts?.[severity] as number + const sev = severity as string + data.push({ + severity: sev, + count: count + }) + total += count + } + return ( + + + {data.map(severity => { + return( + + + + ) + })} + + + ) +} \ No newline at end of file diff --git a/plugins/ecr/frontend/src/components/PieChart/index.ts b/plugins/ecr/frontend/src/components/PieChart/index.ts new file mode 100644 index 0000000..121f9a3 --- /dev/null +++ b/plugins/ecr/frontend/src/components/PieChart/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { PieChart } from './PieChart'; diff --git a/plugins/ecr/frontend/src/components/ResultsTable/ResultsTable.tsx b/plugins/ecr/frontend/src/components/ResultsTable/ResultsTable.tsx new file mode 100644 index 0000000..057734b --- /dev/null +++ b/plugins/ecr/frontend/src/components/ResultsTable/ResultsTable.tsx @@ -0,0 +1,81 @@ +import { + Table, + TableColumn, +} from "@backstage/core-components"; +import * as React from 'react'; +import { ImageScanFinding } from "@aws-sdk/client-ecr" +import { Entity, getCompoundEntityRef } from "@backstage/catalog-model"; +import { useApi } from "@backstage/core-plugin-api"; +import { awsEcrScanApiRef } from "../../api"; +import { Typography } from "@material-ui/core"; + +export const ResultsTable = (props: {imageTag: string, entity: Entity}) => { + const [results, setResults] = React.useState() + const [columns, setColumns] = React.useState([ + { + title: 'Name', + field: 'name', + width: '25%', + }, + { title: 'Severity', + field: 'severity', + width: '20%', + defaultSort: 'desc', + }, + { title: 'Description', + field: 'description', + width: '30%' + }, + ]); + const api = useApi(awsEcrScanApiRef); + + React.useEffect(() => { + async function getRes() { + const scanResults = await api.listScanResults({ + entityRef: getCompoundEntityRef(props.entity), + imageTag: props.imageTag, + }); + + return scanResults + + } + if (props.imageTag !== "") { + getRes().then(res => { + if (!!res?.results?.enhancedFindings?.length) { + const newColumns = [...columns]; + newColumns[0].field = "title"; + setColumns(newColumns); + } + setResults(res) + }) + } + }, [api, props.entity, props.imageTag]) + + return !!results?.results ? ( + <> + {!results?.results?.enhancedFindings?.length && + No Findings Yet + } + {!!results?.results?.enhancedFindings?.length && + + } + + ) : <> +} \ No newline at end of file diff --git a/plugins/ecr/frontend/src/components/ResultsTable/index.ts b/plugins/ecr/frontend/src/components/ResultsTable/index.ts new file mode 100644 index 0000000..e7a0c12 --- /dev/null +++ b/plugins/ecr/frontend/src/components/ResultsTable/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { ResultsTable } from './ResultsTable'; \ No newline at end of file diff --git a/plugins/ecr/frontend/src/hooks/useImageScanResults.ts b/plugins/ecr/frontend/src/hooks/useImageScanResults.ts new file mode 100644 index 0000000..f8e428a --- /dev/null +++ b/plugins/ecr/frontend/src/hooks/useImageScanResults.ts @@ -0,0 +1,27 @@ +import { Entity, getCompoundEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; +import { awsEcrScanApiRef } from '../api'; +import { ECR_ANNOTATION } from '../plugin'; + + +export const useImageScanResults = ( + entity: Entity, + imageTag: string, +): { results?: any; error?: Error; loading: boolean } => { + const api = useApi(awsEcrScanApiRef); + const componentKey = entity.metadata?.annotations?.[ECR_ANNOTATION] as string; + + const { value, loading, error } = useAsync(() => { + return api.listScanResults({ + entityRef: getCompoundEntityRef(entity), + imageTag: imageTag, + }); + }, [componentKey]); + + return { + results: value, + loading, + error, + }; +} \ No newline at end of file diff --git a/plugins/ecr/frontend/src/hooks/useImages.ts b/plugins/ecr/frontend/src/hooks/useImages.ts new file mode 100644 index 0000000..2f455cc --- /dev/null +++ b/plugins/ecr/frontend/src/hooks/useImages.ts @@ -0,0 +1,41 @@ +import { Entity, getCompoundEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { ImageDetail } from '@aws-sdk/client-ecr'; +import { useAsync } from 'react-use'; +import { awsEcrScanApiRef } from '../api'; +import { ECR_ANNOTATION } from '../plugin'; + +export const useImages = ( + entity: Entity, +): { images?: any; error?: Error; loading: boolean } => { + const api = useApi(awsEcrScanApiRef); + const componentKey = entity.metadata?.annotations?.[ECR_ANNOTATION] as string; + + const { value, loading, error } = useAsync(() => { + return api.listEcrImages({ + entityRef: getCompoundEntityRef(entity), + }); + }, [componentKey]); + + const items = value?.items.sort((a: ImageDetail, b: ImageDetail) => { + if (a.imagePushedAt === undefined) { + return 1; + } + + if (b.imagePushedAt === undefined) { + return -1; + } + const a1 = new Date(a.imagePushedAt).getTime(); + const b1 = new Date(b.imagePushedAt).getTime(); + + return b1 - a1; + }) as ImageDetail[] + + return { + images: { + items, + }, + loading, + error, + }; +} \ No newline at end of file diff --git a/plugins/ecr/frontend/src/index.ts b/plugins/ecr/frontend/src/index.ts new file mode 100644 index 0000000..c78902c --- /dev/null +++ b/plugins/ecr/frontend/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { awsEcrScanPlugin, EntityEcrScanResultsContent, isAwsEcrScanResultsAvailable } from './plugin'; diff --git a/plugins/ecr/frontend/src/plugin.test.ts b/plugins/ecr/frontend/src/plugin.test.ts new file mode 100644 index 0000000..c9202e3 --- /dev/null +++ b/plugins/ecr/frontend/src/plugin.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { awsEcrScanPlugin, EntityEcrScanResultsContent } from './plugin'; + +describe('aws-ecr-scan', () => { + it('should export plugin', () => { + expect(awsEcrScanPlugin).toBeDefined(); + }); + it('should export page', () => { + expect(EntityEcrScanResultsContent).toBeDefined(); + }); +}); diff --git a/plugins/ecr/frontend/src/plugin.ts b/plugins/ecr/frontend/src/plugin.ts new file mode 100644 index 0000000..199ae30 --- /dev/null +++ b/plugins/ecr/frontend/src/plugin.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity } from '@backstage/catalog-model'; +import { + createApiFactory, + createPlugin, + createComponentExtension, + discoveryApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { AwsEcrClient, awsEcrScanApiRef } from './api'; + +export const ECR_ANNOTATION = 'aws.amazon.com/aws-ecr-repository-arn'; + +export const isAwsEcrScanResultsAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[ECR_ANNOTATION]); + +export const awsEcrScanPlugin = createPlugin({ + id: 'aws-ecr-scan', + apis: [ + createApiFactory({ + api: awsEcrScanApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new AwsEcrClient({ discoveryApi, identityApi }), + }), + ], +}); + +export const EntityEcrScanResultsContent = awsEcrScanPlugin.provide( + createComponentExtension({ + name: 'AwsEcrScanTab', + component: { + lazy: () => + import('./components/EntityEcrScanResultsContent').then( + m => m.EntityEcrScanResultsContent, + ), + }, + }), +); diff --git a/plugins/ecr/frontend/src/routes.ts b/plugins/ecr/frontend/src/routes.ts new file mode 100644 index 0000000..4fadf85 --- /dev/null +++ b/plugins/ecr/frontend/src/routes.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'ecr-aws', +}); \ No newline at end of file diff --git a/plugins/ecr/frontend/src/setupTests.ts b/plugins/ecr/frontend/src/setupTests.ts new file mode 100644 index 0000000..9bb3e72 --- /dev/null +++ b/plugins/ecr/frontend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/yarn.lock b/yarn.lock index 1b3baac..58e4766 100644 --- a/yarn.lock +++ b/yarn.lock @@ -691,6 +691,56 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-ecr@npm:^3.568.0": + version: 3.699.0 + resolution: "@aws-sdk/client-ecr@npm:3.699.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/client-sso-oidc": "npm:3.699.0" + "@aws-sdk/client-sts": "npm:3.699.0" + "@aws-sdk/core": "npm:3.696.0" + "@aws-sdk/credential-provider-node": "npm:3.699.0" + "@aws-sdk/middleware-host-header": "npm:3.696.0" + "@aws-sdk/middleware-logger": "npm:3.696.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.696.0" + "@aws-sdk/middleware-user-agent": "npm:3.696.0" + "@aws-sdk/region-config-resolver": "npm:3.696.0" + "@aws-sdk/types": "npm:3.696.0" + "@aws-sdk/util-endpoints": "npm:3.696.0" + "@aws-sdk/util-user-agent-browser": "npm:3.696.0" + "@aws-sdk/util-user-agent-node": "npm:3.696.0" + "@smithy/config-resolver": "npm:^3.0.12" + "@smithy/core": "npm:^2.5.3" + "@smithy/fetch-http-handler": "npm:^4.1.1" + "@smithy/hash-node": "npm:^3.0.10" + "@smithy/invalid-dependency": "npm:^3.0.10" + "@smithy/middleware-content-length": "npm:^3.0.12" + "@smithy/middleware-endpoint": "npm:^3.2.3" + "@smithy/middleware-retry": "npm:^3.0.27" + "@smithy/middleware-serde": "npm:^3.0.10" + "@smithy/middleware-stack": "npm:^3.0.10" + "@smithy/node-config-provider": "npm:^3.1.11" + "@smithy/node-http-handler": "npm:^3.3.1" + "@smithy/protocol-http": "npm:^4.1.7" + "@smithy/smithy-client": "npm:^3.4.4" + "@smithy/types": "npm:^3.7.1" + "@smithy/url-parser": "npm:^3.0.10" + "@smithy/util-base64": "npm:^3.0.0" + "@smithy/util-body-length-browser": "npm:^3.0.0" + "@smithy/util-body-length-node": "npm:^3.0.0" + "@smithy/util-defaults-mode-browser": "npm:^3.0.27" + "@smithy/util-defaults-mode-node": "npm:^3.0.27" + "@smithy/util-endpoints": "npm:^2.1.6" + "@smithy/util-middleware": "npm:^3.0.10" + "@smithy/util-retry": "npm:^3.0.10" + "@smithy/util-utf8": "npm:^3.0.0" + "@smithy/util-waiter": "npm:^3.1.9" + tslib: "npm:^2.6.2" + checksum: 10c0/2baa287019d591231528c870a144ab76b53efe408285e03509c80a3910692a3a360922de65a8988dbcac57e55fbd4462bf127eab4e0d8cfda917c42702dfa8a8 + languageName: node + linkType: hard + "@aws-sdk/client-ecs@npm:^3.511.0": version: 3.699.0 resolution: "@aws-sdk/client-ecs@npm:3.699.0" @@ -2424,6 +2474,85 @@ __metadata: languageName: unknown linkType: soft +"@aws/ecr-plugin-for-backstage-backend@workspace:^, @aws/ecr-plugin-for-backstage-backend@workspace:plugins/ecr/backend": + version: 0.0.0-use.local + resolution: "@aws/ecr-plugin-for-backstage-backend@workspace:plugins/ecr/backend" + dependencies: + "@aws-sdk/client-ecr": "npm:^3.568.0" + "@aws-sdk/middleware-sdk-sts": "npm:^3.523.0" + "@aws-sdk/types": "npm:^3.609.0" + "@aws-sdk/util-arn-parser": "npm:^3.495.0" + "@aws/aws-core-plugin-for-backstage-common": "workspace:^" + "@backstage/backend-common": "npm:^0.25.0" + "@backstage/backend-defaults": "npm:^0.5.3" + "@backstage/backend-plugin-api": "npm:^1.0.2" + "@backstage/backend-test-utils": "npm:^1.1.0" + "@backstage/catalog-client": "npm:^1.8.0" + "@backstage/catalog-model": "npm:^1.7.1" + "@backstage/cli": "npm:^0.29.2" + "@backstage/config": "npm:^1.3.0" + "@backstage/errors": "npm:^1.2.5" + "@backstage/integration-aws-node": "npm:^0.1.13" + "@backstage/plugin-auth-backend": "npm:^0.24.0" + "@backstage/plugin-auth-backend-module-guest-provider": "npm:^0.2.2" + "@backstage/plugin-catalog-node": "npm:^1.14.0" + "@types/express": "npm:*" + "@types/luxon": "npm:^3" + "@types/regression": "npm:^2" + "@types/supertest": "npm:^2.0.12" + aws-sdk-client-mock: "npm:^4.0.0" + express: "npm:^4.17.1" + express-promise-router: "npm:^4.1.0" + luxon: "npm:^3.4.4" + msw: "npm:^1.0.0" + node-fetch: "npm:^2.6.7" + regression: "npm:^2.0.1" + supertest: "npm:^6.2.4" + winston: "npm:^3.2.1" + yn: "npm:^4.0.0" + languageName: unknown + linkType: soft + +"@aws/ecr-plugin-for-backstage@workspace:^, @aws/ecr-plugin-for-backstage@workspace:plugins/ecr/frontend": + version: 0.0.0-use.local + resolution: "@aws/ecr-plugin-for-backstage@workspace:plugins/ecr/frontend" + dependencies: + "@aws-sdk/client-ecr": "npm:^3.568.0" + "@aws-sdk/util-arn-parser": "npm:^3.495.0" + "@aws/aws-core-plugin-for-backstage-common": "workspace:^" + "@aws/aws-core-plugin-for-backstage-react": "workspace:^" + "@aws/ecr-plugin-for-backstage-backend": "workspace:^" + "@backstage/catalog-model": "npm:^1.7.1" + "@backstage/cli": "npm:^0.29.2" + "@backstage/core-app-api": "npm:^1.15.2" + "@backstage/core-components": "npm:^0.16.1" + "@backstage/core-plugin-api": "npm:^1.10.1" + "@backstage/dev-utils": "npm:^1.1.4" + "@backstage/errors": "npm:^1.2.5" + "@backstage/plugin-catalog-react": "npm:^1.14.2" + "@backstage/test-utils": "npm:^1.7.2" + "@backstage/theme": "npm:^0.6.2" + "@material-ui/core": "npm:^4.12.2" + "@material-ui/icons": "npm:^4.9.1" + "@material-ui/lab": "npm:4.0.0-alpha.61" + "@testing-library/dom": "npm:^9.0.0" + "@testing-library/jest-dom": "npm:^6.0.0" + "@testing-library/react": "npm:^14.0.0" + "@testing-library/user-event": "npm:^14.0.0" + "@types/dateformat": "npm:^5" + "@types/humanize-duration": "npm:^3" + dateformat: "npm:^5.0.3" + humanize-duration: "npm:^3.31.0" + msw: "npm:^1.0.0" + react-use: "npm:^17.2.4" + recharts: "npm:^2.12.7" + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@azure/abort-controller@npm:^1.0.0": version: 1.1.0 resolution: "@azure/abort-controller@npm:1.1.0" @@ -16559,6 +16688,7 @@ __metadata: "@aws/aws-codebuild-plugin-for-backstage": "workspace:^" "@aws/aws-codepipeline-plugin-for-backstage": "workspace:^" "@aws/cost-insights-plugin-for-backstage": "workspace:^" + "@aws/ecr-plugin-for-backstage": "workspace:^" "@backstage-community/plugin-cost-insights": "npm:^0.12.25" "@backstage-community/plugin-github-actions": "npm:^0.6.16" "@backstage-community/plugin-tech-radar": "npm:^0.7.4" @@ -17392,6 +17522,7 @@ __metadata: "@aws/aws-codepipeline-plugin-for-backstage-backend": "workspace:^" "@aws/aws-core-plugin-for-backstage-scaffolder-actions": "workspace:^" "@aws/cost-insights-plugin-for-backstage-backend": "workspace:^" + "@aws/ecr-plugin-for-backstage-backend": "workspace:^" "@backstage/backend-defaults": "npm:^0.5.3" "@backstage/backend-plugin-api": "npm:^1.0.2" "@backstage/catalog-client": "npm:^1.8.0"