Skip to content
Open
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
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,30 @@ export GOPROXY=http://cachew.example.com/gomod,direct
gomod {
proxy = "https://proxy.golang.org"
private-paths = ["github.com/myorg/*"]

package-policy {
socket {
api-url = "https://api.socket.dev"
organization = "my-socket-org"
token = "${SOCKET_SECURITY_API_TOKEN}"
}
}
}
```

When `package-policy` is configured, Cachew evaluates the PURL for each cold,
canonical-version public module before it contacts the Go module origin. Branch
and revision `.info` queries pass through so the Go proxy can resolve them; the
resulting canonical version's module files are evaluated before download. Cached
module files bypass the check. The `socket` provider sends the PURL to Socket;
modules matching `private-paths` are not sent.

The warm-path cache probe does not read or backfill the module body. It also
disables origin fallback for that request. If the object is evicted between the
probe and goproxy's body read, goproxy returns its temporary `404` rather than
fetching an unevaluated body; a later client retry performs a normal miss and
policy evaluation.

### Hermit

Caches [Hermit](https://cashapp.github.io/hermit/) package downloads. GitHub release URLs are automatically routed through the `github-releases` strategy.
Expand Down Expand Up @@ -95,6 +116,14 @@ codeartifact "example-111122223333.d.codeartifact.us-east-1.amazonaws.com" {
domain-owner = "111122223333"
region = "us-east-1"
role-arn = "arn:aws:iam::111122223333:role/cachew-codeartifact-read"

package-policy {
socket {
api-url = "https://api.socket.dev"
organization = "my-socket-org"
token = "${SOCKET_SECURITY_API_TOKEN}"
}
}
}
```

Expand All @@ -106,6 +135,69 @@ The role needs `codeartifact:GetAuthorizationToken` and its underlying principal
needs `sts:GetServiceBearerToken`. Repository read permissions remain governed by
the CodeArtifact resource policy.

