Every image is inventoried, vulnerability-gated, cryptographically signed and verified before it can run.
README
Format: AWS CDK (TypeScript) ยท Est. cost: $1/month idle + CodeBuild minutes ($0.01/min); Signer ~free at low volume; Inspector enhanced scanning optional
Every image that leaves this pipeline is inventoried (SBOM), vulnerability-gated, signed, and verified, and the gates are defined in the platform's CDK code, not in the app repo, so nobody can quietly delete them.
source.zip โโถ CodePipeline V2
โโ BuildScanSign (CodeBuild, privileged)
โ docker build โโถ syft SBOM โโถ grype --fail-on high โโถ push (immutable tag)
โ โโถ notation sign <digest> with AWS Signer โโถ SBOM to evidence bucket
โโ VerifySignature (CodeBuild)
notation verify <digest> against trust policy (strict) โโถ ready to deploy
EKS admission: kubernetes/kyverno-verify-aws-signer.yaml blocks unsigned images at runtime
ECR: scan-on-push + (optional) Amazon Inspector continuous rescans
Deploy
npm install
npx cdk deploy
# optional account-wide continuous scanning + stricter gate:
npx cdk deploy -c enhancedScanning=true -c failOnSeverity=medium
Run it
cd sample-app && zip -r ../source.zip . && cd ..
aws s3 cp source.zip s3://<SourceBucketName>/source.zip
aws codepipeline get-pipeline-state --name secure-supply-chain --query 'stageStates[].[stageName,latestExecution.status]'
Prove the gate works
Change the Dockerfile base to an old image (e.g. python:3.8-slim-buster), push source.zip again and watch
BuildScanSign fail with a table of HIGH/CRITICAL CVEs. Nothing reaches ECR.
Prove the signature works
notation inspect <RepositoryUri>@sha256:... # shows the AWS Signer signature
# push an unsigned image with the same repo name to a test tag -> Kyverno rejects the pod
Evidence for auditors
s3://<EvidenceBucketName>/sbom/<tag>.spdx.jsonfor every build (SPDX 2.3)- CodeBuild logs with the scan table, and CloudTrail
SignPayloadevents per signature
Clean up
npx cdk destroy # the evidence bucket is retained on purpose
Files (9)
- README.md
- bin/app.ts
- lib/supply-chain-stack.ts
- kubernetes/kyverno-verify-aws-signer.yaml
- sample-app/app.py
- sample-app/Dockerfile
- cdk.json
- package.json
- tsconfig.json
# 11 ยท Supply-Chain-Secure CI/CD Pipeline
**Format:** AWS CDK (TypeScript) ยท **Est. cost:** ~$1/month idle + CodeBuild minutes (~$0.01/min); Signer ~free at low volume; Inspector enhanced scanning optional
Every image that leaves this pipeline is **inventoried (SBOM), vulnerability-gated, signed, and verified**, and the
gates are defined in the platform's CDK code, not in the app repo, so nobody can quietly delete them.
```
source.zip โโถ CodePipeline V2
โโ BuildScanSign (CodeBuild, privileged)
โ docker build โโถ syft SBOM โโถ grype --fail-on high โโถ push (immutable tag)
โ โโถ notation sign <digest> with AWS Signer โโถ SBOM to evidence bucket
โโ VerifySignature (CodeBuild)
notation verify <digest> against trust policy (strict) โโถ ready to deploy
EKS admission: kubernetes/kyverno-verify-aws-signer.yaml blocks unsigned images at runtime
ECR: scan-on-push + (optional) Amazon Inspector continuous rescans
```
## Deploy
```bash
npm install
npx cdk deploy
# optional account-wide continuous scanning + stricter gate:
npx cdk deploy -c enhancedScanning=true -c failOnSeverity=medium
```
## Run it
```bash
cd sample-app && zip -r ../source.zip . && cd ..
aws s3 cp source.zip s3://<SourceBucketName>/source.zip
aws codepipeline get-pipeline-state --name secure-supply-chain --query 'stageStates[].[stageName,latestExecution.status]'
```
## Prove the gate works
Change the Dockerfile base to an old image (e.g. `python:3.8-slim-buster`), push `source.zip` again and watch
**BuildScanSign fail** with a table of HIGH/CRITICAL CVEs. Nothing reaches ECR.
## Prove the signature works
```bash
notation inspect <RepositoryUri>@sha256:... # shows the AWS Signer signature
# push an unsigned image with the same repo name to a test tag -> Kyverno rejects the pod
```
## Evidence for auditors
- `s3://<EvidenceBucketName>/sbom/<tag>.spdx.json` for every build (SPDX 2.3)
- CodeBuild logs with the scan table, and CloudTrail `SignPayload` events per signature
## Clean up
```bash
npx cdk destroy # the evidence bucket is retained on purpose
```
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { SupplyChainStack } from '../lib/supply-chain-stack';
const app = new cdk.App();
new SupplyChainStack(app, 'SecureSupplyChain', {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
enableEnhancedScanning: app.node.tryGetContext('enhancedScanning') === 'true',
// Fail the build at or above this severity: negligible|low|medium|high|critical
failOnSeverity: app.node.tryGetContext('failOnSeverity') ?? 'high',
});
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as codepipeline from 'aws-cdk-lib/aws-codepipeline';
import * as actions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as signer from 'aws-cdk-lib/aws-signer';
export interface SupplyChainStackProps extends cdk.StackProps {
enableEnhancedScanning: boolean;
failOnSeverity: string;
}
const SIGNER_PLUGIN_RPM = 'https://d2hvyiie56hcat.cloudfront.net/linux/amd64/installer/rpm/latest/aws-signer-notation-cli_amd64.rpm';
/**
* Source -> Build + SBOM + vulnerability gate + sign -> Verify signature -> (deploy).
* The buildspecs live HERE, owned by the platform team, so app teams cannot skip a gate.
*/
export class SupplyChainStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: SupplyChainStackProps) {
super(scope, id, props);
const repo = new ecr.Repository(this, 'AppRepo', {
repositoryName: 'secure-supply-chain/app',
imageScanOnPush: true,
imageTagMutability: ecr.TagMutability.IMMUTABLE,
encryption: ecr.RepositoryEncryption.KMS,
lifecycleRules: [{ maxImageCount: 50 }],
emptyOnDelete: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
if (props.enableEnhancedScanning) {
// Account-wide: Amazon Inspector continuously rescans images as new CVEs are published.
new ecr.CfnRegistryScanningConfiguration(this, 'EnhancedScanning', {
scanType: 'ENHANCED',
rules: [{ scanFrequency: 'CONTINUOUS_SCAN', repositoryFilters: [{ filter: '*', filterType: 'WILDCARD' }] }],
});
}
const profile = new signer.SigningProfile(this, 'SigningProfile', {
platform: signer.Platform.NOTATION_OCI_SHA384_ECDSA,
signatureValidity: cdk.Duration.days(365),
});
const source = new s3.Bucket(this, 'SourceBucket', {
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const evidence = new s3.Bucket(this, 'EvidenceBucket', {
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
});
const commonEnv = {
REPO_URI: { value: repo.repositoryUri },
SIGNER_PROFILE_ARN: { value: profile.signingProfileArn },
EVIDENCE_BUCKET: { value: evidence.bucketName },
FAIL_ON: { value: props.failOnSeverity },
};
const installNotation = [
'curl -sSfL -o /tmp/signer.rpm ' + SIGNER_PLUGIN_RPM,
'rpm -U /tmp/signer.rpm || dnf install -y /tmp/signer.rpm',
'notation version && notation plugin ls',
];
// ---------------------------------------------------------------- build, SBOM, scan, sign
const build = new codebuild.PipelineProject(this, 'BuildScanSign', {
projectName: 'supply-chain-build-scan-sign',
environment: { buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5, privileged: true },
environmentVariables: commonEnv,
timeout: cdk.Duration.minutes(30),
buildSpec: codebuild.BuildSpec.fromObject({
version: '0.2',
phases: {
install: {
commands: [
'curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin',
'curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin',
...installNotation,
],
},
pre_build: {
commands: [
'aws ecr get-login-password | docker login --username AWS --password-stdin ${REPO_URI%%/*}',
'TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c1-12)-$CODEBUILD_BUILD_NUMBER',
],
},
build: {
commands: [
'docker build --pull --label org.opencontainers.image.revision=$CODEBUILD_RESOLVED_SOURCE_VERSION -t $REPO_URI:$TAG .',
// 1. SBOM - what is inside
'syft $REPO_URI:$TAG -o spdx-json=sbom.spdx.json -o table',
// 2. Gate - stop known-vulnerable images before they ever reach the registry
'grype sbom:sbom.spdx.json --fail-on $FAIL_ON -o table',
'docker push $REPO_URI:$TAG',
'DIGEST=$(docker inspect --format="{{index .RepoDigests 0}}" $REPO_URI:$TAG)',
// 3. Sign the immutable digest with AWS Signer
'notation sign $DIGEST --plugin com.amazonaws.signer.notation.plugin --id $SIGNER_PROFILE_ARN',
'aws s3 cp sbom.spdx.json s3://$EVIDENCE_BUCKET/sbom/$TAG.spdx.json',
'printf \'{"imageUri":"%s","tag":"%s"}\' "$DIGEST" "$TAG" > image.json',
],
},
},
artifacts: { files: ['image.json', 'sbom.spdx.json'] },
}),
});
repo.grantPullPush(build);
evidence.grantPut(build);
build.addToRolePolicy(new iam.PolicyStatement({ actions: ['signer:SignPayload', 'signer:GetSigningProfile'], resources: [profile.signingProfileArn] }));
// ---------------------------------------------------------------- verify before deploy
const verify = new codebuild.PipelineProject(this, 'VerifySignature', {
projectName: 'supply-chain-verify',
environment: { buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5 },
environmentVariables: commonEnv,
buildSpec: codebuild.BuildSpec.fromObject({
version: '0.2',
phases: {
install: { commands: [...installNotation, 'dnf install -y jq || yum install -y jq'] },
build: {
commands: [
'IMAGE=$(jq -r .imageUri image.json)',
'aws ecr get-login-password | notation login --username AWS --password-stdin ${REPO_URI%%/*}',
'cat > trustpolicy.json <<EOF\n' +
'{"version":"1.0","trustPolicies":[{"name":"aws-signer","registryScopes":["*"],' +
'"signatureVerification":{"level":"strict"},"trustStores":["signingAuthority:aws-signer-ts"],' +
'"trustedIdentities":["$SIGNER_PROFILE_ARN"]}]}\nEOF',
'notation policy import --force trustpolicy.json',
'notation verify $IMAGE',
'echo "VERIFIED $IMAGE - signed by $SIGNER_PROFILE_ARN"',
],
},
},
}),
});
repo.grantPull(verify);
verify.addToRolePolicy(new iam.PolicyStatement({ actions: ['signer:GetRevocationStatus'], resources: ['*'] }));
// ---------------------------------------------------------------- pipeline
const sourceOut = new codepipeline.Artifact('Source');
const buildOut = new codepipeline.Artifact('Image');
new codepipeline.Pipeline(this, 'Pipeline', {
pipelineName: 'secure-supply-chain',
pipelineType: codepipeline.PipelineType.V2,
stages: [
{
stageName: 'Source',
actions: [new actions.S3SourceAction({ actionName: 'Source', bucket: source, bucketKey: 'source.zip', output: sourceOut })],
},
{
stageName: 'BuildScanSign',
actions: [new actions.CodeBuildAction({ actionName: 'BuildScanSign', project: build, input: sourceOut, outputs: [buildOut] })],
},
{
stageName: 'VerifySignature',
actions: [new actions.CodeBuildAction({ actionName: 'Verify', project: verify, input: buildOut })],
},
],
});
new cdk.CfnOutput(this, 'SourceBucketName', { value: source.bucketName });
new cdk.CfnOutput(this, 'RepositoryUri', { value: repo.repositoryUri });
new cdk.CfnOutput(this, 'SigningProfileArn', { value: profile.signingProfileArn });
new cdk.CfnOutput(this, 'EvidenceBucketName', { value: evidence.bucketName });
}
}
# Admission control for EKS: refuse any pod whose image from our ECR repo is not
# signed by our AWS Signer profile. Requires Kyverno + the AWS Signer Notation plugin
# (or the "Kyverno Notation AWS" integration) installed in the cluster.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-aws-signer-signature
annotations:
policies.kyverno.io/title: Only run images signed by AWS Signer
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
rules:
- name: require-aws-signer-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- type: Notary
imageReferences:
- "*.dkr.ecr.*.amazonaws.com/secure-supply-chain/app*"
attestors:
- entries:
- certificates:
# Replace with the AWS Signer root certificate for your partition
# (download: https://d2hvyiie56hcat.cloudfront.net/aws-signer-notation-root.cert)
cert: |-
-----BEGIN CERTIFICATE-----
REPLACE_WITH_AWS_SIGNER_ROOT_CERT
-----END CERTIFICATE-----
mutateDigest: true
verifyDigest: true
required: true
"""Tiny dependency-free web app used to exercise the secure pipeline."""
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import os
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({"status": "ok", "revision": os.environ.get("REVISION", "dev")}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_):
pass
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
# Minimal, non-root, distroless-style image: small attack surface = fewer CVEs to gate on.
FROM public.ecr.aws/docker/library/python:3.12-slim AS build
WORKDIR /app
COPY app.py .
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=build /app/app.py .
EXPOSE 8080
USER nonroot
ENTRYPOINT ["python3", "app.py"]
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": { "include": ["**"], "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules", "cdk.out"] },
"context": {}
}
{
"name": "11-secure-supply-chain-cicd",
"version": "1.0.0",
"private": true,
"bin": { "app": "bin/app.js" },
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"deploy": "cdk deploy --all",
"destroy": "cdk destroy --all"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1000.0",
"ts-node": "^10.9.2",
"typescript": "~5.6.3"
},
"dependencies": {
"aws-cdk-lib": "^2.200.0",
"constructs": "^10.4.2"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["es2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"exclude": ["node_modules", "cdk.out"]
}
Build it yourself
Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.