11 ยท A Supply-Chain-Secure CI/CD Pipeline: SBOM, Scanning and Signing

Code Repository / projects / 11-secure-supply-chain-cicd
Project 11 ยท AWS CDK (TypeScript)

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.json for every build (SPDX 2.3)
  • CodeBuild logs with the scan table, and CloudTrail SignPayload events per signature

Clean up

npx cdk destroy   # the evidence bucket is retained on purpose

Files (9)

README.md ยท 52 lines
# 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
```
bin/app.ts ยท 12 lines
#!/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',
});
lib/supply-chain-stack.ts ยท 177 lines
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 });
  }
}
kubernetes/kyverno-verify-aws-signer.yaml ยท 35 lines
# 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
sample-app/app.py ยท 22 lines
"""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()
sample-app/Dockerfile ยท 12 lines
# 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"]
cdk.json ยท 6 lines
{
  "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": {}
}
package.json ยท 23 lines
{
  "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"
  }
}
tsconfig.json ยท 14 lines
{
  "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.