Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions pkg/clouds/pulumi/docker/build_and_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,34 @@ func executeSecurityOperations(ctx *sdk.Context, stack api.Stack, dockerImage *d
if scanGate != nil {
reportDeps = append(reportDeps, scanGate)
}
_, err = local.NewCommand(ctx, fmt.Sprintf("security-report-%s", imageName), &local.CommandArgs{
reportResourceName := fmt.Sprintf("security-report-%s", imageName)
_, err = local.NewCommand(ctx, reportResourceName, &local.CommandArgs{
// Stage the report script to a tempfile and invoke it by path —
// keeps the Create argv under ARG_MAX regardless of how many
// vulnerabilities the merged scan-results.json enumerates. Invoked
// via `sh` (not `bash`) because the generated script only uses
// POSIX constructs (`printf`, `jq`, parameter expansion) and SC
// can land on Alpine-based runners where `bash` is absent.
//
// On any filesystem error the helper returns "" and we fall back
// to inlining the script (preserves prior behaviour for short
// reports). The staging error is logged via ctx.Log.Warn so
// operators can investigate — silent fallback would re-introduce
// the exact ARG_MAX failure this helper exists to fix.
Create: securityImageRef.ApplyT(func(img string) string {
commentOutput := resolveCommentOutputPath(security, imageName)
return buildSecurityReportScript(img, imageName, security, commentOutput)
script := buildSecurityReportScript(img, imageName, security, commentOutput)
path, stageErr := stageSecurityReportScript(reportResourceName, script)
if stageErr != nil || path == "" {
if stageErr != nil {
_ = ctx.Log.Warn(
fmt.Sprintf("security-report: failed to stage script for %q (falling back to inline, large reports may hit ARG_MAX): %v", imageName, stageErr),
&sdk.LogArgs{},
)
}
return script
}
return fmt.Sprintf("sh %s", shellQuote(path))
}).(sdk.StringOutput),
}, sdk.DependsOn(reportDeps))
if err != nil {
Expand Down
153 changes: 153 additions & 0 deletions pkg/clouds/pulumi/docker/security_report.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,166 @@
package docker

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"

"github.com/simple-container-com/api/pkg/api"
)

// sanitizeForFilenameRe replaces any char outside the safelist with `_`, used
// when composing a tempfile name from a Pulumi resource name (which may
// contain registry hostnames, `:`, `/`, etc.).
var sanitizeForFilenameRe = regexp.MustCompile(`[^A-Za-z0-9._-]`)

// maxSafeNameLen caps the resource-name component of the staged-script
// basename so the full filename stays under common NAME_MAX limits (255
// bytes on Linux, 255 chars on macOS). A long ECR-derived image name +
// stack + service can easily push the unsanitised resource name past 200
// chars — at which point os.WriteFile returns ENAMETOOLONG and the caller
// falls back to inlining the full script, reintroducing the ARG_MAX
// failure this helper exists to avoid.
const maxSafeNameLen = 64
const stagedScriptPrefix = "sc-security-report-"

// stagedScriptMaxAge is the lifetime ceiling for staged scripts in TMPDIR.
// On a long-lived CI runner with hundreds of deploys per day, the staged
// files would accumulate indefinitely otherwise. 24h is long enough that
// a Pulumi resource's Delete (which inspects Create text) can still find
// the file during normal teardown windows.
const stagedScriptMaxAge = 24 * time.Hour

// sweepOnce coalesces the TTL sweep so multiple stages in a single
// process (e.g., a stack with several images) don't each scan TMPDIR.
var sweepOnce sync.Once

// sweepStaleStagedScripts removes stagedScriptPrefix-named files in TMPDIR
// older than `maxAge`. Best-effort — any error is silently ignored, since
// failure here would be cleanup of prior runs, not the critical path.
func sweepStaleStagedScripts(maxAge time.Duration) {
dir := os.TempDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
cutoff := time.Now().Add(-maxAge)
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, stagedScriptPrefix) {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, name))
}
}
}

