diff --git a/new-components/docker-compose.yaml b/new-components/docker-compose.yaml index ee699a97d..3d70417c3 100644 --- a/new-components/docker-compose.yaml +++ b/new-components/docker-compose.yaml @@ -1,5 +1,5 @@ services: - reporter: + json-logger-reporter: build: context: . dockerfile: Dockerfile @@ -12,6 +12,16 @@ services: depends_on: enricher: condition: service_completed_successfully + pdf-reporter: + build: + context: reporters/pdf + dockerfile: Dockerfile + platform: linux/amd64 + env_file: + - reporters/pdf/.env + depends_on: + enricher: + condition: service_completed_successfully enricher: build: context: . diff --git a/new-components/reporters/pdf/.env b/new-components/reporters/pdf/.env new file mode 100644 index 000000000..e92fc0908 --- /dev/null +++ b/new-components/reporters/pdf/.env @@ -0,0 +1,10 @@ +# This is for local setup only. +SMITHY_INSTANCE_ID=8d719c1c-c569-4078-87b3-4951bd4012ee +SMITHY_LOG_LEVEL=debug +AWS_ACCESS_KEY_ID='' +AWS_SECRET_ACCESS_KEY='' +BUCKET_NAME='' +BUCKET_REGION='' +SKIP_S3_UPLOAD=true +SMITHY_STORE_TYPE=postgresql +SMITHY_REMOTE_STORE_POSTGRES_DSN="postgresql://smithy:smithy1234@findings-db:5432/findings-db?sslmode=disable&connect_timeout=10" diff --git a/new-components/reporters/pdf/Dockerfile b/new-components/reporters/pdf/Dockerfile new file mode 100644 index 000000000..ec2c256f1 --- /dev/null +++ b/new-components/reporters/pdf/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.23.3 AS builder +COPY . /workdir +WORKDIR /workdir +# Install Playwright CLI with the correct version +RUN go install github.com/playwright-community/playwright-go/cmd/playwright@v0.4901.0 +# Build your Go application +RUN GOOS=linux GOARCH=amd64 go build -o /bin/reporter cmd/main.go + +# Stage 3: Final image +FROM ubuntu:22.04 + +COPY --from=builder /bin/reporter / +COPY --from=builder /go/ /go/ + +RUN apt-get update +RUN apt-get install -y ca-certificates tzdata +RUN ./go/bin/playwright install chromium --with-deps +RUN rm -rf /var/lib/apt/lists/* + +CMD ["/reporter"] diff --git a/new-components/reporters/pdf/README.md b/new-components/reporters/pdf/README.md new file mode 100644 index 000000000..f0b2534d6 --- /dev/null +++ b/new-components/reporters/pdf/README.md @@ -0,0 +1,41 @@ +# PDF + +This component implements a [reporter](https://github.com/smithy-security/smithy/blob/main/sdk/component/component.go) +that prints vulnerability findings into a PDF and uploads it to an AWS +S3 bucket. + +## Environment variables + +The component uses environment variables for configuration. + +It requires the component +environment variables defined +[here](https://github.com/smithy-security/smithy/blob/main/sdk/README.md#component) +as the following: + +* `CONSUMER_PDF_S3\_ACCESS_KEY_ID` - **string, required** + * Your S3 access key + ID for a user that has write access to the bucket +* `CONSUMER_PDF_S3\_ACCESS_KEY` - **string, required** + * Your S3 access key for a user that has write access to the bucket +* `CONSUMER_PDF_S3\_BUCKET_NAME` - **string, required** + * Your S3 bucket name, e.g. "test-bucket" +* `CONSUMER_PDF_S3_BUCKET_REGION` - **string, required** + * Your S3 bucket region, e.g. "us-west-1" + +On AWS, you will need a new IAM user with programmatic access and\ +with write permissions for your S3 bucket. + +## How to run + +Execute: + +```shell +docker-compose up --build --force-recreate --remove-orphans +``` + +Then shutdown with: + +```shell +docker-compose down --rmi all +``` diff --git a/new-components/reporters/pdf/cmd/main.go b/new-components/reporters/pdf/cmd/main.go new file mode 100644 index 000000000..5e3d2acaf --- /dev/null +++ b/new-components/reporters/pdf/cmd/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log" + "time" + + "github.com/go-errors/errors" + + "github.com/smithy-security/smithy/new-components/reporters/pdf/internal/reporter" + "github.com/smithy-security/smithy/sdk/component" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + if err := Main(ctx); err != nil { + log.Fatalf("unexpected error: %v", err) + } +} + +func Main(ctx context.Context, opts ...component.RunnerOption) error { + conf, err := reporter.NewConf(nil) + if err != nil { + return errors.Errorf("could not create new configuration: %w", err) + } + + opts = append(opts, component.RunnerWithComponentName("pdf")) + + if err := component.RunReporter( + ctx, + reporter.NewReporter(conf), + opts..., + ); err != nil { + return errors.Errorf("could not run reporter: %w", err) + } + + return nil +} diff --git a/new-components/reporters/pdf/docker-compose.yaml b/new-components/reporters/pdf/docker-compose.yaml new file mode 100644 index 000000000..48e06a379 --- /dev/null +++ b/new-components/reporters/pdf/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + reporter: + build: + context: . + dockerfile: Dockerfile + args: + - COMPONENT_PATH=reporters/pdf + - COMPONENT_BINARY_SOURCE_PATH=cmd/main.go + platform: linux/amd64 + env_file: + - .env diff --git a/new-components/reporters/pdf/internal/reporter/reporter.go b/new-components/reporters/pdf/internal/reporter/reporter.go new file mode 100644 index 000000000..334088126 --- /dev/null +++ b/new-components/reporters/pdf/internal/reporter/reporter.go @@ -0,0 +1,204 @@ +package reporter + +import ( + "context" + _ "embed" + "fmt" + "html/template" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/go-errors/errors" + "github.com/smithy-security/pkg/env" + + playwright "github.com/smithy-security/smithy/pkg/playwright" + s3client "github.com/smithy-security/smithy/pkg/s3" + "github.com/smithy-security/smithy/sdk/component" + vf "github.com/smithy-security/smithy/sdk/component/vulnerability-finding" +) + +// NewReporter returns a new PDF reporter. +func NewReporter(conf *Conf) *PdfReporter { + return &PdfReporter{ + conf: conf, + } +} + +type PdfReporter struct { + conf *Conf +} + +type ( + Conf struct { + Bucket string + Region string + SkipS3Upload bool + } +) + +// NewConf returns a new configuration build from environment lookup. +func NewConf(envLoader env.Loader) (*Conf, error) { + var envOpts = make([]env.ParseOption, 0) + if envLoader != nil { + envOpts = append(envOpts, env.WithLoader(envLoader)) + } + + skipS3Upload, err := env.GetOrDefault( + "SKIP_S3_UPLOAD", + true, + append(envOpts, env.WithDefaultOnError(false))..., + ) + if err != nil { + return nil, errors.Errorf("could not get env variable for SKIP_S3_UPLOAD: %w", err) + } + + bucket, err := env.GetOrDefault( + "BUCKET_NAME", + "", + append(envOpts, env.WithDefaultOnError(false))..., + ) + if err != nil { + return nil, errors.Errorf("could not get env variable for BUCKET_NAME: %w", err) + } + + region, err := env.GetOrDefault( + "BUCKET_REGION", + "", + append(envOpts, env.WithDefaultOnError(false))..., + ) + if err != nil { + return nil, errors.Errorf("could not get env variable for BUCKET_REGION: %w", err) + } + + return &Conf{ + Bucket: bucket, + Region: region, + SkipS3Upload: skipS3Upload, + }, nil +} + +func (p PdfReporter) Report( + ctx context.Context, + findings []*vf.VulnerabilityFinding, +) error { + logger := component.LoggerFromContext(ctx) + + // get the PDF + resultFilename, pdfBytes, err := p.getPdf(findings) + if err != nil { + return fmt.Errorf("could not build pdf: %w", err) + } + logger.Info("built the PDF") + + // upload the pdf to the s3 if needed + if !p.conf.SkipS3Upload { + return p.uploadToS3(resultFilename, pdfBytes) + } + return nil +} + +// getPdf initializes Playwright and starts the PDF generation +func (p PdfReporter) getPdf(findings []*vf.VulnerabilityFinding) (string, []byte, error) { + pw, err := playwright.NewClient() + if err != nil { + slog.Error("could not launch playwright: %s", slog.String("err", err.Error())) + } + + defer func() { + if err := pw.Stop(); err != nil { + slog.Error("could not stop Playwright", slog.String("err", err.Error())) + } + }() + + slog.Info("reading PDF") + resultFilename, pdfBytes, err := p.buildPdf(findings, pw) + if err != nil { + return "", nil, fmt.Errorf("could not build pdf: %w", err) + } + slog.Info("result filename", slog.String("filename", resultFilename)) + + return resultFilename, pdfBytes, nil +} + +//go:embed template.html +var templateFile string + +// buildPdf builds a PDF +func (p PdfReporter) buildPdf(data any, pw playwright.Wrapper) (string, []byte, error) { + // process the default template into a html result + tmpl, err := template.New("template.html").Funcs(template.FuncMap{ + "formatTime": FormatTime, + }).Parse(templateFile) + if err != nil { + return "", nil, fmt.Errorf("could not parse files: %w", err) + } + + currentPath, err := os.Getwd() + if err != nil { + return "", nil, fmt.Errorf("could not get current working directory: %w", err) + } + + reportHTMLPath := filepath.Join(currentPath, "report.html") + //#nosec: G304 + f, err := os.OpenFile(reportHTMLPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //#nosec: G304 + if err != nil { + return "", nil, fmt.Errorf("could not open report.html: %w", err) + } + if err = tmpl.Execute(f, data); err != nil { + return "", nil, fmt.Errorf("could not apply data to template: %w", err) + } + // close the file after writing it + defer func(f *os.File) { + err := f.Close() + if err != nil { + slog.Error("could not close file", slog.String("err", err.Error())) + } + }(f) + + //todo: add instance id to name of file + reportPDFPath := filepath.Join(currentPath, "report.pdf") + reportPage := fmt.Sprintf("file:///%s", reportHTMLPath) + pdfBytes, err := pw.GetPDFOfPage(reportPage, reportPDFPath) + if err != nil { + return "", nil, fmt.Errorf("could not generate pdf from page %s, err: %w", reportPage, err) + + } + + // delete the intermediate HTML file + if err := os.Remove(reportHTMLPath); err != nil { + slog.Error("could not delete report.html", slog.String("err", err.Error())) + } + return reportPDFPath, pdfBytes, err +} + +// FormatTime is a template function for the PDF, that converts a timestamp to a human-readable format +func FormatTime(timestamp *int64) string { + if timestamp == nil { + return "" + } + + // Convert the int64 value to a time.Time + parsedTime := time.Unix(*timestamp, 0) + + // Format the time using a predefined layout + return parsedTime.Format(time.DateTime) +} + +// uploadToS3 uploads the PDF to AWS +func (p PdfReporter) uploadToS3(resultFilename string, pdfBytes []byte) error { + if p.conf.Bucket == "" { + slog.Error("bucket is empty, you need to provide a bucket name") + } + + if p.conf.Region == "" { + slog.Error("region is empty, you need to provide a region name") + } + client, err := s3client.NewClient(p.conf.Region) + if err != nil { + slog.Error(err.Error()) + } + slog.Info("uploading pdf to s3", slog.String("filename", resultFilename), slog.String("bucket", p.conf.Bucket), slog.String("region", p.conf.Region)) + return client.UpsertFile(resultFilename, p.conf.Bucket, "", pdfBytes) +} diff --git a/new-components/reporters/pdf/internal/reporter/reporter_test.go b/new-components/reporters/pdf/internal/reporter/reporter_test.go new file mode 100644 index 000000000..4e9913acd --- /dev/null +++ b/new-components/reporters/pdf/internal/reporter/reporter_test.go @@ -0,0 +1,262 @@ +package reporter + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + + playwright "github.com/smithy-security/smithy/pkg/playwright/mock" + vf "github.com/smithy-security/smithy/sdk/component/vulnerability-finding" + ocsf "github.com/smithy-security/smithy/sdk/gen/ocsf_schema/v1" +) + +func TestPdfReporter(t *testing.T) { + t.Run("The config should initialize correctly", func(t *testing.T) { + err := os.Setenv("SKIP_S3_UPLOAD", "true") + require.NoError(t, err) + err = os.Setenv("BUCKET_NAME", "test-bucket") + require.NoError(t, err) + err = os.Setenv("BUCKET_REGION", "us-west-1") + require.NoError(t, err) + + conf, err := NewConf(nil) + require.NoError(t, err) + assert.Equal(t, "test-bucket", conf.Bucket) + assert.Equal(t, "us-west-1", conf.Region) + assert.True(t, conf.SkipS3Upload) + }) + + t.Run("it should build a PDF", func(t *testing.T) { + // set up test data + now := time.Now().Unix() + findings := getTestData(now) + + // set up the reporter component + conf := &Conf{ + Bucket: "test-bucket", + Region: "us-west-1", + SkipS3Upload: true, + } + reporter := NewReporter(conf) + + // set up the mock playwright + mockClient, err := playwright.NewMockClient() + require.NoError(t, err) + expected := []byte("this is a pdf") + mockClient.GetPDFOfPageCallBack = func(s1, s2 string) ([]byte, error) { + return expected, nil + } + + // check if the PDF builds + _, result, err := reporter.buildPdf(findings, mockClient) + require.NoError(t, err) + require.Equal(t, result, expected) + }) + + t.Run("the time formatting function for the PDF template should work", func(t *testing.T) { + timestamp := int64(1672531199) // Example timestamp + formattedTime := FormatTime(×tamp) + expectedTime := time.Unix(timestamp, 0).Format(time.DateTime) + assert.Equal(t, expectedTime, formattedTime) + }) +} + +func ptr[T any](v T) *T { + return &v +} + +func getTestData(now int64) []*vf.VulnerabilityFinding { + vulns := []*ocsf.VulnerabilityFinding{ + { + ActivityId: ocsf.VulnerabilityFinding_ACTIVITY_ID_CREATE, + CategoryUid: ocsf.VulnerabilityFinding_CATEGORY_UID_FINDINGS, + ClassUid: ocsf.VulnerabilityFinding_CLASS_UID_VULNERABILITY_FINDING, + Confidence: ptr("MEDIUM"), + ConfidenceId: ptr(ocsf.VulnerabilityFinding_CONFIDENCE_ID_LOW), + Count: ptr(int32(1)), + FindingInfo: &ocsf.FindingInfo{ + CreatedTime: &now, + DataSources: []string{ + "/main.go", + }, + Desc: ptr("lots of hacks"), + FirstSeenTime: &now, + LastSeenTime: &now, + ModifiedTime: &now, + ProductUid: ptr("gosec"), + Title: "You have lots of issues", + Uid: "1", + }, + Message: ptr("lots of hacks"), + Resource: &ocsf.ResourceDetails{ + Uid: ptr( + strings.Join([]string{ + "/main.go", + "1", + "1", + }, + ":", + ), + ), + Data: &structpb.Value{ + Kind: &structpb.Value_StringValue{ + StringValue: "1", + }, + }, + }, + RawData: ptr(`{"issues" : []}`), + Severity: ptr("CRITICAL"), + SeverityId: ocsf.VulnerabilityFinding_SEVERITY_ID_CRITICAL, + StartTime: &now, + Status: ptr("opened"), + Time: now, + TypeUid: int64( + ocsf.VulnerabilityFinding_CLASS_UID_VULNERABILITY_FINDING.Number()* + 100 + + ocsf.VulnerabilityFinding_ACTIVITY_ID_CREATE.Number(), + ), + Vulnerabilities: []*ocsf.Vulnerability{ + { + Cwe: &ocsf.Cwe{ + Uid: "1", + SrcUrl: ptr("https://issues.com/1"), + }, + }, + }, + }, + { + ActivityId: ocsf.VulnerabilityFinding_ACTIVITY_ID_CREATE, + CategoryUid: ocsf.VulnerabilityFinding_CATEGORY_UID_FINDINGS, + ClassUid: ocsf.VulnerabilityFinding_CLASS_UID_VULNERABILITY_FINDING, + Confidence: ptr("HIGH"), + ConfidenceId: ptr(ocsf.VulnerabilityFinding_CONFIDENCE_ID_HIGH), + Count: ptr(int32(2)), + FindingInfo: &ocsf.FindingInfo{ + CreatedTime: &now, + DataSources: []string{ + "/internal/sketchy/sketch.go", + }, + Desc: ptr("stop writing hacky code"), + FirstSeenTime: &now, + LastSeenTime: &now, + ModifiedTime: &now, + ProductUid: ptr("gosec"), + Title: "You have lots of hacky code", + Uid: "2", + }, + Message: ptr("lots of hacky code"), + Resource: &ocsf.ResourceDetails{ + Uid: ptr( + strings.Join([]string{ + "/internal/sketchy/sketch.go", + "10", + "1", + }, + ":", + ), + ), + Data: &structpb.Value{ + Kind: &structpb.Value_StringValue{ + StringValue: "2", + }, + }, + }, + RawData: ptr(`{"issues" : [{"id": 2}]}`), + Severity: ptr("HIGH"), + SeverityId: ocsf.VulnerabilityFinding_SEVERITY_ID_HIGH, + StartTime: &now, + Status: ptr("opened"), + Time: now, + TypeUid: int64( + ocsf.VulnerabilityFinding_CLASS_UID_VULNERABILITY_FINDING.Number()* + 100 + + ocsf.VulnerabilityFinding_ACTIVITY_ID_CREATE.Number(), + ), + Vulnerabilities: []*ocsf.Vulnerability{ + { + Cwe: &ocsf.Cwe{ + Uid: "2", + SrcUrl: ptr("https://issues.com/2"), + }, + }, + }, + }, + { + ActivityId: ocsf.VulnerabilityFinding_ACTIVITY_ID_CREATE, + CategoryUid: ocsf.VulnerabilityFinding_CATEGORY_UID_FINDINGS, + ClassUid: ocsf.VulnerabilityFinding_CLASS_UID_VULNERABILITY_FINDING, + Confidence: ptr("LOW"), + ConfidenceId: ptr(ocsf.VulnerabilityFinding_CONFIDENCE_ID_LOW), + Count: ptr(int32(3)), + FindingInfo: &ocsf.FindingInfo{ + CreatedTime: &now, + DataSources: []string{ + "/internal/sketchy/hacks.go", + }, + Desc: ptr("stop writing hacks"), + FirstSeenTime: &now, + LastSeenTime: &now, + ModifiedTime: &now, + ProductUid: ptr("gosec"), + Title: "You have lots of hacks", + Uid: "3", + }, + Message: ptr("lots of hacks"), + Resource: &ocsf.ResourceDetails{ + Uid: ptr( + strings.Join([]string{ + "/internal/sketchy/hacks.go", + "123", + "13", + }, + ":", + ), + ), + Data: &structpb.Value{ + Kind: &structpb.Value_StringValue{ + StringValue: "3", + }, + }, + }, + RawData: ptr(`{"issues" : [{"id": 3}]}`), + Severity: ptr("HIGH"), + SeverityId: ocsf.VulnerabilityFinding_SEVERITY_ID_HIGH, + StartTime: &now, + Status: ptr("opened"), + Time: now, + TypeUid: int64( + ocsf.VulnerabilityFinding_CLASS_UID_VULNERABILITY_FINDING.Number()* + 100 + + ocsf.VulnerabilityFinding_ACTIVITY_ID_CREATE.Number(), + ), + Vulnerabilities: []*ocsf.Vulnerability{ + { + Cwe: &ocsf.Cwe{ + Uid: "3", + SrcUrl: ptr("https://issues.com/3"), + }, + }, + }, + }, + } + findings := []*vf.VulnerabilityFinding{ + { + ID: 0, + Finding: vulns[0], + }, + { + ID: 1, + Finding: vulns[1], + }, + { + ID: 2, + Finding: vulns[2], + }, + } + return findings +} diff --git a/new-components/reporters/pdf/internal/reporter/template.html b/new-components/reporters/pdf/internal/reporter/template.html new file mode 100644 index 000000000..cc82bce17 --- /dev/null +++ b/new-components/reporters/pdf/internal/reporter/template.html @@ -0,0 +1,360 @@ + + + + + + + Vulnerability Scan Results + + + + + +
+ Logo +