The optional `package-policy` block is a provider-independent package-admission
interface based on standard Package URLs (PURLs). Its `socket` provider checks
cold npm and PyPI artifact PURLs with the configured organization's [Socket
package policy](https://docs.socket.dev/reference/batchpackagefetchbyorg) before
Cachew mints a CodeArtifact token or contacts the repository. Another provider
can implement the same PURL-to-decision interface without changing the
CodeArtifact or Go module strategies.

For the Socket provider, a policy action of `error`, or a package Socket cannot
resolve, returns `403`. Pending analysis or an unavailable Socket API returns
`503` with `Retry-After`; these outcomes fail closed and never fall through to
CodeArtifact. Cache hits do not recheck the policy because Cachew only admits
complete, origin-declared immutable bodies.

A later policy change does not automatically invalidate an admitted object.
Cachew's generic `delete` operation accepts one exact cache key, but CodeArtifact
can store multiple representations of one URL. The unhashed key material is the
origin URL followed by these optional lines, in this order:

```text
https://codeartifact.example.com/npm/repository/package/-/package-1.0.0.tgz
Accept=<comma-joined request values>
Accept-Encoding=<comma-joined request values>
```

`cachew delete codeartifact <key-material>` hashes that material and deletes the
matching object from every backend configured in that Cachew deployment. Run it
against every deployment that may hold a local tier. A separate key exists for
every observed header combination, and Cachew cannot currently list or delete
keys by package path or PURL. The command is therefore a complete targeted purge
only when every request variant is known. Otherwise operators must clear every
backing tier with deployment-specific tooling or wait for the origin TTL; Cachew
does not currently provide a reliable targeted purge for that incident.

Socket receives the public ecosystem, package name, and version from the
CodeArtifact request path. Cachew does not send package contents, CodeArtifact
credentials, repository names, or AWS identity to Socket. Operators should
still treat the package name and version as data crossing from their
CodeArtifact environment to Socket's SaaS boundary.

The Socket token needs only the `packages:list` scope. Keep it out of the HCL
file by using an environment placeholder as shown above and inject
`SOCKET_SECURITY_API_TOKEN` into the Cachew container from the deployment's
secret manager. For example, Kubernetes can source the environment variable
from a `Secret` with `env[].valueFrom.secretKeyRef`; the secret does not need to
be exposed to package-manager clients.

Policy outcomes and API latency are exported as
`cachew.package_policy.evaluations_total` and
`cachew.package_policy.evaluation_duration_seconds`. The evaluation counter has
bounded provider and outcome attributes (`allow`, `deny`, `pending`,
`unavailable`, or `not_applicable`); package names and versions are not metric
labels. The latency histogram covers actual provider evaluations. Unsupported
ecosystems, non-package metadata or query paths, and excluded private Go modules
record `not_applicable` so gaps in enforcement coverage remain visible. Metadata
GETs can dominate that outcome, so dashboards should chart it separately and
exclude it from allow/deny availability ratios. `HEAD` requests are not counted
because they cannot admit a package body.

Concurrent requests for the same PURL share one in-flight provider call. Cachew
discards the decision after that call completes: a later cold request performs a
fresh evaluation, so coalescing does not delay a changed Socket verdict.

Cachew checks its cache for every full CodeArtifact `GET` without a query string,
range, or encoded path separator. On a miss, it stores only a successful,
complete response with a positive shared freshness lifetime and an origin policy
Expand Down
14 changes: 14 additions & 0 deletions cachew.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
# domain-owner = "111122223333"
# region = "us-east-1"
# role-arn = "arn:aws:iam::111122223333:role/cachew-codeartifact-read"
# package-policy {
# socket {
# api-url = "https://api.socket.dev"
# organization = "my-socket-org"
# token = "${SOCKET_SECURITY_API_TOKEN}"
# }
# }
# }

state = "./state"
Expand Down Expand Up @@ -70,6 +77,13 @@ strategy github-releases {

strategy gomod {
proxy = "https://proxy.golang.org"
# package-policy {
# socket {
# api-url = "https://api.socket.dev"
# organization = "my-socket-org"
# token = "${SOCKET_SECURITY_API_TOKEN}"
# }
# }
}

strategy hermit { }
Expand Down
49 changes: 49 additions & 0 deletions internal/packagepolicy/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package packagepolicy_test

import (
"testing"
"time"

"github.com/alecthomas/assert/v2"
"github.com/alecthomas/hcl/v2"

"github.com/block/cachew/internal/packagepolicy"
)

type policyConfigEnvelope struct {
PackagePolicy *packagepolicy.Config `hcl:"package-policy,block,optional"`
}

func TestPackagePolicyConfigRoundTripsThroughHCL(t *testing.T) {
input := []byte(`
package-policy {
socket {
api-url = "https://socket.example.com"
organization = "example-org"
token = "test-token"
timeout = "45s"
}
}
`)
var config policyConfigEnvelope
assert.NoError(t, hcl.Unmarshal(input, &config))
assert.NotZero(t, config.PackagePolicy)
assert.Equal(t, &packagepolicy.SocketConfig{
APIURL: "https://socket.example.com",
Organization: "example-org",
Token: "test-token",
Timeout: 45 * time.Second,
}, config.PackagePolicy.Socket)

encoded, err := hcl.Marshal(&config)
assert.NoError(t, err)
var roundTripped policyConfigEnvelope
assert.NoError(t, hcl.Unmarshal(encoded, &roundTripped))
assert.Equal(t, config, roundTripped)
}

func TestPackagePolicyConfigIsOptional(t *testing.T) {
var config policyConfigEnvelope
assert.NoError(t, hcl.Unmarshal(nil, &config))
assert.Zero(t, config.PackagePolicy)
}
42 changes: 42 additions & 0 deletions internal/packagepolicy/evaluator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Package packagepolicy evaluates package URLs before Cachew fetches package bodies.
package packagepolicy

import (
"context"

"github.com/alecthomas/errors"
)

// Config selects and configures one package policy provider.
type Config struct {
Socket *SocketConfig `hcl:"socket,block,optional" help:"Socket organization policy provider."`
}

// Verdict is the policy result for a package URL.
type Verdict string

const (
VerdictAllow Verdict = "allow"
VerdictDeny Verdict = "deny"
VerdictPending Verdict = "pending"
)

// Decision is an aggregated package policy result.
type Decision struct {
Verdict Verdict
Reasons []string
}

// Evaluator checks package URLs against a package policy.
type Evaluator interface {
Evaluate(context.Context, string) (Decision, error)
ObserveNotApplicable(context.Context)
}

// New creates the configured package policy evaluator.
func New(config Config) (Evaluator, error) {
if config.Socket == nil {
return nil, errors.New("package policy: provider is required")
}
return newSocketEvaluator(*config.Socket, false)
}
54 changes: 54 additions & 0 deletions internal/packagepolicy/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package packagepolicy

import (
"net/http"
"regexp"
"strings"
)

var safeReasonPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)

// AllowRequest reports whether an HTTP package request may continue and writes a
// fail-closed response when the decision is not an allow.
func AllowRequest(w http.ResponseWriter, decision Decision, err error) bool {
if err != nil {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Cachew-Package-Policy", "unavailable")
http.Error(w, "Package security policy unavailable", http.StatusServiceUnavailable)
return false
}
switch decision.Verdict {
case VerdictAllow:
return true
case VerdictDeny:
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Cachew-Package-Policy", "deny")
message := "Package denied by security policy"
if reasons := safeReasons(decision.Reasons); len(reasons) > 0 {
message += ": " + strings.Join(reasons, ", ")
}
http.Error(w, message, http.StatusForbidden)
return false
case VerdictPending:
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Cachew-Package-Policy", "pending")
w.Header().Set("Retry-After", "5")
http.Error(w, "Package security analysis pending", http.StatusServiceUnavailable)
return false
default:
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Cachew-Package-Policy", "unavailable")
http.Error(w, "Package security policy unavailable", http.StatusServiceUnavailable)
return false
}
}

func safeReasons(reasons []string) []string {
safe := make([]string, 0, len(reasons))
for _, reason := range reasons {
if safeReasonPattern.MatchString(reason) {
safe = append(safe, reason)
}
}
return safe
}
63 changes: 63 additions & 0 deletions internal/packagepolicy/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package packagepolicy

import (
"context"
"time"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"

cachewmetrics "github.com/block/cachew/internal/metrics"
)

type metricRecorder interface {
record(context.Context, Decision, error, time.Duration)
recordNotApplicable(context.Context)
}

func (m *clientMetrics) recordNotApplicable(ctx context.Context) {
m.evaluations.Add(ctx, 1, metric.WithAttributes(
attribute.String("provider", m.provider),
attribute.String("outcome", "not_applicable"),
))
}

type clientMetrics struct {
provider string
evaluations metric.Int64Counter
duration metric.Float64Histogram
}

func newMetrics(provider string) *clientMetrics {
meter := otel.Meter("cachew.package_policy")
return &clientMetrics{
provider: provider,
evaluations: cachewmetrics.NewMetric[metric.Int64Counter](
meter,
"cachew.package_policy.evaluations_total",
"{evaluations}",
"Package policy evaluations by provider and outcome",
),
duration: cachewmetrics.NewHistogram(
meter,
"cachew.package_policy.evaluation_duration_seconds",
"s",
"Package policy evaluation duration by provider and outcome",
cachewmetrics.LatencyBuckets(),
),
}
}

func (m *clientMetrics) record(ctx context.Context, decision Decision, err error, duration time.Duration) {
outcome := string(decision.Verdict)
if err != nil || outcome == "" {
outcome = "unavailable"
}
attrs := metric.WithAttributes(
attribute.String("provider", m.provider),
attribute.String("outcome", outcome),
)
m.evaluations.Add(ctx, 1, attrs)
m.duration.Record(ctx, duration.Seconds(), attrs)
}
36 changes: 36 additions & 0 deletions internal/packagepolicy/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package packagepolicy //nolint:testpackage // White-box coverage is required for metric recorder injection.

import (
"context"
"sync/atomic"
"testing"
"time"

"github.com/alecthomas/assert/v2"
)

type recordingMetrics struct {
evaluations atomic.Int32
notApplicable atomic.Int32
recorded chan struct{}
}

func (r *recordingMetrics) record(context.Context, Decision, error, time.Duration) {
r.evaluations.Add(1)
if r.recorded != nil {
r.recorded <- struct{}{}
}
}

func (r *recordingMetrics) recordNotApplicable(context.Context) {
r.notApplicable.Add(1)
}

func TestObserveNotApplicableRecordsProviderMetric(t *testing.T) {
metrics := &recordingMetrics{}
evaluator := &socketEvaluator{metrics: metrics}

evaluator.ObserveNotApplicable(t.Context())

assert.Equal(t, int32(1), metrics.notApplicable.Load())
}
Loading