// stageSecurityReportScript writes the dynamically-built report script to a
// deterministic tempfile under $TMPDIR and returns the path.
//
// Why: the script is composed from the merged scan-results.json (Trivy +
// Grype) — which on an image carrying a fresh Ubuntu base can run into
// thousands of CVEs. The Pulumi `command:local:Command` resource invokes its
// `Create` field via `/bin/sh -c "<Create>"`, which means the script content
// counts against the kernel's ARG_MAX (typically 128 KB on Linux). On a
// chrome-base-derived image we observed 5,025 merged findings producing a
// >150 KB script body, and every deploy failed with:
//
// error: fork/exec /bin/sh: argument list too long
// error: update failed
//
// Staging the script to a tempfile and returning a short `sh <path>`
// invocation keeps the Create argv well under ARG_MAX regardless of how many
// vulnerabilities the report enumerates.
//
// ## Atomicity
//
// Writes through `os.CreateTemp + os.Rename` so concurrent deploys with
// different script content but colliding final paths (extremely unlikely
// given the 64-bit hash, but possible) can't observe a half-written file
// from another writer. `os.Rename` is atomic on the same filesystem; both
// the unique stage file and the final path live in `$TMPDIR`.
//
// ## Path stability
//
// Path is deterministic on (resourceName, script-content) — same inputs
// produce the same path, so Pulumi doesn't see spurious "drift" between
// runs that would otherwise re-trigger the resource on no-op refreshes.
//
// ## Fallback contract
//
// On any filesystem error, returns the empty string and the error; callers
// should fall back to inlining the script (preserves prior behaviour for
// short reports where ARG_MAX is not a concern) AND log the staging error
// so operators can investigate — silent fallback would re-introduce the
// exact failure mode this helper exists to fix.
//
// ## Idempotence
//
// Pulumi's ApplyT callback re-fires on every `up` / `preview`, even when
// the script content hasn't changed. The hash-derived path is stable, so
// we `Stat` first and skip the write if a file already exists at that
// path — both cheaper (no rewrite, no mtime churn) and a no-op for
// readers concurrent with our re-entry.
func stageSecurityReportScript(resourceName, script string) (string, error) {
// First call per process: clear out stale stages from prior deploys.
sweepOnce.Do(func() { sweepStaleStagedScripts(stagedScriptMaxAge) })

sum := sha256.Sum256([]byte(resourceName + "\x00" + script))
safeName := sanitizeForFilenameRe.ReplaceAllString(resourceName, "_")
if len(safeName) > maxSafeNameLen {
safeName = safeName[:maxSafeNameLen]
}
finalPath := filepath.Join(os.TempDir(), fmt.Sprintf("%s%s-%s.sh", stagedScriptPrefix, safeName, hex.EncodeToString(sum[:8])))

// Hash-derived path uniquely identifies the (resourceName, script)
// tuple. If the file already exists, it was written by an earlier
// stage of this process or a prior `pulumi up` — trust the hash and
// skip the rewrite. Refresh mtime so the TTL sweep doesn't garbage-
// collect the file while it's still actively referenced.
if _, err := os.Stat(finalPath); err == nil {
now := time.Now()
_ = os.Chtimes(finalPath, now, now)
return finalPath, nil
}

tmpFile, err := os.CreateTemp(os.TempDir(), stagedScriptPrefix+"stage-*.sh.tmp")
if err != nil {
return "", fmt.Errorf("create stage tempfile: %w", err)
}
tmpPath := tmpFile.Name()
cleanup := func() { _ = os.Remove(tmpPath) }
if _, err := tmpFile.Write([]byte(script)); err != nil {
_ = tmpFile.Close()
cleanup()
return "", fmt.Errorf("write stage tempfile: %w", err)
}
if err := tmpFile.Chmod(0o600); err != nil {
_ = tmpFile.Close()
cleanup()
return "", fmt.Errorf("chmod stage tempfile: %w", err)
}
if err := tmpFile.Close(); err != nil {
cleanup()
return "", fmt.Errorf("close stage tempfile: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
cleanup()
return "", fmt.Errorf("rename stage tempfile to %s: %w", finalPath, err)
}
return finalPath, nil
}

// buildSecurityReportScript generates a shell script that reads scan results and
// outputs a unified security summary to the console, $GITHUB_STEP_SUMMARY, and
// an optional markdown file for PR comments.
Expand Down
Loading
Loading