Smithy Report

+
+ + +
+
+

This report summarizes the results of running Smithy.

+ + +
+

High Severity

+

Total

+
+ + + + + + + + + + + {{range .}} + + + + + + {{end}} + +
NameSeen beforeSeverity
{{ .Finding.FindingInfo.Title }}{{ .Finding.Count }} times{{ .Finding.Severity }}
+
+ + +
+ {{range .}} +
+

{{ .Finding.FindingInfo.Title }}

+ + + {{ if and .Finding.FindingInfo.ProductUid (ne .Finding.FindingInfo.ProductUid nil) }} + + + + + {{ end }} + + {{ if and .Finding.Severity (ne .Finding.Severity nil) }} + + + + + {{ end }} + + {{ if and .Finding.Confidence (ne .Finding.Confidence nil) }} + + + + + {{ end }} + + {{ if and .Finding.TypeUid (ne .Finding.TypeUid nil) }} + + + + + {{ end }} + {{ if and .Finding.FindingInfo.SrcUrl (ne .Finding.FindingInfo.SrcUrl nil) }} + + + + + {{ end }} + {{ if and .Finding.FindingInfo.DataSources (ne .Finding.FindingInfo.DataSources nil) }} + + + + + {{ end }} + + {{ if and .Finding.Message (ne .Finding.Message nil) }} + + + + + {{ end }} + + {{ if and .Finding.FindingInfo.FirstSeenTime (ne .Finding.FindingInfo.FirstSeenTime nil) }} + + + + + {{ end }} + + + + + + + {{ if and .Finding.FindingInfo.ModifiedTime (ne .Finding.FindingInfo.ModifiedTime nil) }} + + + + + {{ end }} + + {{ range .Finding.Vulnerabilities }} + + + {{ if and .AffectedCode (ne .AffectedCode nil) }} + + + + + {{end}} + {{ if and .Cve (ne .Cve nil) }} + + + + + {{end}} + {{ if and .Cwe (ne .Cwe nil) }} + + + + + {{end}} + {{ if and .Remediation (ne .Remediation nil) }} + + + + + {{end}} + + {{end}} +
Tool{{.Finding.FindingInfo.ProductUid}}
Severity{{.Finding.Severity}}
Confidence{{.Finding.Confidence}}
Type{{.Finding.TypeUid}}
Src URL{{.Finding.FindingInfo.SrcUrl}}
Data sources{{range .Finding.FindingInfo.DataSources}}{{.}}{{ end }}
Description{{.Finding.Message}}
First Seen{{.Finding.FindingInfo.FirstSeenTime | formatTime}}
Seen Before{{.Finding.Count}} times
Last Updated{{ .Finding.FindingInfo.ModifiedTime | formatTime }}
Affected Code{{.AffectedCode}}
CVE + CVE-{{.Cve.Uid}}
+

{{.Cve.Caption}}

+
CWE + CWE-{{.Cwe.Uid}}
+

{{.Cwe.Caption}}

+
Remediation{{.Remediation}}
+
+ {{end}} +
+
+ + + +