implementation of syft/grype scanner as a separate binary

This commit is contained in:
Evan Jarrett
2026-02-04 09:53:04 -06:00
parent 9c9c808eea
commit d6114cf549
37 changed files with 4913 additions and 1467 deletions
+51
View File
@@ -0,0 +1,51 @@
FROM docker.io/golang:1.25.4-trixie AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy go.work and both module definitions first for layer caching
COPY go.work ./
COPY go.mod go.sum ./
COPY scanner/go.mod scanner/go.sum ./scanner/
RUN cd scanner && go mod download
# Copy full source
COPY . .
RUN cd scanner && CGO_ENABLED=1 go build \
-ldflags="-s -w -linkmode external -extldflags '-static'" \
-trimpath \
-o /build/atcr-scanner ./cmd/scanner
# ==========================================
# Stage 2: Minimal FROM scratch runtime
# ==========================================
FROM scratch
# Copy CA certificates for HTTPS (presigned URL downloads)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy timezone data for timestamp formatting
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copy binary
COPY --from=builder /build/atcr-scanner /atcr-scanner
# Expose health endpoint port
EXPOSE 9090
# OCI image annotations
LABEL org.opencontainers.image.title="ATCR Scanner" \
org.opencontainers.image.description="ATCR Scanner - container image vulnerability scanner with Syft and Grype" \
org.opencontainers.image.authors="ATCR Contributors" \
org.opencontainers.image.source="https://tangled.org/evan.jarrett.net/at-container-registry" \
org.opencontainers.image.documentation="https://tangled.org/evan.jarrett.net/at-container-registry" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.version="0.1.0"
ENTRYPOINT ["/atcr-scanner"]
CMD ["serve"]
+23 -4
View File
@@ -3,8 +3,11 @@ module atcr.io
go 1.25.4
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/bluesky-social/indigo v0.0.0-20260202181658-ea3d39eec464
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec
github.com/distribution/distribution/v3 v3.0.0
github.com/distribution/reference v0.6.0
github.com/earthboundkid/versioninfo/v2 v2.24.1
@@ -45,6 +48,22 @@ require (
require (
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
github.com/ajg/form v1.6.1 // indirect
github.com/aws/aws-sdk-go v1.55.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bshuster-repo/logrus-logstash-hook v1.1.0 // indirect
@@ -164,8 +183,8 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
+46 -10
View File
@@ -10,8 +10,46 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM=
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
@@ -22,8 +60,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
github.com/bluesky-social/indigo v0.0.0-20260202181658-ea3d39eec464 h1:jL6cPOk1CZ8H06sEn+WFGWufHmqkawsGyDRl+BJhQjs=
github.com/bluesky-social/indigo v0.0.0-20260202181658-ea3d39eec464/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag=
github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec h1:fubriMftMNEmb35sF07gDCsdUSEd0+EIDebt/+5oQRU=
github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bshuster-repo/logrus-logstash-hook v1.1.0 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4=
@@ -403,8 +441,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v84 v84.1.0 h1:9KW8Fm3csWsPNqBJCgdEZBM9pRNaqpESHIw+eXp8A0k=
github.com/stripe/stripe-go/v84 v84.1.0/go.mod h1:kjXh3OrF4PT16qz7z9Q5yqYAZ1mJmu8g8f4Z1sOHBfc=
github.com/stripe/stripe-go/v84 v84.3.0 h1:77HH+ro7yzmyyF7Xkbkj6y5QtnU1WWHC6t2y4mq0Wvk=
github.com/stripe/stripe-go/v84 v84.3.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -564,10 +600,10 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b h1:SGYyueaEovpqmWmtTvwtVgo638V/QFE2zlTCnRrR3jg=
google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+6
View File
@@ -0,0 +1,6 @@
go 1.25.4
use (
.
./scanner
)
+492
View File
@@ -0,0 +1,492 @@
cloud.google.com/go/accessapproval v1.8.7/go.mod h1:BFvZOW4GJjJnl6aA/YDEg0TGViFHyusa/bMdcVFmh8A=
cloud.google.com/go/accesscontextmanager v1.9.6/go.mod h1:884XHwy1AQpCX5Cj2VqYse77gfLaq9f8emE2bYriilk=
cloud.google.com/go/aiplatform v1.102.0/go.mod h1:4rwKOMdubQOND81AlO3EckcskvEFCYSzXKfn42GMm8k=
cloud.google.com/go/analytics v0.30.0/go.mod h1:dneJtsGmmK6EkEPg59vRlncKFWt3xzmKNOc9aKXCTrI=
cloud.google.com/go/apigateway v1.7.7/go.mod h1:j1bCmrUK1BzVHpiIyTApxB7cRyhivKzltqLmp6j6i7U=
cloud.google.com/go/apigeeconnect v1.7.7/go.mod h1:ftGK3nca0JePiVLl0A6alaMjKdOc5C+sAkFMyH2RH8U=
cloud.google.com/go/apigeeregistry v0.9.6/go.mod h1:AFEepJBKPtGDfgabG2HWaLH453VVWWFFs3P4W00jbPs=
cloud.google.com/go/appengine v1.9.7/go.mod h1:y1XpGVeAhbsNzHida79cHbr3pFRsym0ob8xnC8yphbo=
cloud.google.com/go/area120 v0.9.7/go.mod h1:5nJ0yksmjOMfc4Zpk+okWfJ3A1004FvB82rfia+ZLaY=
cloud.google.com/go/artifactregistry v1.17.1/go.mod h1:06gLv5QwQPWtaudI2fWO37gfwwRUHwxm3gA8Fe568Hc=
cloud.google.com/go/asset v1.21.1/go.mod h1:7AzY1GCC+s1O73yzLM1IpHFLHz3ws2OigmCpOQHwebk=
cloud.google.com/go/assuredworkloads v1.12.6/go.mod h1:QyZHd7nH08fmZ+G4ElihV1zoZ7H0FQCpgS0YWtwjCKo=
cloud.google.com/go/automl v1.14.7/go.mod h1:8a4XbIH5pdvrReOU72oB+H3pOw2JBxo9XTk39oljObE=
cloud.google.com/go/baremetalsolution v1.3.6/go.mod h1:7/CS0LzpLccRGO0HL3q2Rofxas2JwjREKut414sE9iM=
cloud.google.com/go/batch v1.12.2/go.mod h1:tbnuTN/Iw59/n1yjAYKV2aZUjvMM2VJqAgvUgft6UEU=
cloud.google.com/go/beyondcorp v1.1.6/go.mod h1:V1PigSWPGh5L/vRRmyutfnjAbkxLI2aWqJDdxKbwvsQ=
cloud.google.com/go/bigquery v1.70.0/go.mod h1:6lEAkgTJN+H2JcaX1eKiuEHTKyqBaJq5U3SpLGbSvwI=
cloud.google.com/go/bigtable v1.39.0/go.mod h1:zgL2Vxux9Bx+TcARDJDUxVyE+BCUfP2u4Zm9qeHF+g0=
cloud.google.com/go/billing v1.20.4/go.mod h1:hBm7iUmGKGCnBm6Wp439YgEdt+OnefEq/Ib9SlJYxIU=
cloud.google.com/go/binaryauthorization v1.9.5/go.mod h1:CV5GkS2eiY461Bzv+OH3r5/AsuB6zny+MruRju3ccB8=
cloud.google.com/go/certificatemanager v1.9.5/go.mod h1:kn7gxT/80oVGhjL8rurMUYD36AOimgtzSBPadtAeffs=
cloud.google.com/go/channel v1.20.0/go.mod h1:nBR1Lz+/1TjSA16HTllvW9Y+QULODj3o3jEKrNNeOp4=
cloud.google.com/go/cloudbuild v1.23.0/go.mod h1:BkxnZUIHUHkl+oNpEbwc7n9id4pZRDQRVKIa6sDCuJI=
cloud.google.com/go/clouddms v1.8.8/go.mod h1:QtCyw+a73dlkDb2q20aTAPvfaTZCepDDi6Gb1AKq0a4=
cloud.google.com/go/cloudtasks v1.13.6/go.mod h1:/IDaQqGKMixD+ayM43CfsvWF2k36GeomEuy9gL4gLmU=
cloud.google.com/go/compute v1.47.0 h1:SG1vwa2xaAzz6XN/qytL3Z8hzKeZzWxV1o5wYLc+TEk=
cloud.google.com/go/compute v1.47.0/go.mod h1:1uoZvP8Avyfhe3Y4he7sMOR16ZiAm2Q+Rc2P5rrJM28=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/contactcenterinsights v1.17.4/go.mod h1:kZe6yOnKDfpPz2GphDHynxk/Spx+53UX/pGf+SmWAKM=
cloud.google.com/go/container v1.44.0/go.mod h1:tVK2o4UZUTkg9WpBcgj4qRzwGA1dSFdWA3mil3YkLIQ=
cloud.google.com/go/containeranalysis v0.14.1/go.mod h1:28e+tlZgauWGHmEbnI5UfIsjMmrkoR1tFN0K2i71jBI=
cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg=
cloud.google.com/go/dataflow v0.11.0/go.mod h1:gNHC9fUjlV9miu0hd4oQaXibIuVYTQvZhMdPievKsPk=
cloud.google.com/go/dataform v0.12.1/go.mod h1:atGS8ReRjfNDUQib0X/o/7Gi2bqHI2G7/J86LKiGimE=
cloud.google.com/go/datafusion v1.8.7/go.mod h1:4dkFb1la41qCEXh1AzYtFwl842bu2ikTUXyKhjvFCb0=
cloud.google.com/go/datalabeling v0.9.7/go.mod h1:EEUVn+wNn3jl19P2S13FqE1s9LsKzRsPuuMRq2CMsOk=
cloud.google.com/go/dataplex v1.27.1/go.mod h1:VB+xlYJiJ5kreonXsa2cHPj0A3CfPh/mgiHG4JFhbUA=
cloud.google.com/go/dataproc/v2 v2.14.1/go.mod h1:tSdkodShfzrrUNPDVEL6MdH9/mIEvp/Z9s9PBdbsZg8=
cloud.google.com/go/dataqna v0.9.7/go.mod h1:4ac3r7zm7Wqm8NAc8sDIDM0v7Dz7d1e/1Ka1yMFanUM=
cloud.google.com/go/datastore v1.20.0/go.mod h1:uFo3e+aEpRfHgtp5pp0+6M0o147KoPaYNaPAKpfh8Ew=
cloud.google.com/go/datastream v1.15.1/go.mod h1:aV1Grr9LFon0YvqryE5/gF1XAhcau2uxN2OvQJPpqRw=
cloud.google.com/go/deploy v1.27.3/go.mod h1:7LFIYYTSSdljYRqY3n+JSmIFdD4lv6aMD5xg0crB5iw=
cloud.google.com/go/dialogflow v1.69.1/go.mod h1:mP4XrpgDvPYBP+cdLxFC1WJJlkwuy0H8L1Lada9No/M=
cloud.google.com/go/dlp v1.25.0/go.mod h1:PY4DMzV7lqRC5JvpxL05fXNeL8dknxYpFp4WjxmE22M=
cloud.google.com/go/documentai v1.38.1/go.mod h1:KmlLO93F7GRU8dENXRxvt+7V8o7eCG6Y6WDitKbcYJs=
cloud.google.com/go/domains v0.10.7/go.mod h1:T3WG/QUAO/52z4tUPooKS8AY7yXaFxPYn1V3F0/JbNQ=
cloud.google.com/go/edgecontainer v1.4.4/go.mod h1:yyNVHsCKtsX/0mqFdbljQw0Uo660q2dlMPaiqYiC2Tg=
cloud.google.com/go/errorreporting v0.3.2/go.mod h1:s5kjs5r3l6A8UUyIsgvAhGq6tkqyBCUss0FRpsoVTww=
cloud.google.com/go/essentialcontacts v1.7.7/go.mod h1:ytycWAEn/aKUMRKQPMVgMrAtphEMgjbzL8vFwM3tqXs=
cloud.google.com/go/eventarc v1.16.1/go.mod h1:wB3NTIQ+l4QPirJiTMeU+YpSc5+iyoDYWV4n2/Vmh78=
cloud.google.com/go/filestore v1.10.3/go.mod h1:94ZGyLTx9j+aWKozPQ6Wbq1DuImie/L/HIdGMshtwac=
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA=
cloud.google.com/go/gkebackup v1.8.1/go.mod h1:GAaAl+O5D9uISH5MnClUop2esQW4pDa2qe/95A4l7YQ=
cloud.google.com/go/gkeconnect v0.12.5/go.mod h1:wMD2RXcsAWlkREZWJDVeDV70PYka1iEb9stFmgpw+5o=
cloud.google.com/go/gkehub v0.16.0/go.mod h1:ADp27Ucor8v81wY+x/5pOxTorxkPj/xswH3AUpN62GU=
cloud.google.com/go/gkemulticloud v1.5.4/go.mod h1:7l9+6Tp4jySSGj4PStO8CE6RrHFdcRARK4ScReHX1bU=
cloud.google.com/go/gsuiteaddons v1.7.8/go.mod h1:DBKNHH4YXAdd/rd6zVvtOGAJNGo0ekOh+nIjTUDEJ5U=
cloud.google.com/go/iap v1.11.3/go.mod h1:+gXO0ClH62k2LVlfhHzrpiHQNyINlEVmGAE3+DB4ShU=
cloud.google.com/go/ids v1.5.7/go.mod h1:N3ZQOIgIBwwOu2tzyhmh3JDT+kt8PcoKkn2BRT9Qe4A=
cloud.google.com/go/iot v1.8.7/go.mod h1:HvVcypV8LPv1yTXSLCNK+YCtqGHhq+p0F3BXETfpN+U=
cloud.google.com/go/kms v1.23.0/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g=
cloud.google.com/go/language v1.14.5/go.mod h1:nl2cyAVjcBct1Hk73tzxuKebk0t2eULFCaruhetdZIA=
cloud.google.com/go/lifesciences v0.10.7/go.mod h1:v3AbTki9iWttEls/Wf4ag3EqeLRHofploOcpsLnu7iY=
cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs=
cloud.google.com/go/maps v1.23.0/go.mod h1:8tjxLplMV7FEoR9FIwqoY7siDnaOdE7FBWnjaXK/xts=
cloud.google.com/go/mediatranslation v0.9.7/go.mod h1:mz3v6PR7+Fd/1bYrRxNFGnd+p4wqdc/fyutqC5QHctw=
cloud.google.com/go/memcache v1.11.7/go.mod h1:AU1jYlUqCihxapcJ1GGMtlMWDVhzjbfUWBXqsXa4rBg=
cloud.google.com/go/metastore v1.14.8/go.mod h1:h1XI2LpD4ohJhQYn9TwXqKb5sVt6KSo47ft96SiFF1s=
cloud.google.com/go/networkconnectivity v1.19.1/go.mod h1:Q5v6uNNNz8BP232uuXM66XgWML9m379xhwv58Y+8Kb0=
cloud.google.com/go/networkmanagement v1.20.1/go.mod h1:clG/5Yt0wQ57qSH6Yh7oehQYlobHw3F6nb3Pn4ig5hU=
cloud.google.com/go/networksecurity v0.10.7/go.mod h1:FgoictpfaJkeBlM1o2m+ngPZi8mgJetbFDH4ws1i2fQ=
cloud.google.com/go/notebooks v1.12.7/go.mod h1:uR9pxAkKmlNloibMr9Q1t8WhIu4P2JeqJs7c064/0Mo=
cloud.google.com/go/optimization v1.7.7/go.mod h1:OY2IAlX23o52qwMAZ0w65wibKuV12a4x6IHDTCq6kcU=
cloud.google.com/go/orchestration v1.11.10/go.mod h1:tz7m1s4wNEvhNNIM3JOMH0lYxBssu9+7si5MCPw/4/0=
cloud.google.com/go/orgpolicy v1.15.1/go.mod h1:bpvi9YIyU7wCW9WiXL/ZKT7pd2Ovegyr2xENIeRX5q0=
cloud.google.com/go/osconfig v1.15.1/go.mod h1:NegylQQl0+5m+I+4Ey/g3HGeQxKkncQ1q+Il4DZ8PME=
cloud.google.com/go/oslogin v1.14.7/go.mod h1:NB6NqBHfDMwznePdBVX+ILllc1oPCdNSGp5u/WIyndY=
cloud.google.com/go/phishingprotection v0.9.7/go.mod h1:JTI4HNGyAbWolBoNOoCyCF0e3cqPNrYnlievHU49EwE=
cloud.google.com/go/policytroubleshooter v1.11.7/go.mod h1:JP/aQ+bUkt4Gz6lQXBi/+A/6nyNRZ0Pvxui5Xl9ieyk=
cloud.google.com/go/privatecatalog v0.10.8/go.mod h1:BkLHi+rtAGYBt5DocXLytHhF0n6F03Tegxgty40Y7aA=
cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk=
cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E=
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
cloud.google.com/go/recaptchaenterprise/v2 v2.20.4/go.mod h1:3H8nb8j8N7Ss2eJ+zr+/H7gyorfzcxiDEtVBDvDjwDQ=
cloud.google.com/go/recommendationengine v0.9.6/go.mod h1:nZnjKJu1vvoxbmuRvLB5NwGuh6cDMMQdOLXTnkukUOE=
cloud.google.com/go/recommender v1.13.5/go.mod h1:v7x/fzk38oC62TsN5Qkdpn0eoMBh610UgArJtDIgH/E=
cloud.google.com/go/redis v1.18.2/go.mod h1:q6mPRhLiR2uLf584Lcl4tsiRn0xiFlu6fnJLwCORMtY=
cloud.google.com/go/resourcemanager v1.10.6/go.mod h1:VqMoDQ03W4yZmxzLPrB+RuAoVkHDS5tFUUQUhOtnRTg=
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
cloud.google.com/go/retail v1.25.0/go.mod h1:J75G8pd+DH0SHueL9IJw7Y5d2VhTsjFsk+F1t9f8jXc=
cloud.google.com/go/run v1.12.0/go.mod h1:/APJ89UqgGdIdaD1yaTiSYXozx3fNoqKR/cueDFRueI=
cloud.google.com/go/scheduler v1.11.7/go.mod h1:gqYs8ndLx2M5D0oMJh48aGS630YYvC432tHCnVWN13s=
cloud.google.com/go/secretmanager v1.15.0/go.mod h1:1hQSAhKK7FldiYw//wbR/XPfPc08eQ81oBsnRUHEvUc=
cloud.google.com/go/security v1.19.1/go.mod h1:+T4yyeDXqBYESnCzswqbq/Oip+IYkIrTfRF4UmeT4Bk=
cloud.google.com/go/securitycenter v1.38.0/go.mod h1:Ge2D/SlG2lP1FrQD7wXHy8qyeloRenvKXeB4e7zO6z0=
cloud.google.com/go/servicedirectory v1.12.6/go.mod h1:OojC1KhOMDYC45oyTn3Mup08FY/S0Kj7I58dxUMMTpg=
cloud.google.com/go/shell v1.8.6/go.mod h1:GNbTWf1QA/eEtYa+kWSr+ef/XTCDkUzRpV3JPw0LqSk=
cloud.google.com/go/spanner v1.85.1/go.mod h1:bbwCXbM+zljwSPLZ44wZOdzcdmy89hbUGmM/r9sD0ws=
cloud.google.com/go/speech v1.28.0/go.mod h1:hJf6oa+1rzCW/CeDE/qCXedV20B2TXEUje5iaGwW+JI=
cloud.google.com/go/storagetransfer v1.13.0/go.mod h1:+aov7guRxXBYgR3WCqedkyibbTICdQOiXOdpPcJCKl8=
cloud.google.com/go/talent v1.8.3/go.mod h1:oD3/BilJpJX8/ad8ZUAxlXHCslTg2YBbafFH3ciZSLQ=
cloud.google.com/go/texttospeech v1.14.0/go.mod h1:l25ywjIgXS+mSE2f5LQdXdU7r3MOLwVOGaYZQMiYIWE=
cloud.google.com/go/tpu v1.8.3/go.mod h1:Do6Gq+/Jx6Xs3LcY2WhHyGwKDKVw++9jIJp+X+0rxRE=
cloud.google.com/go/translate v1.12.6/go.mod h1:nB3AXuX+iHbV8ZURmElcW85qkEDWZw68sf4kqMT/E5o=
cloud.google.com/go/video v1.26.0/go.mod h1:iqsrblPUfkxvyH31rnS02Z0dp9p5lySdq7+I0XzozQI=
cloud.google.com/go/videointelligence v1.12.6/go.mod h1:/l34WMndN5/bt04lHodxiYchLVuWPQjCU6SaiTswrIw=
cloud.google.com/go/vision/v2 v2.9.5/go.mod h1:1SiNZPpypqZDbOzU052ZYRiyKjwOcyqgGgqQCI/nlx8=
cloud.google.com/go/vmmigration v1.9.0/go.mod h1:jI3lBlhQn9+BKIWE/MmMsOzGekCXCc34b1M0CihL3zY=
cloud.google.com/go/vmwareengine v1.3.5/go.mod h1:QuVu2/b/eo8zcIkxBYY5QSwiyEcAy6dInI7N+keI+Jg=
cloud.google.com/go/vpcaccess v1.8.6/go.mod h1:61yymNplV1hAbo8+kBOFO7Vs+4ZHYI244rSFgmsHC6E=
cloud.google.com/go/webrisk v1.11.1/go.mod h1:+9SaepGg2lcp1p0pXuHyz3R2Yi2fHKKb4c1Q9y0qbtA=
cloud.google.com/go/websecurityscanner v1.7.6/go.mod h1:ucaaTO5JESFn5f2pjdX01wGbQ8D6h79KHrmO2uGZeiY=
cloud.google.com/go/workflows v1.14.2/go.mod h1:5nqKjMD+MsJs41sJhdVrETgvD5cOK3hUcAs8ygqYvXQ=
codeberg.org/go-fonts/dejavu v0.4.0/go.mod h1:abni088lmhQJvso2Lsb7azCKzwkfcnttl6tL1UTWKzg=
codeberg.org/go-fonts/latin-modern v0.4.0/go.mod h1:BF68mZznJ9QHn+hic9ks2DaFl4sR5YhfM6xTYaP9vNw=
codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU=
codeberg.org/go-fonts/stix v0.3.0/go.mod h1:1OSJSnA/PoHqbW2tjkkqTmNPp5xTtJQN2GRXJjO/+WA=
codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw=
codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
gioui.org v0.2.0/go.mod h1:1H72sKEk/fNFV+l0JNeM2Dt3co3Y4uaQcD+I+/GQ0e4=
gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.2.0/go.mod h1:rCGN2nZ8ZHqrtseJoQxCMZpt2xrZUrdZ2WuMRLBJmYs=
git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE=
git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0/go.mod h1:cTvi54pg19DoT07ekoeMgE/taAwNtCShVeZqA+Iv2xI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/GoogleCloudPlatform/docker-credential-gcr v2.0.5+incompatible/go.mod h1:BB1eHdMLYEFuFdBlRMb0N7YGVdM5s6Pt0njxgvfbGGs=
github.com/Jorropo/jsync v1.0.1/go.mod h1:jCOZj3vrBCri3bSU3ErUYvevKlnbssrXeCivybS5ABQ=
github.com/Microsoft/cosesign1go v1.4.0/go.mod h1:1La/HcGw19rRLhPW0S6u55K6LKfti+GQSgGCtrfhVe8=
github.com/Microsoft/didx509go v0.0.3/go.mod h1:wWt+iQsLzn3011+VfESzznLIp/Owhuj7rLF7yLglYbk=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/kong v0.5.0/go.mod h1:uzxf/HUh0tj43x1AyJROl3JT7SgsZ5m+icOv1csRhc0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/anchore/bubbly v0.0.0-20250717181826-8a411f9d8cbf/go.mod h1:w8Br1ZKk1Nk82YRSh10pcD7LO7avPyFmNnaY1TRPgs0=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/andybalholm/stroke v0.0.0-20221221101821-bd29b49d73f0/go.mod h1:ccdDYaY5+gO+cbnQdFxEXqfy0RkoV25H3jLXUDNM3wg=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5/go.mod h1:eEuD0vTf9mIzsSjGBFWIaNQwtH5/mzViJOVQfnMY5DE=
github.com/aws/aws-sdk-go-v2/service/ecr v1.51.2/go.mod h1:1NVD1KuMjH2GqnPwMotPndQaT/MreKkWpjkF12d6oKU=
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.2/go.mod h1:x7gU4CAyAz4BsM9hlRkhHiYw2GIr1QCmN45uwQw9l/E=
github.com/aws/aws-sdk-go-v2/service/iam v1.53.1/go.mod h1:GNQZL4JRSGH6L0/SNGOtffaB1vmlToYp3KtcUIB0NhI=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16/go.mod h1:5a78jwLMs7BaesU0UIhLfVy2ZmOEgOy6ewYQXKTD37Q=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.8/go.mod h1:3aOzyhwa/mXPZYLwGaALfl88GFRXHQKXdyQSq2L/Y4g=
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.20/go.mod h1:OG0Y3TgC+IeM++ngh+IcEkN24ruGsmRiAP8GUsOhMW8=
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.11.0/go.mod h1:a2HN6+p7k0JLDO8514sMr0l4cnrR52z4sWoZ/Uc82ho=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec h1:fubriMftMNEmb35sF07gDCsdUSEd0+EIDebt/+5oQRU=
github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/brianvoe/gofakeit/v6 v6.25.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/checkpoint-restore/checkpointctl v1.4.0/go.mod h1:ynQ52zQBazgcTZuxpwTFzRinIcAf0haDTC1X1LA/FKA=
github.com/checkpoint-restore/go-criu/v7 v7.2.0/go.mod h1:u0LCWLg0w4yqqu14aXhiB4YD3a1qd8EcCEg7vda5dwo=
github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU=
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/containerd/btrfs/v2 v2.0.0/go.mod h1:swkD/7j9HApWpzl8OHfrHNxppPd9l44DFZdF94BUj9k=
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/containerd/go-cni v1.1.13/go.mod h1:nTieub0XDRmvCZ9VI/SBG6PyqT95N4FIhxsauF1vSBI=
github.com/containerd/go-runc v1.1.0/go.mod h1:xJv2hFF7GvHtTJd9JqTS2UVxMkULUYw4JN5XAUZqH5U=
github.com/containerd/imgcrypt/v2 v2.0.1/go.mod h1:/qIJL8nxzdzMA2n5iYyyuIY36KfoVQWmgTWdfVtyebM=
github.com/containerd/nri v0.11.0/go.mod h1:bjGTLdUA58WgghKHg8azFMGXr05n1wDHrt3NSVBHiGI=
github.com/containerd/otelttrpc v0.1.0/go.mod h1:XhoA2VvaGPW1clB2ULwrBZfXVuEWuyOd2NUD1IM0yTg=
github.com/containerd/protobuild v0.3.0/go.mod h1:5mNMFKKAwCIAkFBPiOdtRx2KiQlyEJeMXnL5R1DsWu8=
github.com/containerd/zfs/v2 v2.0.0/go.mod h1:fnUDKF98iYuQqLvNdoXs9MXjtfhRWp1nxSgRf7VZH8s=
github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4=
github.com/containernetworking/plugins v1.9.0/go.mod h1:JG3BxoJifxxHBhG3hFyxyhid7JgRVBu/wtooGEvWf1c=
github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/crackcomm/go-gitignore v0.0.0-20241020182519-7843d2ba8fdf/go.mod h1:p1d6YEZWvFzEh4KLyvBcVSnrfNDDvK2zfK/4x2v/4pE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-snaps v0.5.19/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccmack/gocc v1.0.2/go.mod h1:LXX2tFVUggS/Zgx/ICPOr3MLyusuM7EcbfkPvNsjdO8=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/google/certtostore v1.0.6/go.mod h1:2N0ZPLkGvQWhYvXaiBGq02r71fnSLfq78VKIWQHr1wo=
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae/go.mod h1:DoDv8G58DuLNZF0KysYn0bA/6ZWhmRW3fZE2VnGEH0w=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmdtest v0.4.0/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
github.com/intel/goresctrl v0.10.0/go.mod h1:1S8GDqL46GuKb525bxNhIEEkhf4rhVcbSf9DuKhp7mw=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk=
github.com/ipfs/go-fetcher v1.6.1/go.mod h1:27d/xMV8bodjVs9pugh/RCjjK2OZ68UgAMspMdingNo=
github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk=
github.com/ipfs/go-ipfs-exchange-interface v0.2.1/go.mod h1:MUsYn6rKbG6CTtsDp+lKJPmVt3ZrCViNyH3rfPGsZ2E=
github.com/ipfs/go-ipfs-exchange-offline v0.3.0/go.mod h1:MOdJ9DChbb5u37M1IcbrRB02e++Z7521fMxqCNRrz9s=
github.com/ipfs/go-ipfs-files v0.3.0/go.mod h1:xAUtYMwB+iu/dtf6+muHNSFQCJG2dSiStR2P6sn9tIM=
github.com/ipfs/go-ipfs-redirects-file v0.1.2/go.mod h1:yIiTlLcDEM/8lS6T3FlCEXZktPPqSOyuY6dEzVqw7Fw=
github.com/ipfs/go-ipfs-routing v0.3.0/go.mod h1:dKqtTFIql7e1zYsEuWLyuOU+E0WJWW8JjbTPLParDWo=
github.com/ipfs/go-ipns v0.3.0/go.mod h1:3cLT2rbvgPZGkHJoPO1YMJeh6LtkxopCkKFcio/wE24=
github.com/ipfs/go-merkledag v0.11.0/go.mod h1:Q4f/1ezvBiJV0YCIXvt51W/9/kqJGH4I1LsA7+djsM4=
github.com/ipfs/go-namesys v0.7.0/go.mod h1:KYSZBVZG3VJC34EfqqJPG7T48aWgxseoMPAPA5gLyyQ=
github.com/ipfs/go-path v0.3.1/go.mod h1:eNLsxJEEMxn/CDzUJ6wuNl+6No6tEUhOZcPKsZsYX0E=
github.com/ipfs/go-unixfs v0.3.1/go.mod h1:h4qfQYzghiIc8ZNFKiLMFWOTzrWIAtzYQ59W/pCFf1o=
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
github.com/ipfs/interface-go-ipfs-core v0.10.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0=
github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk=
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a/go.mod h1:YPNKjjE7Ubp9dTbnWvsP3HT+hYnY6TfXzubYTBeUxc8=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE=
github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/labstack/echo-contrib v0.15.0/go.mod h1:lei+qt5CLB4oa7VHTE0yEfQSEB9XTJI1LUqko9UWvo4=
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx v1.2.29/go.mod h1:hU8k2l6WF0ncx20uQdOmik/Gjg6E3/wIRtXSNFeZuB8=
github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk=
github.com/libp2p/go-cidranger v1.1.0/go.mod h1:KWZTfSr+r9qEo9OkI9/SIEeAtw+NNoU0dXIXt15Okic=
github.com/libp2p/go-doh-resolver v0.5.0/go.mod h1:aPDxfiD2hNURgd13+hfo29z9IC22fv30ee5iM31RzxU=
github.com/libp2p/go-libp2p-kad-dht v0.37.1/go.mod h1:Uwokdh232k9Y1uMy2yJOK5zb7hpMHn4P8uWS4s9i05Q=
github.com/libp2p/go-libp2p-kbucket v0.8.0/go.mod h1:JMlxqcEyKwO6ox716eyC0hmiduSWZZl6JY93mGaaqc4=
github.com/libp2p/go-libp2p-routing-helpers v0.7.5/go.mod h1:3YaxrwP0OBPDD7my3D0KxfR89FlcX/IEbxDEDfAmj98=
github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM=
github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU=
github.com/libp2p/go-yamux/v5 v5.0.1/go.mod h1:en+3cdX51U0ZslwRdRLrvQsdayFt3TSUKvBGErzpWbU=
github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo=
github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/goveralls v0.0.5/go.mod h1:Xg2LHi51faXLyKXwsndxiW6uxEEQT9+3sjGzzwU4xy0=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU=
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s=
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
github.com/mistifyio/go-zfs/v3 v3.0.1/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
github.com/moby/sys/symlink v0.3.0/go.mod h1:3eNdhduHmYPcgsJtZXW1W4XUJdZGBIkttZ8xKqPUJq0=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0=
github.com/open-policy-agent/opa v0.70.0/go.mod h1:Y/nm5NY0BX0BqjBriKUiV81sCl8XOjjvqQG7dXrggtI=
github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM=
github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw=
github.com/opensearch-project/opensearch-go/v2 v2.3.0/go.mod h1:8LDr9FCgUTVoT+5ESjc2+iaZuldqE+23Iq0r1XeNue8=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/owenrumney/go-sarif v1.1.2-0.20231003122901-1000f5e05554/go.mod h1:n73K/hcuJ50MiVznXyN4rde6fZY7naGKWBXOLFTyc94=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/samber/slog-echo v1.8.0/go.mod h1:0ab2AwcciQXNAXEcjkHwD9okOh9vEHEYn8xP97ocuhM=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U=
github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
github.com/sigstore/sigstore v1.9.5/go.mod h1:VtxgvGqCmEZN9X2zhFSOkfXxvKUjpy8RpUW39oCtoII=
github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4=
github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb/go.mod h1:ikPs9bRWicNw3S7XpJ8sK/smGwU9WcSVU3dy9qahYBM=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/veraison/go-cose v1.1.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b/go.mod h1:ewlIKbKV8l+jCj8rkdXIs361ocR5x3qGyoCSca47Gx8=
github.com/whyrusleeping/base32 v0.0.0-20170828182744-c30ac30633cc/go.mod h1:r45hJU7yEoA81k6MWNhpMj/kms0n14dkzkxYHoB96UM=
github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f/go.mod h1:p9UJB6dDgdPgMJZs7UjUOdulKyRr9fqkS+6JKAInPy8=
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1/go.mod h1:8UvriyWtv5Q5EOgjHaSseUEdkQfvwFv1I/In/O2M9gc=
github.com/whyrusleeping/go-logging v0.0.0-20170515211332-0457bb6b88fc/go.mod h1:bopw91TMyo8J3tvftk8xmU2kPmlrt4nScJQZU2hE5EM=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis=
go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o=
go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.64.0/go.mod h1:LgtjWWXo7OpbSMkXnTlT2jrGtdI6Fmipn8UJCIgbqzg=
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.45.0/go.mod h1:Px9kH7SJ+NhsgWRtD/eMcs15Tyt4uL3rM7X54qv6pfA=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/jaeger v1.14.0/go.mod h1:4Ay9kk5vELRrbg5z4cpP9EtmQRFap2Wb0woPG4lujZA=
go.opentelemetry.io/otel/exporters/zipkin v1.38.0/go.mod h1:Su/nq/K5zRjDKKC3Il0xbViE3juWgG3JDoqLumFx5G0=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20200113040837-eac381796e91/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200317205521-2944c61d58b4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
gonum.org/v1/plot v0.15.2/go.mod h1:DX+x+DWso3LTha+AdkJEv5Txvi+Tql3KAGkehP0/Ubg=
gonum.org/v1/tools v0.0.0-20200318103217-c168b003ce8c/go.mod h1:fy6Otjqbk477ELp8IXTpw1cObQtLbRCBVonY+bTTfcM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20251103181224-f26f9409b101/go.mod h1:ejCb7yLmK6GCVHp5qpeKbm4KZew/ldg+9b8kq5MONgk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a h1:UIpYSuWdWHSzjwcAFRLjKcPXFZVVLXGEM23W+NWqipw=
google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a/go.mod h1:9i1T9n4ZinTUZGgzENMi8MDDgbGC5mqTS75JAv6xN3A=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gorm.io/plugin/opentelemetry v0.1.3/go.mod h1:tndJHOdvPT0pyGhOb8E2209eXJCUxhC5UpKw7bGVWeI=
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/cri-api v0.34.1/go.mod h1:4qVUjidMg7/Z9YGZpqIDygbkPWkg3mkS1PvOx/kpHTE=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
rsc.io/goversion v1.2.0/go.mod h1:Eih9y/uIBS3ulggl7KNJ09xGSLcuNaLgmvvqa07sgfo=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q=
tags.cncf.io/container-device-interface/specs-go v1.1.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ=
+1 -1
View File
@@ -38,7 +38,7 @@ func NewProcessor(database *sql.DB, useCache bool, statsCache *StatsCache) *Proc
db: database,
useCache: useCache,
statsCache: statsCache,
catalog: &catalog,
catalog: catalog,
}
if useCache {
+1 -1
View File
@@ -55,7 +55,7 @@ func GetDirectory() identity.Directory {
// - errTTL: 2 minutes
// - invalidHandleTTL: 5 minutes
cached := identity.NewCacheDirectory(&base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5)
sharedDirectory = &cached
sharedDirectory = cached
})
return sharedDirectory
}
+7
View File
@@ -75,6 +75,13 @@ const (
// Response: {"status": "pending"|"approved"}
HoldRequestCrew = "/xrpc/io.atcr.hold.requestCrew"
// HoldSubscribeScanJobs subscribes to scan jobs via WebSocket (scanner → hold).
// Method: GET (WebSocket upgrade)
// Query: cursor={lastSeq}
// Auth: Shared secret (query param or header)
// Response: Stream of scan job events (JSON)
HoldSubscribeScanJobs = "/xrpc/io.atcr.hold.subscribeScanJobs"
// Future: HoldDelegateAccess = "/xrpc/io.atcr.hold.delegateAccess"
)
+10
View File
@@ -34,6 +34,7 @@ type Config struct {
Database DatabaseConfig `yaml:"database" comment:"Embedded PDS database settings."`
Admin AdminConfig `yaml:"admin" comment:"Admin panel settings."`
Quota quota.Config `yaml:"quota" comment:"Storage quota tiers. Empty disables quota enforcement."`
Scanner ScannerConfig `yaml:"scanner" comment:"Vulnerability scanner settings. Empty disables scanning."`
configPath string `yaml:"-"` // internal: path to YAML file for subsystem config loading
}
@@ -125,6 +126,12 @@ type ServerConfig struct {
WriteTimeout time.Duration `yaml:"write_timeout" comment:"Write timeout for HTTP requests."`
}
// ScannerConfig defines vulnerability scanner settings
type ScannerConfig struct {
// Shared secret for scanner WebSocket authentication. Empty disables scanning.
Secret string `yaml:"secret" comment:"Shared secret for scanner WebSocket auth. Empty disables scanning."`
}
// DatabaseConfig defines embedded PDS database settings
type DatabaseConfig struct {
// Directory for the embedded PDS database.
@@ -168,6 +175,9 @@ func setHoldDefaults(v *viper.Viper) {
v.SetDefault("storage.bucket", "")
v.SetDefault("storage.endpoint", "")
// Scanner defaults
v.SetDefault("scanner.secret", "")
// Log shipper defaults
v.SetDefault("log_shipper.batch_size", 100)
v.SetDefault("log_shipper.flush_interval", "5s")
+12 -13
View File
@@ -10,7 +10,8 @@ import (
"time"
"atcr.io/pkg/s3"
awss3 "github.com/aws/aws-sdk-go/service/s3"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/google/uuid"
)
@@ -143,7 +144,7 @@ func (h *XRPCHandler) StartMultipartUploadWithManager(ctx context.Context, diges
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
result, err := h.s3Service.Client.CreateMultipartUploadWithContext(ctx, &awss3.CreateMultipartUploadInput{
result, err := h.s3Service.Client.CreateMultipartUpload(ctx, &awss3.CreateMultipartUploadInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
})
@@ -176,15 +177,13 @@ func (h *XRPCHandler) GetPartUploadURL(ctx context.Context, uploadID string, par
if h.s3Service.PathPrefix != "" {
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
pnum := int64(partNumber)
req := h.s3Service.Client.UploadPartPresignable(&awss3.UploadPartInput{
pnum := int32(partNumber)
url, err := h.s3Service.Client.PresignUploadPart(ctx, &awss3.UploadPartInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
UploadId: &session.S3UploadID,
PartNumber: &pnum,
})
url, err := req.Presign(15 * time.Minute)
}, 15*time.Minute)
if err != nil {
return nil, err
}
@@ -220,11 +219,11 @@ func (h *XRPCHandler) CompleteMultipartUploadWithManager(ctx context.Context, up
// Convert to S3 CompletedPart format
// IMPORTANT: S3 requires ETags to be quoted in the CompleteMultipartUpload XML
s3Parts := make([]*awss3.CompletedPart, len(parts))
s3Parts := make([]s3types.CompletedPart, len(parts))
for i, p := range parts {
etag := normalizeETag(p.ETag)
pnum := int64(p.PartNumber)
s3Parts[i] = &awss3.CompletedPart{
pnum := int32(p.PartNumber)
s3Parts[i] = s3types.CompletedPart{
PartNumber: &pnum,
ETag: &etag,
}
@@ -235,11 +234,11 @@ func (h *XRPCHandler) CompleteMultipartUploadWithManager(ctx context.Context, up
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
_, err = h.s3Service.Client.CompleteMultipartUploadWithContext(ctx, &awss3.CompleteMultipartUploadInput{
_, err = h.s3Service.Client.CompleteMultipartUpload(ctx, &awss3.CompleteMultipartUploadInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
UploadId: &session.S3UploadID,
MultipartUpload: &awss3.CompletedMultipartUpload{
MultipartUpload: &s3types.CompletedMultipartUpload{
Parts: s3Parts,
},
})
@@ -301,7 +300,7 @@ func (h *XRPCHandler) AbortMultipartUploadWithManager(ctx context.Context, uploa
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
_, err = h.s3Service.Client.AbortMultipartUploadWithContext(ctx, &awss3.AbortMultipartUploadInput{
_, err = h.s3Service.Client.AbortMultipartUpload(ctx, &awss3.AbortMultipartUploadInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
UploadId: &session.S3UploadID,
+40 -1
View File
@@ -2,6 +2,7 @@
package oci
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -24,7 +25,8 @@ type XRPCHandler struct {
pds *pds.HoldPDS
httpClient pds.HTTPClient
enableBlueskyPosts bool
quotaMgr *quota.Manager // Quota manager for tier-based limits
quotaMgr *quota.Manager // Quota manager for tier-based limits
scanBroadcaster *pds.ScanBroadcaster // Scan job dispatcher (nil = scanning disabled)
}
// NewXRPCHandler creates a new OCI XRPC handler
@@ -40,6 +42,11 @@ func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, driver storage
}
}
// SetScanBroadcaster sets the scan broadcaster for triggering scans on push
func (h *XRPCHandler) SetScanBroadcaster(sb *pds.ScanBroadcaster) {
h.scanBroadcaster = sb
}
// RegisterHandlers registers all OCI XRPC endpoints with the chi router
func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// All multipart upload endpoints require blob:write permission
@@ -375,6 +382,38 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
postCreated = true
}
}
// Enqueue scan job if scanner is connected
if h.scanBroadcaster != nil {
tier := "deckhand"
if stats != nil && stats.Tier != "" {
tier = stats.Tier
}
configJSON, _ := json.Marshal(req.Manifest.Config)
layersJSON, _ := json.Marshal(req.Manifest.Layers)
// Resolve handle for scanner context
_, userHandle, _, resolveErr := atproto.ResolveIdentity(ctx, req.UserDID)
if resolveErr != nil {
userHandle = req.UserDID
}
if err := h.scanBroadcaster.Enqueue(&pds.ScanJobEvent{
ManifestDigest: req.ManifestDigest,
Repository: req.Repository,
Tag: req.Tag,
UserDID: req.UserDID,
UserHandle: userHandle,
Tier: tier,
Config: configJSON,
Layers: layersJSON,
}); err != nil {
slog.Error("Failed to enqueue scan job",
"repository", req.Repository,
"error", err)
}
}
}
// ALWAYS increment stats (even if Bluesky posts disabled, even for pulls)
+3 -1
View File
@@ -328,7 +328,7 @@ func (b *EventBroadcaster) Close() error {
}
// Subscribe adds a new WebSocket subscriber
func (b *EventBroadcaster) Subscribe(conn *websocket.Conn, cursor int64) *Subscriber {
func (b *EventBroadcaster) Subscribe(conn *websocket.Conn, cursor int64, userAgent string) *Subscriber {
sub := &Subscriber{
conn: conn,
send: make(chan *RepoCommitEvent, 10), // Buffer 10 events
@@ -340,6 +340,8 @@ func (b *EventBroadcaster) Subscribe(conn *websocket.Conn, cursor int64) *Subscr
currentSeq := b.eventSeq
b.mu.Unlock()
slog.Info("New firehose subscriber", "remote", conn.RemoteAddr(), "cursor", cursor, "currentSeq", currentSeq, "userAgent", userAgent)
// Handle cursor-based backfill:
// - cursor < 0: No backfill, stream new events only
// - cursor >= 0: Backfill events from cursor onwards
+1 -1
View File
@@ -62,7 +62,7 @@ func NewRepoManager(cs carstore.CarStore, kmgr KeyManager) *RepoManager {
kmgr: kmgr,
log: slog.Default().With("system", "repomgr"),
noArchive: noArchive,
clk: &clk,
clk: clk,
}
}
+684
View File
@@ -0,0 +1,684 @@
package pds
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/gorilla/websocket"
)
// ScanBroadcaster manages scanner WebSocket connections and dispatches scan jobs
// using a competing-consumer pattern. Jobs are persisted in SQLite and dispatched
// round-robin to connected scanners.
type ScanBroadcaster struct {
mu sync.RWMutex
subscribers []*ScanSubscriber
nextIdx int // Round-robin index for dispatch
db *sql.DB
holdDID string
holdEndpoint string
driver storagedriver.StorageDriver
pds *HoldPDS
ackTimeout time.Duration
secret string // Shared secret for scanner authentication
}
// ScanSubscriber represents a connected scanner WebSocket client
type ScanSubscriber struct {
conn *websocket.Conn
send chan *ScanJobEvent
id string // Unique subscriber ID
done chan struct{}
}
// ScanJobEvent is the message sent from hold to scanner over WebSocket
type ScanJobEvent struct {
Type string `json:"type"` // Always "job"
Seq int64 `json:"seq"`
ManifestDigest string `json:"manifestDigest"`
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Tier string `json:"tier"`
Config json.RawMessage `json:"config"`
Layers json.RawMessage `json:"layers"`
}
// ScannerMessage is a message received from scanner over WebSocket
type ScannerMessage struct {
Type string `json:"type"` // "ack", "result", "error"
Seq int64 `json:"seq"` // Job sequence number
SBOM string `json:"sbom,omitempty"`
VulnReport string `json:"vulnReport,omitempty"`
Summary *VulnerabilitySummary `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
}
// VulnerabilitySummary contains counts of vulnerabilities by severity
type VulnerabilitySummary struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Total int `json:"total"`
}
// NewScanBroadcaster creates a new scan job broadcaster
// dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3")
func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver storagedriver.StorageDriver, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open scan jobs database: %w", err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping scan jobs database: %w", err)
}
sb := &ScanBroadcaster{
subscribers: make([]*ScanSubscriber, 0),
db: db,
holdDID: holdDID,
holdEndpoint: holdEndpoint,
driver: driver,
pds: holdPDS,
ackTimeout: 5 * time.Minute,
secret: secret,
}
if err := sb.initSchema(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
}
// Start re-dispatch loop for timed-out jobs
go sb.reDispatchLoop()
return sb, nil
}
// initSchema creates the scan_jobs table if it doesn't exist
func (sb *ScanBroadcaster) initSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS scan_jobs (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
manifest_digest TEXT NOT NULL,
repository TEXT NOT NULL,
tag TEXT,
user_did TEXT NOT NULL,
user_handle TEXT,
hold_did TEXT NOT NULL,
hold_endpoint TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'deckhand',
config_json TEXT NOT NULL,
layers_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
assigned_to TEXT,
assigned_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_scan_jobs_status ON scan_jobs(status);
CREATE INDEX IF NOT EXISTS idx_scan_jobs_assigned ON scan_jobs(assigned_to, status);
`
_, err := sb.db.Exec(schema)
return err
}
// Enqueue inserts a scan job into SQLite and dispatches to the next available scanner
func (sb *ScanBroadcaster) Enqueue(job *ScanJobEvent) error {
job.Type = "job"
job.HoldDID = sb.holdDID
job.HoldEndpoint = sb.holdEndpoint
// Insert into database
result, err := sb.db.Exec(`
INSERT INTO scan_jobs (manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
`, job.ManifestDigest, job.Repository, job.Tag, job.UserDID, job.UserHandle, job.HoldDID, job.HoldEndpoint, job.Tier, string(job.Config), string(job.Layers))
if err != nil {
return fmt.Errorf("failed to insert scan job: %w", err)
}
seq, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get job seq: %w", err)
}
job.Seq = seq
slog.Info("Scan job enqueued",
"seq", seq,
"repository", job.Repository,
"tag", job.Tag,
"tier", job.Tier)
// Try to dispatch immediately
sb.dispatchJob(job)
return nil
}
// Subscribe adds a new scanner WebSocket subscriber and drains pending jobs to it
func (sb *ScanBroadcaster) Subscribe(conn *websocket.Conn, cursor int64) *ScanSubscriber {
id := generateSubscriberID()
sub := &ScanSubscriber{
conn: conn,
send: make(chan *ScanJobEvent, 20),
id: id,
done: make(chan struct{}),
}
sb.mu.Lock()
sb.subscribers = append(sb.subscribers, sub)
sb.mu.Unlock()
slog.Info("Scanner subscribed",
"id", id,
"remote", conn.RemoteAddr(),
"cursor", cursor,
"totalSubscribers", len(sb.subscribers))
// Start writer goroutine (sends jobs to scanner)
go sb.handleWriter(sub)
// Start reader goroutine (receives acks/results/errors from scanner)
go sb.handleReader(sub)
// Drain pending and timed-out jobs from database
go sb.drainPendingJobs(sub, cursor)
return sub
}
// Unsubscribe removes a scanner subscriber and makes its jobs re-dispatchable
func (sb *ScanBroadcaster) Unsubscribe(sub *ScanSubscriber) {
sb.mu.Lock()
defer sb.mu.Unlock()
for i, s := range sb.subscribers {
if s == sub {
sb.subscribers = append(sb.subscribers[:i], sb.subscribers[i+1:]...)
break
}
}
// Mark assigned jobs as pending again so they can be re-dispatched
_, err := sb.db.Exec(`
UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL
WHERE assigned_to = ? AND status IN ('pending', 'assigned')
`, sub.id)
if err != nil {
slog.Error("Failed to unassign jobs from disconnected scanner",
"subscriberId", sub.id,
"error", err)
}
close(sub.send)
slog.Info("Scanner unsubscribed",
"id", sub.id,
"totalSubscribers", len(sb.subscribers))
}
// dispatchJob sends a job to the next available scanner via round-robin
func (sb *ScanBroadcaster) dispatchJob(job *ScanJobEvent) {
sb.mu.Lock()
defer sb.mu.Unlock()
if len(sb.subscribers) == 0 {
slog.Debug("No scanners connected, job will wait in queue", "seq", job.Seq)
return
}
// Round-robin dispatch
sub := sb.subscribers[sb.nextIdx%len(sb.subscribers)]
sb.nextIdx++
// Mark as assigned in database
_, err := sb.db.Exec(`
UPDATE scan_jobs SET status = 'assigned', assigned_to = ?, assigned_at = ?
WHERE seq = ? AND status = 'pending'
`, sub.id, time.Now(), job.Seq)
if err != nil {
slog.Error("Failed to assign scan job", "seq", job.Seq, "error", err)
return
}
// Send to subscriber
select {
case sub.send <- job:
slog.Info("Scan job dispatched",
"seq", job.Seq,
"repository", job.Repository,
"subscriberId", sub.id)
default:
slog.Warn("Scanner buffer full, re-marking job as pending",
"seq", job.Seq,
"subscriberId", sub.id)
sb.db.Exec(`UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL WHERE seq = ?`, job.Seq)
}
}
// handleWriter sends jobs to a scanner over its WebSocket connection
func (sb *ScanBroadcaster) handleWriter(sub *ScanSubscriber) {
defer func() {
sub.conn.Close()
close(sub.done)
}()
for job := range sub.send {
data, err := json.Marshal(job)
if err != nil {
slog.Error("Failed to marshal scan job", "seq", job.Seq, "error", err)
continue
}
if err := sub.conn.WriteMessage(websocket.TextMessage, data); err != nil {
slog.Error("Failed to write scan job to WebSocket",
"seq", job.Seq,
"subscriberId", sub.id,
"error", err)
sb.Unsubscribe(sub)
return
}
}
}
// handleReader receives ack/result/error messages from a scanner
func (sb *ScanBroadcaster) handleReader(sub *ScanSubscriber) {
defer sb.Unsubscribe(sub)
for {
_, data, err := sub.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
slog.Error("Scanner WebSocket read error",
"subscriberId", sub.id,
"error", err)
}
return
}
var msg ScannerMessage
if err := json.Unmarshal(data, &msg); err != nil {
slog.Error("Failed to unmarshal scanner message",
"subscriberId", sub.id,
"error", err)
continue
}
switch msg.Type {
case "ack":
sb.handleAck(sub, msg.Seq)
case "result":
sb.handleResult(sub, msg)
case "error":
sb.handleError(sub, msg)
default:
slog.Warn("Unknown scanner message type",
"type", msg.Type,
"subscriberId", sub.id)
}
}
}
// handleAck marks a job as processing (scanner received and queued it)
func (sb *ScanBroadcaster) handleAck(sub *ScanSubscriber, seq int64) {
_, err := sb.db.Exec(`
UPDATE scan_jobs SET status = 'processing'
WHERE seq = ? AND assigned_to = ? AND status = 'assigned'
`, seq, sub.id)
if err != nil {
slog.Error("Failed to update job status to processing",
"seq", seq,
"subscriberId", sub.id,
"error", err)
return
}
slog.Info("Scan job acknowledged",
"seq", seq,
"subscriberId", sub.id)
}
// handleResult processes a completed scan result: stores ORAS manifest + marks completed
func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage) {
ctx := context.Background()
slog.Info("Scan result received",
"seq", msg.Seq,
"subscriberId", sub.id,
"hasSBOM", msg.SBOM != "",
"hasVulnReport", msg.VulnReport != "",
"summary", msg.Summary)
// Get job details from database
var (
manifestDigest string
repository string
tag string
userDID string
)
err := sb.db.QueryRow(`
SELECT manifest_digest, repository, tag, user_did
FROM scan_jobs WHERE seq = ?
`, msg.Seq).Scan(&manifestDigest, &repository, &tag, &userDID)
if err != nil {
slog.Error("Failed to get job details for result storage",
"seq", msg.Seq,
"error", err)
return
}
// Store vulnerability report blob in S3
if msg.VulnReport != "" {
vulnJSON := []byte(msg.VulnReport)
vulnDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(vulnJSON))
if err := sb.uploadBlob(ctx, vulnDigest, vulnJSON); err != nil {
slog.Error("Failed to upload vulnerability report blob",
"seq", msg.Seq,
"error", err)
}
// Build and store ORAS manifest
if msg.Summary != nil {
if err := sb.storeORASManifest(ctx, manifestDigest, repository, userDID, vulnDigest, vulnJSON, *msg.Summary); err != nil {
slog.Error("Failed to store ORAS manifest",
"seq", msg.Seq,
"error", err)
}
}
}
// Store SBOM blob if provided
if msg.SBOM != "" {
sbomJSON := []byte(msg.SBOM)
sbomDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(sbomJSON))
if err := sb.uploadBlob(ctx, sbomDigest, sbomJSON); err != nil {
slog.Error("Failed to upload SBOM blob",
"seq", msg.Seq,
"error", err)
}
}
// Mark job as completed
_, err = sb.db.Exec(`
UPDATE scan_jobs SET status = 'completed', completed_at = ?
WHERE seq = ?
`, time.Now(), msg.Seq)
if err != nil {
slog.Error("Failed to mark scan job as completed",
"seq", msg.Seq,
"error", err)
}
slog.Info("Scan job completed",
"seq", msg.Seq,
"repository", repository,
"tag", tag,
"critical", msg.Summary.Critical,
"high", msg.Summary.High,
"total", msg.Summary.Total)
}
// handleError marks a job as failed
func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage) {
_, err := sb.db.Exec(`
UPDATE scan_jobs SET status = 'failed', completed_at = ?
WHERE seq = ?
`, time.Now(), msg.Seq)
if err != nil {
slog.Error("Failed to mark scan job as failed",
"seq", msg.Seq,
"error", err)
}
slog.Warn("Scan job failed",
"seq", msg.Seq,
"subscriberId", sub.id,
"error", msg.Error)
}
// drainPendingJobs sends pending/timed-out jobs to a newly connected scanner
func (sb *ScanBroadcaster) drainPendingJobs(sub *ScanSubscriber, cursor int64) {
rows, err := sb.db.Query(`
SELECT seq, manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json
FROM scan_jobs
WHERE status = 'pending' AND seq > ?
ORDER BY seq ASC
`, cursor)
if err != nil {
slog.Error("Failed to drain pending scan jobs", "error", err)
return
}
defer rows.Close()
count := 0
for rows.Next() {
job := &ScanJobEvent{Type: "job"}
var configJSON, layersJSON string
err := rows.Scan(
&job.Seq, &job.ManifestDigest, &job.Repository, &job.Tag,
&job.UserDID, &job.UserHandle, &job.HoldDID, &job.HoldEndpoint,
&job.Tier, &configJSON, &layersJSON,
)
if err != nil {
slog.Error("Failed to scan pending job row", "error", err)
continue
}
job.Config = json.RawMessage(configJSON)
job.Layers = json.RawMessage(layersJSON)
// Assign and dispatch
_, err = sb.db.Exec(`
UPDATE scan_jobs SET status = 'assigned', assigned_to = ?, assigned_at = ?
WHERE seq = ? AND status = 'pending'
`, sub.id, time.Now(), job.Seq)
if err != nil {
continue
}
select {
case sub.send <- job:
count++
case <-sub.done:
return
case <-time.After(5 * time.Second):
slog.Warn("Drain timeout for scanner", "subscriberId", sub.id)
return
}
}
if count > 0 {
slog.Info("Drained pending jobs to scanner",
"subscriberId", sub.id,
"jobsDrained", count)
}
}
// reDispatchLoop periodically checks for timed-out jobs and re-dispatches them
func (sb *ScanBroadcaster) reDispatchLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
sb.reDispatchTimedOut()
}
}
// reDispatchTimedOut finds jobs that were assigned but not acked/completed within timeout
func (sb *ScanBroadcaster) reDispatchTimedOut() {
timeout := time.Now().Add(-sb.ackTimeout)
rows, err := sb.db.Query(`
SELECT seq, manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json
FROM scan_jobs
WHERE status = 'assigned' AND assigned_at < ?
ORDER BY seq ASC
`, timeout)
if err != nil {
slog.Error("Failed to query timed-out scan jobs", "error", err)
return
}
defer rows.Close()
for rows.Next() {
job := &ScanJobEvent{Type: "job"}
var configJSON, layersJSON string
err := rows.Scan(
&job.Seq, &job.ManifestDigest, &job.Repository, &job.Tag,
&job.UserDID, &job.UserHandle, &job.HoldDID, &job.HoldEndpoint,
&job.Tier, &configJSON, &layersJSON,
)
if err != nil {
continue
}
job.Config = json.RawMessage(configJSON)
job.Layers = json.RawMessage(layersJSON)
// Reset to pending and re-dispatch
_, err = sb.db.Exec(`
UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL
WHERE seq = ?
`, job.Seq)
if err != nil {
continue
}
slog.Info("Re-dispatching timed-out scan job",
"seq", job.Seq,
"repository", job.Repository)
sb.dispatchJob(job)
}
}
// Close closes the scan broadcaster's database connection
func (sb *ScanBroadcaster) Close() error {
if sb.db != nil {
return sb.db.Close()
}
return nil
}
// ValidateScannerSecret checks if the provided secret matches
func (sb *ScanBroadcaster) ValidateScannerSecret(secret string) bool {
return sb.secret != "" && secret == sb.secret
}
// storeORASManifest creates an ORAS vulnerability manifest as a blob in S3
// The ORAS manifest's "subject" field references the original manifest by digest,
// enabling OCI referrers API discovery.
func (sb *ScanBroadcaster) storeORASManifest(ctx context.Context, manifestDigest, repository, userDID, vulnDigest string, vulnJSON []byte, summary VulnerabilitySummary) error {
scannerVersion := "atcr-scanner-v1.0.0"
// Create ORAS manifest
orasManifest := map[string]interface{}{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.atcr.vulnerabilities+json",
"config": map[string]interface{}{
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2,
},
"subject": map[string]interface{}{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": manifestDigest,
"size": 0,
},
"layers": []map[string]interface{}{
{
"mediaType": "application/json",
"digest": vulnDigest,
"size": len(vulnJSON),
"annotations": map[string]string{
"org.opencontainers.image.title": "vulnerability-report.json",
},
},
},
"annotations": map[string]string{
"io.atcr.vuln.critical": fmt.Sprintf("%d", summary.Critical),
"io.atcr.vuln.high": fmt.Sprintf("%d", summary.High),
"io.atcr.vuln.medium": fmt.Sprintf("%d", summary.Medium),
"io.atcr.vuln.low": fmt.Sprintf("%d", summary.Low),
"io.atcr.vuln.total": fmt.Sprintf("%d", summary.Total),
"io.atcr.vuln.scannedAt": time.Now().Format(time.RFC3339),
"io.atcr.vuln.scannerVersion": scannerVersion,
"io.atcr.vuln.repository": repository,
"io.atcr.vuln.ownerDid": userDID,
"io.atcr.vuln.holdDid": sb.holdDID,
},
}
orasManifestJSON, err := json.Marshal(orasManifest)
if err != nil {
return fmt.Errorf("failed to encode ORAS manifest: %w", err)
}
orasHash := sha256.Sum256(orasManifestJSON)
orasDigest := fmt.Sprintf("sha256:%x", orasHash)
// Upload ORAS manifest blob to S3
if err := sb.uploadBlob(ctx, orasDigest, orasManifestJSON); err != nil {
return fmt.Errorf("failed to upload ORAS manifest blob: %w", err)
}
slog.Info("ORAS manifest stored",
"digest", orasDigest,
"repository", repository,
"userDid", userDID,
"critical", summary.Critical,
"high", summary.High,
"total", summary.Total)
return nil
}
// uploadBlob uploads a blob to S3 storage
func (sb *ScanBroadcaster) uploadBlob(ctx context.Context, digest string, data []byte) error {
digestHex := digest[len("sha256:"):]
if len(digestHex) < 2 {
return fmt.Errorf("invalid digest: %s", digest)
}
blobPath := fmt.Sprintf("/docker/registry/v2/blobs/sha256/%s/%s/data",
digestHex[:2], digestHex)
writer, err := sb.driver.Writer(ctx, blobPath, false)
if err != nil {
return fmt.Errorf("failed to create storage writer: %w", err)
}
defer writer.Close()
if _, err := writer.Write(data); err != nil {
writer.Cancel(ctx)
return fmt.Errorf("failed to write blob data: %w", err)
}
return writer.Commit(ctx)
}
func generateSubscriberID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
+70 -20
View File
@@ -30,7 +30,7 @@ import (
"github.com/multiformats/go-multihash"
awss3 "github.com/aws/aws-sdk-go/service/s3"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
)
// XRPC handler for ATProto endpoints
@@ -44,12 +44,13 @@ const (
// XRPCHandler handles XRPC requests for the embedded PDS
type XRPCHandler struct {
pds *HoldPDS
s3Service s3.S3Service
storageDriver driver.StorageDriver
broadcaster *EventBroadcaster
httpClient HTTPClient // For testing - allows injecting mock HTTP client
quotaMgr *quota.Manager // Quota manager for tier-based limits
pds *HoldPDS
s3Service s3.S3Service
storageDriver driver.StorageDriver
broadcaster *EventBroadcaster
scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners
httpClient HTTPClient // For testing - allows injecting mock HTTP client
quotaMgr *quota.Manager // Quota manager for tier-based limits
}
// PartInfo represents a completed part in a multipart upload
@@ -78,6 +79,11 @@ func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, storageDriver driver.S
}
}
// SetScanBroadcaster sets the scan broadcaster for dispatching scan jobs to scanners
func (h *XRPCHandler) SetScanBroadcaster(sb *ScanBroadcaster) {
h.scanBroadcaster = sb
}
// CORSMiddleware returns a simple CORS middleware configured for ATProto
// This should be applied in the main router before registering any routes
func (h *XRPCHandler) CORSMiddleware() func(http.Handler) http.Handler {
@@ -203,6 +209,9 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
r.Get(atproto.HoldGetQuota, h.HandleGetQuota)
// Scanner WebSocket endpoint (shared secret auth)
r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs)
}
// HandleHealth returns health check information
@@ -953,7 +962,51 @@ func (h *XRPCHandler) HandleSubscribeRepos(w http.ResponseWriter, r *http.Reques
// Subscribe to events
// The broadcaster's handleSubscriber goroutine will manage this connection
// and handle cleanup when the client disconnects
h.broadcaster.Subscribe(conn, cursor)
h.broadcaster.Subscribe(conn, cursor, r.UserAgent())
}
// HandleSubscribeScanJobs handles WebSocket connections from scanners
// Scanners connect here to receive scan jobs and send back results
func (h *XRPCHandler) HandleSubscribeScanJobs(w http.ResponseWriter, r *http.Request) {
if h.scanBroadcaster == nil {
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
return
}
// Authenticate via shared secret (query param or header)
secret := r.URL.Query().Get("secret")
if secret == "" {
secret = r.Header.Get("X-Scanner-Secret")
}
if !h.scanBroadcaster.ValidateScannerSecret(secret) {
http.Error(w, "invalid scanner secret", http.StatusUnauthorized)
return
}
// Get optional cursor for backfill
var cursor int64 = -1
if cursorStr := r.URL.Query().Get("cursor"); cursorStr != "" {
var err error
cursor, err = strconv.ParseInt(cursorStr, 10, 64)
if err != nil {
http.Error(w, "invalid cursor parameter", http.StatusBadRequest)
return
}
}
// Upgrade to WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
slog.Error("Scanner WebSocket upgrade failed", "error", err)
return
}
h.scanBroadcaster.Subscribe(conn, cursor)
}
// ScanBroadcasterRef returns the scan broadcaster (used by OCI handler to enqueue jobs)
func (h *XRPCHandler) ScanBroadcasterRef() *ScanBroadcaster {
return h.scanBroadcaster
}
// HandleUploadBlob handles blob uploads with support for multipart operations
@@ -1387,38 +1440,35 @@ func (h *XRPCHandler) GetPresignedURL(ctx context.Context, operation string, dig
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
// Create appropriate S3 request based on operation
var req interface {
Presign(time.Duration) (string, error)
}
// Generate presigned URL with 15 minute expiry
var url string
var err error
contentType := "application/octet-stream"
switch operation {
case http.MethodGet:
// Note: Don't use ResponseContentType - not supported by all S3-compatible services
req = h.s3Service.Client.GetObjectPresignable(&awss3.GetObjectInput{
url, err = h.s3Service.Client.PresignGetObject(ctx, &awss3.GetObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
})
}, 15*time.Minute)
case http.MethodHead:
req = h.s3Service.Client.HeadObjectPresignable(&awss3.HeadObjectInput{
url, err = h.s3Service.Client.PresignHeadObject(ctx, &awss3.HeadObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
})
}, 15*time.Minute)
case http.MethodPut:
req = h.s3Service.Client.PutObjectPresignable(&awss3.PutObjectInput{
url, err = h.s3Service.Client.PresignPutObject(ctx, &awss3.PutObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
ContentType: &contentType,
})
}, 15*time.Minute)
default:
return "", fmt.Errorf("unsupported operation: %s", operation)
}
// Generate presigned URL with 15 minute expiry
url, err := req.Presign(15 * time.Minute)
if err != nil {
slog.Warn("Presign failed, falling back to XRPC endpoint",
"error", err,
-270
View File
@@ -1,270 +0,0 @@
package scanner
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
)
// extractLayers extracts all image layers from storage to a temporary directory
// Returns the directory path and a cleanup function
func (w *Worker) extractLayers(ctx context.Context, job *ScanJob) (string, func(), error) {
// Create temp directory for extraction
// Use the database directory as the base (since we're in a scratch container with no /tmp)
scanTmpBase := filepath.Join(w.config.Database.Path, "scanner-tmp")
if err := os.MkdirAll(scanTmpBase, 0755); err != nil {
return "", nil, fmt.Errorf("failed to create scanner temp base: %w", err)
}
tmpDir, err := os.MkdirTemp(scanTmpBase, "scan-*")
if err != nil {
return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
}
cleanup := func() {
if err := os.RemoveAll(tmpDir); err != nil {
slog.Warn("Failed to clean up temp directory", "dir", tmpDir, "error", err)
}
}
// Create image directory structure
imageDir := filepath.Join(tmpDir, "image")
if err := os.MkdirAll(imageDir, 0755); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to create image directory: %w", err)
}
// Download and extract config blob
slog.Info("Downloading config blob", "digest", job.Config.Digest)
configPath := filepath.Join(imageDir, "config.json")
if err := w.downloadBlob(ctx, job.Config.Digest, configPath); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to download config blob: %w", err)
}
// Validate config is valid JSON
configData, err := os.ReadFile(configPath)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to read config: %w", err)
}
var configObj map[string]interface{}
if err := json.Unmarshal(configData, &configObj); err != nil {
cleanup()
return "", nil, fmt.Errorf("invalid config JSON: %w", err)
}
// Create layers directory for extracted content
layersDir := filepath.Join(imageDir, "layers")
if err := os.MkdirAll(layersDir, 0755); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to create layers directory: %w", err)
}
// Download and extract each layer in order (creating overlayfs-style filesystem)
rootfsDir := filepath.Join(imageDir, "rootfs")
if err := os.MkdirAll(rootfsDir, 0755); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to create rootfs directory: %w", err)
}
for i, layer := range job.Layers {
slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size)
// Download layer blob to temp file
layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d.tar.gz", i))
if err := w.downloadBlob(ctx, layer.Digest, layerPath); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to download layer %d: %w", i, err)
}
// Extract layer on top of rootfs (overlayfs style)
if err := w.extractTarGz(layerPath, rootfsDir); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to extract layer %d: %w", i, err)
}
// Remove layer tar.gz to save space
os.Remove(layerPath)
}
// Check what was extracted
entries, err := os.ReadDir(rootfsDir)
if err != nil {
slog.Warn("Failed to read rootfs directory", "error", err)
} else {
slog.Info("Successfully extracted image",
"layers", len(job.Layers),
"rootfs", rootfsDir,
"topLevelEntries", len(entries),
"sampleEntries", func() []string {
var samples []string
for i, e := range entries {
if i >= 10 {
break
}
samples = append(samples, e.Name())
}
return samples
}())
}
return rootfsDir, cleanup, nil
}
// downloadBlob downloads a blob from storage to a local file
func (w *Worker) downloadBlob(ctx context.Context, digest, destPath string) error {
// Convert digest to storage path using distribution's sharding scheme
// Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
// where 47 is the first 2 characters of the hash for directory sharding
blobPath := blobPathForDigest(digest)
// Open blob from storage driver
reader, err := w.driver.Reader(ctx, blobPath, 0)
if err != nil {
return fmt.Errorf("failed to open blob %s: %w", digest, err)
}
defer reader.Close()
// Create destination file
dest, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer dest.Close()
// Copy blob data to file
if _, err := io.Copy(dest, reader); err != nil {
return fmt.Errorf("failed to copy blob data: %w", err)
}
return nil
}
// extractTarGz extracts a tar.gz file to a destination directory (overlayfs style)
func (w *Worker) extractTarGz(tarGzPath, destDir string) error {
// Open tar.gz file
file, err := os.Open(tarGzPath)
if err != nil {
return fmt.Errorf("failed to open tar.gz: %w", err)
}
defer file.Close()
// Create gzip reader
gzr, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzr.Close()
// Create tar reader
tr := tar.NewReader(gzr)
// Extract each file
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar header: %w", err)
}
// Build target path (clean to prevent path traversal)
target := filepath.Join(destDir, filepath.Clean(header.Name))
// Ensure target is within destDir (security check)
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
slog.Warn("Skipping path outside destination", "path", header.Name)
continue
}
switch header.Typeflag {
case tar.TypeDir:
// Create directory
if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
return fmt.Errorf("failed to create directory %s: %w", target, err)
}
case tar.TypeReg:
// Create parent directory
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
// Create file
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return fmt.Errorf("failed to create file %s: %w", target, err)
}
// Copy file contents
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return fmt.Errorf("failed to write file %s: %w", target, err)
}
outFile.Close()
case tar.TypeSymlink:
// Create symlink
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for symlink: %w", err)
}
// Remove existing file/symlink if it exists
os.Remove(target)
if err := os.Symlink(header.Linkname, target); err != nil {
slog.Warn("Failed to create symlink", "target", target, "link", header.Linkname, "error", err)
}
case tar.TypeLink:
// Create hard link
linkTarget := filepath.Join(destDir, filepath.Clean(header.Linkname))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for hardlink: %w", err)
}
// Remove existing file if it exists
os.Remove(target)
if err := os.Link(linkTarget, target); err != nil {
slog.Warn("Failed to create hardlink", "target", target, "link", linkTarget, "error", err)
}
default:
slog.Debug("Skipping unsupported tar entry type", "type", header.Typeflag, "name", header.Name)
}
}
return nil
}
// blobPathForDigest converts a digest to a storage path using distribution's sharding scheme
// Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
// where 47 is the first 2 characters of the hash for directory sharding
func blobPathForDigest(digest string) string {
// Split digest into algorithm and hash
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
// Fallback for malformed digest
return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
}
algorithm := parts[0]
hash := parts[1]
// Use first 2 characters for sharding
if len(hash) < 2 {
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
}
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
}
-351
View File
@@ -1,351 +0,0 @@
package scanner
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/anchore/grype/grype"
"github.com/anchore/grype/grype/db/v6/distribution"
"github.com/anchore/grype/grype/db/v6/installation"
"github.com/anchore/grype/grype/distro"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/matcher"
"github.com/anchore/grype/grype/matcher/dotnet"
"github.com/anchore/grype/grype/matcher/golang"
"github.com/anchore/grype/grype/matcher/java"
"github.com/anchore/grype/grype/matcher/javascript"
"github.com/anchore/grype/grype/matcher/python"
"github.com/anchore/grype/grype/matcher/ruby"
"github.com/anchore/grype/grype/matcher/stock"
grypePkg "github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/syft/syft/sbom"
)
// Global vulnerability database (shared across workers)
var (
vulnDB vulnerability.Provider
vulnDBLock sync.RWMutex
)
// scanVulnerabilities scans an SBOM for vulnerabilities using Grype
// Returns vulnerability report JSON, digest, summary, and any error
func (w *Worker) scanVulnerabilities(ctx context.Context, s *sbom.SBOM) ([]byte, string, VulnerabilitySummary, error) {
slog.Info("Scanning for vulnerabilities with Grype")
// Load vulnerability database (cached globally)
store, err := w.loadVulnDatabase(ctx)
if err != nil {
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to load vulnerability database: %w", err)
}
// Create package context from SBOM (need distro for synthesis)
var grypeDistro *distro.Distro
if s.Artifacts.LinuxDistribution != nil {
grypeDistro = distro.FromRelease(s.Artifacts.LinuxDistribution, nil)
if grypeDistro != nil {
slog.Info("Using distro for package synthesis",
"name", grypeDistro.Name(),
"version", grypeDistro.Version,
"type", grypeDistro.Type,
"codename", grypeDistro.Codename)
}
}
// Convert Syft packages to Grype packages WITH distro info
synthesisConfig := grypePkg.SynthesisConfig{
GenerateMissingCPEs: true,
Distro: grypePkg.DistroConfig{
Override: grypeDistro,
},
}
grypePackages := grypePkg.FromCollection(s.Artifacts.Packages, synthesisConfig)
slog.Info("Converted packages for vulnerability scanning",
"syftPackages", s.Artifacts.Packages.PackageCount(),
"grypePackages", len(grypePackages),
"distro", func() string {
if s.Artifacts.LinuxDistribution != nil {
return fmt.Sprintf("%s %s", s.Artifacts.LinuxDistribution.Name, s.Artifacts.LinuxDistribution.Version)
}
return "none"
}())
// Create matchers
matchers := matcher.NewDefaultMatchers(matcher.Config{
Java: java.MatcherConfig{},
Ruby: ruby.MatcherConfig{},
Python: python.MatcherConfig{},
Dotnet: dotnet.MatcherConfig{},
Javascript: javascript.MatcherConfig{},
Golang: golang.MatcherConfig{},
Stock: stock.MatcherConfig{},
})
// Create package context with the same distro we used for synthesis
pkgContext := grypePkg.Context{
Source: &s.Source,
Distro: grypeDistro,
}
// Create vulnerability matcher
vulnerabilityMatcher := &grype.VulnerabilityMatcher{
VulnerabilityProvider: store,
Matchers: matchers,
NormalizeByCVE: true,
}
// Find vulnerabilities
slog.Info("Matching vulnerabilities",
"packages", len(grypePackages),
"distro", func() string {
if grypeDistro != nil {
return fmt.Sprintf("%s %s", grypeDistro.Name(), grypeDistro.Version)
}
return "none"
}())
allMatches, _, err := vulnerabilityMatcher.FindMatches(grypePackages, pkgContext)
if err != nil {
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to find vulnerabilities: %w", err)
}
slog.Info("Vulnerability matching complete",
"totalMatches", allMatches.Count())
// If we found 0 matches, log some diagnostic info
if allMatches.Count() == 0 {
slog.Warn("No vulnerability matches found - this may indicate an issue",
"distro", func() string {
if grypeDistro != nil {
return fmt.Sprintf("%s %s", grypeDistro.Name(), grypeDistro.Version)
}
return "none"
}(),
"packages", len(grypePackages),
"databaseBuilt", func() string {
vulnDBLock.RLock()
defer vulnDBLock.RUnlock()
if vulnDB == nil {
return "not loaded"
}
// We can't easily get the build date here without exposing internal state
return "loaded"
}())
}
// Count vulnerabilities by severity
summary := w.countVulnerabilitiesBySeverity(*allMatches)
slog.Info("Vulnerability scan complete",
"critical", summary.Critical,
"high", summary.High,
"medium", summary.Medium,
"low", summary.Low,
"total", summary.Total)
// Create vulnerability report JSON
report := map[string]interface{}{
"matches": allMatches.Sorted(),
"source": s.Source,
"distro": s.Artifacts.LinuxDistribution,
"descriptor": map[string]interface{}{
"name": "grype",
"version": "v0.102.0", // TODO: Get actual Grype version
},
"summary": summary,
}
// Encode report to JSON
reportJSON, err := json.MarshalIndent(report, "", " ")
if err != nil {
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to encode vulnerability report: %w", err)
}
// Calculate digest
hash := sha256.Sum256(reportJSON)
digest := fmt.Sprintf("sha256:%x", hash)
slog.Info("Vulnerability report generated", "size", len(reportJSON), "digest", digest)
// Upload report blob to storage
if err := w.uploadBlob(ctx, digest, reportJSON); err != nil {
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to upload vulnerability report: %w", err)
}
return reportJSON, digest, summary, nil
}
// loadVulnDatabase loads the Grype vulnerability database (with caching)
func (w *Worker) loadVulnDatabase(ctx context.Context) (vulnerability.Provider, error) {
// Check if database is already loaded
vulnDBLock.RLock()
if vulnDB != nil {
vulnDBLock.RUnlock()
return vulnDB, nil
}
vulnDBLock.RUnlock()
// Acquire write lock to load database
vulnDBLock.Lock()
defer vulnDBLock.Unlock()
// Check again (another goroutine might have loaded it)
if vulnDB != nil {
return vulnDB, nil
}
slog.Info("Loading Grype vulnerability database", "path", w.config.Scanner.VulnDBPath)
// Ensure database directory exists
if err := ensureDir(w.config.Scanner.VulnDBPath); err != nil {
return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err)
}
// Configure database distribution
distConfig := distribution.DefaultConfig()
// Configure database installation
installConfig := installation.Config{
DBRootDir: w.config.Scanner.VulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: w.config.Scanner.VulnDBUpdateInterval,
}
// Load database (should already be downloaded by initializeVulnDatabase)
store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false)
if err != nil {
return nil, fmt.Errorf("failed to load vulnerability database (status=%v): %w (hint: database may still be downloading)", status, err)
}
slog.Info("Vulnerability database loaded",
"status", status,
"built", status.Built,
"location", status.Path,
"schemaVersion", status.SchemaVersion)
// Check database file size to verify it has content
if stat, err := os.Stat(status.Path); err == nil {
slog.Info("Vulnerability database file stats",
"size", stat.Size(),
"sizeMB", stat.Size()/1024/1024)
}
// Cache database globally
vulnDB = store
slog.Info("Vulnerability database loaded successfully")
return vulnDB, nil
}
// countVulnerabilitiesBySeverity counts vulnerabilities by severity level
func (w *Worker) countVulnerabilitiesBySeverity(matches match.Matches) VulnerabilitySummary {
summary := VulnerabilitySummary{}
for m := range matches.Enumerate() {
summary.Total++
// Get severity from vulnerability metadata
if m.Vulnerability.Metadata != nil {
severity := m.Vulnerability.Metadata.Severity
switch severity {
case "Critical":
summary.Critical++
case "High":
summary.High++
case "Medium":
summary.Medium++
case "Low":
summary.Low++
}
}
}
return summary
}
// initializeVulnDatabase downloads and initializes the vulnerability database on startup
func (w *Worker) initializeVulnDatabase(ctx context.Context) error {
slog.Info("Initializing vulnerability database", "path", w.config.Scanner.VulnDBPath)
// Ensure database directory exists
if err := ensureDir(w.config.Scanner.VulnDBPath); err != nil {
return fmt.Errorf("failed to create vulnerability database directory: %w", err)
}
// Create temp directory for Grype downloads (scratch container has no /tmp)
tmpDir := filepath.Join(w.config.Database.Path, "tmp")
if err := ensureDir(tmpDir); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
// Set TMPDIR environment variable so Grype uses our temp directory
oldTmpDir := os.Getenv("TMPDIR")
os.Setenv("TMPDIR", tmpDir)
defer func() {
if oldTmpDir != "" {
os.Setenv("TMPDIR", oldTmpDir)
} else {
os.Unsetenv("TMPDIR")
}
}()
// Configure database distribution
distConfig := distribution.DefaultConfig()
// Configure database installation
installConfig := installation.Config{
DBRootDir: w.config.Scanner.VulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: w.config.Scanner.VulnDBUpdateInterval,
}
// Create distribution client for downloading
downloader, err := distribution.NewClient(distConfig)
if err != nil {
return fmt.Errorf("failed to create database downloader: %w", err)
}
// Create curator to manage database
curator, err := installation.NewCurator(installConfig, downloader)
if err != nil {
return fmt.Errorf("failed to create database curator: %w", err)
}
// Check if database already exists
status := curator.Status()
if !status.Built.IsZero() && status.Error == nil {
slog.Info("Vulnerability database already exists", "built", status.Built, "schema", status.SchemaVersion)
return nil
}
// Download database (this may take several minutes)
slog.Info("Downloading vulnerability database (this may take 5-10 minutes)...")
updated, err := curator.Update()
if err != nil {
return fmt.Errorf("failed to download vulnerability database: %w", err)
}
if updated {
slog.Info("Vulnerability database downloaded successfully")
} else {
slog.Info("Vulnerability database is up to date")
}
return nil
}
// ensureDir creates a directory if it doesn't exist
func ensureDir(path string) error {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", path, err)
}
return nil
}
-67
View File
@@ -1,67 +0,0 @@
package scanner
import (
"time"
"atcr.io/pkg/atproto"
)
// ScanJob represents a vulnerability scanning job for a container image
type ScanJob struct {
// ManifestDigest is the digest of the manifest to scan
ManifestDigest string
// Repository is the repository name (e.g., "alice/myapp")
Repository string
// Tag is the tag name (e.g., "latest")
Tag string
// UserDID is the DID of the user who owns this image
UserDID string
// UserHandle is the handle of the user (for display)
UserHandle string
// Config is the image config blob descriptor
Config atproto.BlobReference
// Layers are the image layer blob descriptors (in order)
Layers []atproto.BlobReference
// EnqueuedAt is when this job was enqueued
EnqueuedAt time.Time
}
// ScanResult represents the result of a vulnerability scan
type ScanResult struct {
// Job is the original scan job
Job *ScanJob
// VulnerabilitiesJSON is the raw Grype JSON output
VulnerabilitiesJSON []byte
// Summary contains vulnerability counts by severity
Summary VulnerabilitySummary
// SBOMDigest is the digest of the SBOM blob (if SBOM was generated)
SBOMDigest string
// VulnDigest is the digest of the vulnerability report blob
VulnDigest string
// ScannedAt is when the scan completed
ScannedAt time.Time
// ScannerVersion is the version of the scanner used
ScannerVersion string
}
// VulnerabilitySummary contains counts of vulnerabilities by severity
type VulnerabilitySummary struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Total int `json:"total"`
}
-226
View File
@@ -1,226 +0,0 @@
package scanner
import (
"context"
"fmt"
"log/slog"
"sync"
"atcr.io/pkg/atproto"
)
// Queue manages a pool of workers for scanning container images
type Queue struct {
jobs chan *ScanJob
results chan *ScanResult
workers int
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
// NewQueue creates a new scanner queue with the specified number of workers
func NewQueue(workers int, bufferSize int) *Queue {
ctx, cancel := context.WithCancel(context.Background())
return &Queue{
jobs: make(chan *ScanJob, bufferSize),
results: make(chan *ScanResult, bufferSize),
workers: workers,
ctx: ctx,
cancel: cancel,
}
}
// Start starts the worker pool
// The workerFunc is called for each job to perform the actual scanning
func (q *Queue) Start(workerFunc func(context.Context, *ScanJob) (*ScanResult, error)) {
slog.Info("Starting scanner worker pool", "workers", q.workers)
for i := 0; i < q.workers; i++ {
q.wg.Add(1)
go q.worker(i, workerFunc)
}
// Start result handler goroutine
q.wg.Add(1)
go q.resultHandler()
}
// worker processes jobs from the queue
func (q *Queue) worker(id int, workerFunc func(context.Context, *ScanJob) (*ScanResult, error)) {
defer q.wg.Done()
slog.Info("Scanner worker started", "worker_id", id)
for {
select {
case <-q.ctx.Done():
slog.Info("Scanner worker shutting down", "worker_id", id)
return
case job, ok := <-q.jobs:
if !ok {
slog.Info("Scanner worker: jobs channel closed", "worker_id", id)
return
}
slog.Info("Scanner worker processing job",
"worker_id", id,
"repository", job.Repository,
"tag", job.Tag,
"digest", job.ManifestDigest)
result, err := workerFunc(q.ctx, job)
if err != nil {
slog.Error("Scanner worker failed to process job",
"worker_id", id,
"repository", job.Repository,
"tag", job.Tag,
"error", err)
continue
}
// Send result to results channel
select {
case q.results <- result:
slog.Info("Scanner worker completed job",
"worker_id", id,
"repository", job.Repository,
"tag", job.Tag,
"vulnerabilities", result.Summary.Total)
case <-q.ctx.Done():
return
}
}
}
}
// resultHandler processes scan results (for logging and metrics)
func (q *Queue) resultHandler() {
defer q.wg.Done()
for {
select {
case <-q.ctx.Done():
return
case result, ok := <-q.results:
if !ok {
return
}
// Log the result
slog.Info("Scan completed",
"repository", result.Job.Repository,
"tag", result.Job.Tag,
"digest", result.Job.ManifestDigest,
"critical", result.Summary.Critical,
"high", result.Summary.High,
"medium", result.Summary.Medium,
"low", result.Summary.Low,
"total", result.Summary.Total,
"scanner", result.ScannerVersion)
}
}
}
// Enqueue adds a job to the queue
func (q *Queue) Enqueue(jobAny any) error {
// Type assert to ScanJob (can be map or struct from HandleNotifyManifest)
var job *ScanJob
switch v := jobAny.(type) {
case *ScanJob:
job = v
case map[string]interface{}:
// Convert map to ScanJob (from HandleNotifyManifest)
job = &ScanJob{
ManifestDigest: v["manifestDigest"].(string),
Repository: v["repository"].(string),
Tag: v["tag"].(string),
UserDID: v["userDID"].(string),
UserHandle: v["userHandle"].(string),
}
// Parse config blob reference
if configMap, ok := v["config"].(map[string]interface{}); ok {
job.Config = atproto.BlobReference{
Digest: configMap["digest"].(string),
Size: convertToInt64(configMap["size"]),
MediaType: configMap["mediaType"].(string),
}
}
// Parse layers
if layersSlice, ok := v["layers"].([]interface{}); ok {
slog.Info("Parsing layers from scan job",
"layersFound", len(layersSlice))
job.Layers = make([]atproto.BlobReference, len(layersSlice))
for i, layerAny := range layersSlice {
if layerMap, ok := layerAny.(map[string]interface{}); ok {
job.Layers[i] = atproto.BlobReference{
Digest: layerMap["digest"].(string),
Size: convertToInt64(layerMap["size"]),
MediaType: layerMap["mediaType"].(string),
}
}
}
} else {
slog.Warn("No layers found in scan job map",
"layersType", fmt.Sprintf("%T", v["layers"]),
"layersValue", v["layers"])
}
default:
return fmt.Errorf("invalid job type: %T", jobAny)
}
select {
case q.jobs <- job:
slog.Info("Enqueued scan job",
"repository", job.Repository,
"tag", job.Tag,
"digest", job.ManifestDigest)
return nil
case <-q.ctx.Done():
return q.ctx.Err()
}
}
// Shutdown gracefully shuts down the queue, waiting for all workers to finish
func (q *Queue) Shutdown() {
slog.Info("Shutting down scanner queue")
// Close the jobs channel to signal no more jobs
close(q.jobs)
// Wait for all workers to finish
q.wg.Wait()
// Close results channel
close(q.results)
// Cancel context
q.cancel()
slog.Info("Scanner queue shut down complete")
}
// Len returns the number of jobs currently in the queue
func (q *Queue) Len() int {
return len(q.jobs)
}
// convertToInt64 converts an interface{} number to int64, handling both float64 and int64
func convertToInt64(v interface{}) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
case int:
return int64(n)
default:
return 0
}
}
-123
View File
@@ -1,123 +0,0 @@
package scanner
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"time"
"atcr.io/pkg/atproto"
)
// storeResults uploads scan results and creates ORAS manifest records in the hold's PDS
func (w *Worker) storeResults(ctx context.Context, job *ScanJob, sbomDigest, vulnDigest string, vulnJSON []byte, summary VulnerabilitySummary) error {
if !w.config.Scanner.VulnEnabled {
slog.Info("Vulnerability scanning disabled, skipping result storage")
return nil
}
slog.Info("Storing scan results as ORAS artifact",
"repository", job.Repository,
"subjectDigest", job.ManifestDigest,
"vulnDigest", vulnDigest)
// Create ORAS manifest for vulnerability report
orasManifest := map[string]interface{}{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.atcr.vulnerabilities+json",
"config": map[string]interface{}{
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", // Empty JSON object
"size": 2,
},
"subject": map[string]interface{}{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": job.ManifestDigest,
"size": 0, // We don't have the size, but it's optional
},
"layers": []map[string]interface{}{
{
"mediaType": "application/json",
"digest": vulnDigest,
"size": len(vulnJSON),
"annotations": map[string]string{
"org.opencontainers.image.title": "vulnerability-report.json",
},
},
},
"annotations": map[string]string{
"io.atcr.vuln.critical": fmt.Sprintf("%d", summary.Critical),
"io.atcr.vuln.high": fmt.Sprintf("%d", summary.High),
"io.atcr.vuln.medium": fmt.Sprintf("%d", summary.Medium),
"io.atcr.vuln.low": fmt.Sprintf("%d", summary.Low),
"io.atcr.vuln.total": fmt.Sprintf("%d", summary.Total),
"io.atcr.vuln.scannedAt": time.Now().Format(time.RFC3339),
"io.atcr.vuln.scannerVersion": w.getScannerVersion(),
},
}
// Encode ORAS manifest to JSON
orasManifestJSON, err := json.Marshal(orasManifest)
if err != nil {
return fmt.Errorf("failed to encode ORAS manifest: %w", err)
}
// Calculate ORAS manifest digest
orasDigest := fmt.Sprintf("sha256:%x", sha256Bytes(orasManifestJSON))
// Upload ORAS manifest blob to storage
if err := w.uploadBlob(ctx, orasDigest, orasManifestJSON); err != nil {
return fmt.Errorf("failed to upload ORAS manifest blob: %w", err)
}
// Create manifest record in hold's PDS
if err := w.createManifestRecord(ctx, job, orasDigest, orasManifestJSON, summary); err != nil {
return fmt.Errorf("failed to create manifest record: %w", err)
}
slog.Info("Successfully stored scan results", "orasDigest", orasDigest)
return nil
}
// createManifestRecord creates an ORAS manifest record in the hold's PDS
func (w *Worker) createManifestRecord(ctx context.Context, job *ScanJob, orasDigest string, orasManifestJSON []byte, summary VulnerabilitySummary) error {
// Create ManifestRecord from ORAS manifest
record, err := atproto.NewManifestRecord(job.Repository, orasDigest, orasManifestJSON)
if err != nil {
return fmt.Errorf("failed to create manifest record: %w", err)
}
// Set SBOM/vulnerability specific fields
record.OwnerDID = job.UserDID
record.ScannedAt = time.Now().Format(time.RFC3339)
record.ScannerVersion = w.getScannerVersion()
// Add hold DID (this ORAS artifact is stored in the hold's PDS)
record.HoldDID = w.pds.DID()
// Convert digest to record key (remove "sha256:" prefix)
rkey := orasDigest[len("sha256:"):]
// Store record in hold's PDS
slog.Info("Creating manifest record in hold's PDS",
"collection", atproto.ManifestCollection,
"rkey", rkey,
"ownerDid", job.UserDID)
_, _, err = w.pds.CreateManifestRecord(ctx, record, rkey)
if err != nil {
return fmt.Errorf("failed to put record in PDS: %w", err)
}
slog.Info("Manifest record created successfully", "uri", fmt.Sprintf("at://%s/%s/%s", w.pds.DID(), atproto.ManifestCollection, rkey))
return nil
}
// sha256Bytes calculates SHA256 hash of byte slice
func sha256Bytes(data []byte) []byte {
hash := sha256.Sum256(data)
return hash[:]
}
-128
View File
@@ -1,128 +0,0 @@
package scanner
import (
"context"
"crypto/sha256"
"fmt"
"log/slog"
"os"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source/directorysource"
)
// generateSBOM generates an SBOM using Syft from an extracted image directory
// Returns the SBOM object, SBOM JSON, its digest, and any error
func (w *Worker) generateSBOM(ctx context.Context, imageDir string) (*sbom.SBOM, []byte, string, error) {
slog.Info("Generating SBOM with Syft", "imageDir", imageDir)
// Check if directory exists and is accessible
entries, err := os.ReadDir(imageDir)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to read image directory: %w", err)
}
slog.Info("Image directory contents",
"path", imageDir,
"entries", len(entries),
"sampleFiles", func() []string {
var samples []string
for i, e := range entries {
if i >= 20 {
break
}
samples = append(samples, e.Name())
}
return samples
}())
// Create Syft source from directory
src, err := directorysource.NewFromPath(imageDir)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create Syft source: %w", err)
}
defer src.Close()
// Generate SBOM
slog.Info("Running Syft cataloging")
sbomResult, err := syft.CreateSBOM(ctx, src, nil)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate SBOM: %w", err)
}
if sbomResult == nil {
return nil, nil, "", fmt.Errorf("Syft returned nil SBOM")
}
slog.Info("SBOM generated",
"packages", sbomResult.Artifacts.Packages.PackageCount(),
"distro", func() string {
if sbomResult.Artifacts.LinuxDistribution != nil {
return fmt.Sprintf("%s %s", sbomResult.Artifacts.LinuxDistribution.Name, sbomResult.Artifacts.LinuxDistribution.Version)
}
return "none"
}())
// Encode SBOM to SPDX JSON format
encoder, err := spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig())
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create SPDX encoder: %w", err)
}
sbomJSON, err := format.Encode(*sbomResult, encoder)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to encode SBOM to SPDX JSON: %w", err)
}
// Calculate digest
hash := sha256.Sum256(sbomJSON)
digest := fmt.Sprintf("sha256:%x", hash)
slog.Info("SBOM encoded", "format", "spdx-json", "size", len(sbomJSON), "digest", digest)
// Upload SBOM blob to storage
if err := w.uploadBlob(ctx, digest, sbomJSON); err != nil {
return nil, nil, "", fmt.Errorf("failed to upload SBOM blob: %w", err)
}
return sbomResult, sbomJSON, digest, nil
}
// uploadBlob uploads a blob to storage
func (w *Worker) uploadBlob(ctx context.Context, digest string, data []byte) error {
// Convert digest to storage path (same format as distribution uses)
// Path format: /docker/registry/v2/blobs/sha256/ab/abcd1234.../data
algorithm := "sha256"
digestHex := digest[len("sha256:"):]
if len(digestHex) < 2 {
return fmt.Errorf("invalid digest: %s", digest)
}
blobPath := fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data",
algorithm,
digestHex[:2],
digestHex)
slog.Info("Uploading blob to storage", "digest", digest, "size", len(data), "path", blobPath)
// Write blob to storage
writer, err := w.driver.Writer(ctx, blobPath, false)
if err != nil {
return fmt.Errorf("failed to create storage writer: %w", err)
}
defer writer.Close()
if _, err := writer.Write(data); err != nil {
writer.Cancel(ctx)
return fmt.Errorf("failed to write blob data: %w", err)
}
if err := writer.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit blob: %w", err)
}
slog.Info("Successfully uploaded blob", "digest", digest)
return nil
}
-116
View File
@@ -1,116 +0,0 @@
package scanner
import (
"context"
"fmt"
"log/slog"
"time"
"atcr.io/pkg/hold"
"atcr.io/pkg/hold/pds"
"github.com/distribution/distribution/v3/registry/storage/driver"
)
// Worker performs vulnerability scanning on container images
type Worker struct {
config *hold.Config
driver driver.StorageDriver
pds *pds.HoldPDS
queue *Queue
}
// NewWorker creates a new scanner worker
func NewWorker(config *hold.Config, driver driver.StorageDriver, pds *pds.HoldPDS) *Worker {
return &Worker{
config: config,
driver: driver,
pds: pds,
}
}
// Start starts the worker pool and initializes vulnerability database
func (w *Worker) Start(queue *Queue) {
w.queue = queue
// Initialize vulnerability database on startup if scanning is enabled
if w.config.Scanner.VulnEnabled {
go func() {
ctx := context.Background()
if err := w.initializeVulnDatabase(ctx); err != nil {
slog.Error("Failed to initialize vulnerability database", "error", err)
slog.Warn("Vulnerability scanning will be disabled until database is available")
}
}()
}
queue.Start(w.processJob)
}
// processJob processes a single scan job
func (w *Worker) processJob(ctx context.Context, job *ScanJob) (*ScanResult, error) {
slog.Info("Processing scan job",
"repository", job.Repository,
"tag", job.Tag,
"digest", job.ManifestDigest,
"layers", len(job.Layers))
startTime := time.Now()
// Step 1: Extract image layers from storage
slog.Info("Extracting image layers", "repository", job.Repository)
imageDir, cleanup, err := w.extractLayers(ctx, job)
if err != nil {
return nil, fmt.Errorf("failed to extract layers: %w", err)
}
defer cleanup()
// Step 2: Generate SBOM with Syft
slog.Info("Generating SBOM", "repository", job.Repository)
sbomResult, _, sbomDigest, err := w.generateSBOM(ctx, imageDir)
if err != nil {
return nil, fmt.Errorf("failed to generate SBOM: %w", err)
}
// Step 3: Scan SBOM with Grype (if enabled)
var vulnJSON []byte
var vulnDigest string
var summary VulnerabilitySummary
if w.config.Scanner.VulnEnabled {
slog.Info("Scanning for vulnerabilities", "repository", job.Repository)
vulnJSON, vulnDigest, summary, err = w.scanVulnerabilities(ctx, sbomResult)
if err != nil {
return nil, fmt.Errorf("failed to scan vulnerabilities: %w", err)
}
}
// Step 4: Upload results to storage and create ORAS manifests
slog.Info("Storing scan results", "repository", job.Repository)
err = w.storeResults(ctx, job, sbomDigest, vulnDigest, vulnJSON, summary)
if err != nil {
return nil, fmt.Errorf("failed to store results: %w", err)
}
duration := time.Since(startTime)
slog.Info("Scan job completed",
"repository", job.Repository,
"tag", job.Tag,
"duration", duration,
"vulnerabilities", summary.Total)
return &ScanResult{
Job: job,
VulnerabilitiesJSON: vulnJSON,
Summary: summary,
SBOMDigest: sbomDigest,
VulnDigest: vulnDigest,
ScannedAt: time.Now(),
ScannerVersion: w.getScannerVersion(),
}, nil
}
// getScannerVersion returns the version string for the scanner
func (w *Worker) getScannerVersion() string {
// TODO: Get actual Syft and Grype versions dynamically
return "syft-v1.36.0/grype-v0.102.0"
}
+24
View File
@@ -44,6 +44,7 @@ type HoldServer struct {
// internal fields for shutdown
httpServer *http.Server
broadcaster *pds.EventBroadcaster
scanBroadcaster *pds.ScanBroadcaster
garbageCollector *gc.GarbageCollector
adminUI *admin.AdminUI
}
@@ -147,6 +148,20 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, driver, s.broadcaster, nil, s.QuotaManager)
ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, driver, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager)
// Initialize scan broadcaster if scanner secret is configured
if cfg.Scanner.Secret != "" {
holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL)
scanDBPath := cfg.Database.Path + "/db.sqlite3"
sb, err := pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, scanDBPath, driver, s.PDS)
if err != nil {
return nil, fmt.Errorf("failed to initialize scan broadcaster: %w", err)
}
s.scanBroadcaster = sb
xrpcHandler.SetScanBroadcaster(sb)
ociHandler.SetScanBroadcaster(sb)
slog.Info("Scan broadcaster initialized (scanner WebSocket enabled)")
}
// Initialize garbage collector
gcConfig := gc.LoadConfigFromEnv()
s.garbageCollector = gc.NewGarbageCollector(s.PDS, driver, gcConfig)
@@ -297,6 +312,15 @@ func (s *HoldServer) shutdown() {
slog.Info("Garbage collector stopped")
}
// Close scan broadcaster database connection
if s.scanBroadcaster != nil {
if err := s.scanBroadcaster.Close(); err != nil {
slog.Warn("Failed to close scan broadcaster database", "error", err)
} else {
slog.Info("Scan broadcaster database closed")
}
}
// Close broadcaster database connection
if s.broadcaster != nil {
if err := s.broadcaster.Close(); err != nil {
+58 -69
View File
@@ -1,26 +1,16 @@
package s3
import (
"context"
"fmt"
"sync"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go-v2/aws"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
// mockPresignable implements Presignable for testing
type mockPresignable struct {
url string
}
// Presign returns the pre-configured mock URL
func (m *mockPresignable) Presign(expire time.Duration) (string, error) {
return m.url, nil
}
// MockS3Client implements S3Client for testing without real S3 credentials.
// It generates fake presigned URLs that point to a test server.
type MockS3Client struct {
@@ -28,7 +18,7 @@ type MockS3Client struct {
// Requests to these URLs should be handled by a test server (httptest.Server).
TestServerURL string
// UploadID is returned by CreateMultipartUploadWithContext.
// UploadID is returned by CreateMultipartUpload.
// If empty, a UUID is generated.
UploadID string
@@ -48,13 +38,13 @@ type MockS3Client struct {
AbortError error
}
// CreateMultipartCall records a CreateMultipartUploadWithContext call
// CreateMultipartCall records a CreateMultipartUpload call
type CreateMultipartCall struct {
Bucket string
Key string
}
// CompleteCall records a CompleteMultipartUploadWithContext call
// CompleteCall records a CompleteMultipartUpload call
type CompleteCall struct {
Bucket string
Key string
@@ -62,34 +52,34 @@ type CompleteCall struct {
Parts int
}
// AbortCall records an AbortMultipartUploadWithContext call
// AbortCall records an AbortMultipartUpload call
type AbortCall struct {
Bucket string
Key string
UploadID string
}
// UploadPartCall records an UploadPartRequest call
// UploadPartCall records a PresignUploadPart call
type UploadPartCall struct {
Bucket string
Key string
UploadID string
PartNumber int64
PartNumber int32
}
// GetObjectCall records a GetObjectRequest call
// GetObjectCall records a PresignGetObject call
type GetObjectCall struct {
Bucket string
Key string
}
// HeadObjectCall records a HeadObjectRequest call
// HeadObjectCall records a PresignHeadObject call
type HeadObjectCall struct {
Bucket string
Key string
}
// PutObjectCall records a PutObjectRequest call
// PutObjectCall records a PresignPutObject call
type PutObjectCall struct {
Bucket string
Key string
@@ -109,14 +99,14 @@ func NewMockS3Client(testServerURL string) *MockS3Client {
}
}
// CreateMultipartUploadWithContext implements S3Client
func (m *MockS3Client) CreateMultipartUploadWithContext(ctx aws.Context, input *s3.CreateMultipartUploadInput, opts ...request.Option) (*s3.CreateMultipartUploadOutput, error) {
// CreateMultipartUpload implements S3Client
func (m *MockS3Client) CreateMultipartUpload(ctx context.Context, input *awss3.CreateMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.CreateMultipartCalls = append(m.CreateMultipartCalls, CreateMultipartCall{
Bucket: aws.StringValue(input.Bucket),
Key: aws.StringValue(input.Key),
Bucket: aws.ToString(input.Bucket),
Key: aws.ToString(input.Key),
})
if m.CreateMultipartError != nil {
@@ -128,13 +118,13 @@ func (m *MockS3Client) CreateMultipartUploadWithContext(ctx aws.Context, input *
uploadID = "mock-upload-" + uuid.New().String()
}
return &s3.CreateMultipartUploadOutput{
return &awss3.CreateMultipartUploadOutput{
UploadId: aws.String(uploadID),
}, nil
}
// CompleteMultipartUploadWithContext implements S3Client
func (m *MockS3Client) CompleteMultipartUploadWithContext(ctx aws.Context, input *s3.CompleteMultipartUploadInput, opts ...request.Option) (*s3.CompleteMultipartUploadOutput, error) {
// CompleteMultipartUpload implements S3Client
func (m *MockS3Client) CompleteMultipartUpload(ctx context.Context, input *awss3.CompleteMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -144,9 +134,9 @@ func (m *MockS3Client) CompleteMultipartUploadWithContext(ctx aws.Context, input
}
m.CompleteCalls = append(m.CompleteCalls, CompleteCall{
Bucket: aws.StringValue(input.Bucket),
Key: aws.StringValue(input.Key),
UploadID: aws.StringValue(input.UploadId),
Bucket: aws.ToString(input.Bucket),
Key: aws.ToString(input.Key),
UploadID: aws.ToString(input.UploadId),
Parts: partsCount,
})
@@ -156,90 +146,89 @@ func (m *MockS3Client) CompleteMultipartUploadWithContext(ctx aws.Context, input
// Return a mock ETag
etag := "\"mock-etag-" + uuid.New().String() + "\""
return &s3.CompleteMultipartUploadOutput{
return &awss3.CompleteMultipartUploadOutput{
ETag: aws.String(etag),
}, nil
}
// AbortMultipartUploadWithContext implements S3Client
func (m *MockS3Client) AbortMultipartUploadWithContext(ctx aws.Context, input *s3.AbortMultipartUploadInput, opts ...request.Option) (*s3.AbortMultipartUploadOutput, error) {
// AbortMultipartUpload implements S3Client
func (m *MockS3Client) AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.AbortCalls = append(m.AbortCalls, AbortCall{
Bucket: aws.StringValue(input.Bucket),
Key: aws.StringValue(input.Key),
UploadID: aws.StringValue(input.UploadId),
Bucket: aws.ToString(input.Bucket),
Key: aws.ToString(input.Key),
UploadID: aws.ToString(input.UploadId),
})
if m.AbortError != nil {
return nil, m.AbortError
}
return &s3.AbortMultipartUploadOutput{}, nil
return &awss3.AbortMultipartUploadOutput{}, nil
}
// UploadPartPresignable implements S3Client
// Returns a mock Presignable that generates test server URLs
func (m *MockS3Client) UploadPartPresignable(input *s3.UploadPartInput) Presignable {
// PresignUploadPart implements S3Client
// Returns a mock presigned URL for test server
func (m *MockS3Client) PresignUploadPart(ctx context.Context, input *awss3.UploadPartInput, expires time.Duration) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.UploadPartCalls = append(m.UploadPartCalls, UploadPartCall{
Bucket: aws.StringValue(input.Bucket),
Key: aws.StringValue(input.Key),
UploadID: aws.StringValue(input.UploadId),
PartNumber: aws.Int64Value(input.PartNumber),
Bucket: aws.ToString(input.Bucket),
Key: aws.ToString(input.Key),
UploadID: aws.ToString(input.UploadId),
PartNumber: aws.ToInt32(input.PartNumber),
})
// Create a mock presignable request
url := fmt.Sprintf("%s/upload/%s?partNumber=%d&uploadId=%s",
m.TestServerURL,
aws.StringValue(input.Key),
aws.Int64Value(input.PartNumber),
aws.StringValue(input.UploadId))
aws.ToString(input.Key),
aws.ToInt32(input.PartNumber),
aws.ToString(input.UploadId))
return &mockPresignable{url: url}
return url, nil
}
// GetObjectPresignable implements S3Client
func (m *MockS3Client) GetObjectPresignable(input *s3.GetObjectInput) Presignable {
// PresignGetObject implements S3Client
func (m *MockS3Client) PresignGetObject(ctx context.Context, input *awss3.GetObjectInput, expires time.Duration) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.GetObjectCalls = append(m.GetObjectCalls, GetObjectCall{
Bucket: aws.StringValue(input.Bucket),
Key: aws.StringValue(input.Key),
Bucket: aws.ToString(input.Bucket),
Key: aws.ToString(input.Key),
})
url := fmt.Sprintf("%s/get/%s", m.TestServerURL, aws.StringValue(input.Key))
return &mockPresignable{url: url}
url := fmt.Sprintf("%s/get/%s", m.TestServerURL, aws.ToString(input.Key))
return url, nil
}
// HeadObjectPresignable implements S3Client
func (m *MockS3Client) HeadObjectPresignable(input *s3.HeadObjectInput) Presignable {
// PresignHeadObject implements S3Client
func (m *MockS3Client) PresignHeadObject(ctx context.Context, input *awss3.HeadObjectInput, expires time.Duration) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.HeadObjectCalls = append(m.HeadObjectCalls, HeadObjectCall{
Bucket: aws.StringValue(input.Bucket),
Key: aws.StringValue(input.Key),
Bucket: aws.ToString(input.Bucket),
Key: aws.ToString(input.Key),
})
url := fmt.Sprintf("%s/head/%s", m.TestServerURL, aws.StringValue(input.Key))
return &mockPresignable{url: url}
url := fmt.Sprintf("%s/head/%s", m.TestServerURL, aws.ToString(input.Key))
return url, nil
}
// PutObjectPresignable implements S3Client
func (m *MockS3Client) PutObjectPresignable(input *s3.PutObjectInput) Presignable {
// PresignPutObject implements S3Client
func (m *MockS3Client) PresignPutObject(ctx context.Context, input *awss3.PutObjectInput, expires time.Duration) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.PutObjectCalls = append(m.PutObjectCalls, PutObjectCall{
Bucket: aws.StringValue(input.Bucket),
Key: aws.StringValue(input.Key),
Bucket: aws.ToString(input.Bucket),
Key: aws.ToString(input.Key),
})
url := fmt.Sprintf("%s/put/%s", m.TestServerURL, aws.StringValue(input.Key))
return &mockPresignable{url: url}
url := fmt.Sprintf("%s/put/%s", m.TestServerURL, aws.ToString(input.Key))
return url, nil
}
+88 -65
View File
@@ -3,88 +3,105 @@
package s3
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
)
// Presignable represents a request that can be presigned for direct client access.
// This interface allows mocking the Presign() method in tests.
type Presignable interface {
Presign(expire time.Duration) (string, error)
}
// S3Client defines the S3 operations used by the hold service.
// This interface allows mocking S3 for tests without real credentials.
// Use RealS3Client to wrap *s3.S3, or MockS3Client for testing.
// Use RealS3Client to wrap *s3.Client, or MockS3Client for testing.
type S3Client interface {
// Multipart upload operations
CreateMultipartUploadWithContext(ctx aws.Context, input *s3.CreateMultipartUploadInput, opts ...request.Option) (*s3.CreateMultipartUploadOutput, error)
CompleteMultipartUploadWithContext(ctx aws.Context, input *s3.CompleteMultipartUploadInput, opts ...request.Option) (*s3.CompleteMultipartUploadOutput, error)
AbortMultipartUploadWithContext(ctx aws.Context, input *s3.AbortMultipartUploadInput, opts ...request.Option) (*s3.AbortMultipartUploadOutput, error)
CreateMultipartUpload(ctx context.Context, input *awss3.CreateMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error)
CompleteMultipartUpload(ctx context.Context, input *awss3.CompleteMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error)
AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error)
// Presigned URL operations - return Presignable interface for testability
// The second return value (_) is ignored by callers in practice
UploadPartPresignable(input *s3.UploadPartInput) Presignable
GetObjectPresignable(input *s3.GetObjectInput) Presignable
HeadObjectPresignable(input *s3.HeadObjectInput) Presignable
PutObjectPresignable(input *s3.PutObjectInput) Presignable
// Presigned URL operations - return URL string directly
PresignGetObject(ctx context.Context, input *awss3.GetObjectInput, expires time.Duration) (string, error)
PresignHeadObject(ctx context.Context, input *awss3.HeadObjectInput, expires time.Duration) (string, error)
PresignPutObject(ctx context.Context, input *awss3.PutObjectInput, expires time.Duration) (string, error)
PresignUploadPart(ctx context.Context, input *awss3.UploadPartInput, expires time.Duration) (string, error)
}
// RealS3Client wraps *s3.S3 to implement S3Client interface
// RealS3Client wraps AWS SDK v2 *s3.Client to implement S3Client interface
type RealS3Client struct {
client *s3.S3
client *awss3.Client
presign *awss3.PresignClient
}
// NewRealS3Client creates a new RealS3Client wrapper
func NewRealS3Client(client *s3.S3) *RealS3Client {
return &RealS3Client{client: client}
func NewRealS3Client(client *awss3.Client) *RealS3Client {
return &RealS3Client{
client: client,
presign: awss3.NewPresignClient(client),
}
}
// CreateMultipartUploadWithContext implements S3Client
func (r *RealS3Client) CreateMultipartUploadWithContext(ctx aws.Context, input *s3.CreateMultipartUploadInput, opts ...request.Option) (*s3.CreateMultipartUploadOutput, error) {
return r.client.CreateMultipartUploadWithContext(ctx, input, opts...)
// CreateMultipartUpload implements S3Client
func (r *RealS3Client) CreateMultipartUpload(ctx context.Context, input *awss3.CreateMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error) {
return r.client.CreateMultipartUpload(ctx, input, opts...)
}
// CompleteMultipartUploadWithContext implements S3Client
func (r *RealS3Client) CompleteMultipartUploadWithContext(ctx aws.Context, input *s3.CompleteMultipartUploadInput, opts ...request.Option) (*s3.CompleteMultipartUploadOutput, error) {
return r.client.CompleteMultipartUploadWithContext(ctx, input, opts...)
// CompleteMultipartUpload implements S3Client
func (r *RealS3Client) CompleteMultipartUpload(ctx context.Context, input *awss3.CompleteMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error) {
return r.client.CompleteMultipartUpload(ctx, input, opts...)
}
// AbortMultipartUploadWithContext implements S3Client
func (r *RealS3Client) AbortMultipartUploadWithContext(ctx aws.Context, input *s3.AbortMultipartUploadInput, opts ...request.Option) (*s3.AbortMultipartUploadOutput, error) {
return r.client.AbortMultipartUploadWithContext(ctx, input, opts...)
// AbortMultipartUpload implements S3Client
func (r *RealS3Client) AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) {
return r.client.AbortMultipartUpload(ctx, input, opts...)
}
// UploadPartPresignable implements S3Client
func (r *RealS3Client) UploadPartPresignable(input *s3.UploadPartInput) Presignable {
req, _ := r.client.UploadPartRequest(input)
return req
// PresignGetObject implements S3Client
func (r *RealS3Client) PresignGetObject(ctx context.Context, input *awss3.GetObjectInput, expires time.Duration) (string, error) {
result, err := r.presign.PresignGetObject(ctx, input, func(opts *awss3.PresignOptions) {
opts.Expires = expires
})
if err != nil {
return "", err
}
return result.URL, nil
}
// GetObjectPresignable implements S3Client
func (r *RealS3Client) GetObjectPresignable(input *s3.GetObjectInput) Presignable {
req, _ := r.client.GetObjectRequest(input)
return req
// PresignHeadObject implements S3Client
func (r *RealS3Client) PresignHeadObject(ctx context.Context, input *awss3.HeadObjectInput, expires time.Duration) (string, error) {
result, err := r.presign.PresignHeadObject(ctx, input, func(opts *awss3.PresignOptions) {
opts.Expires = expires
})
if err != nil {
return "", err
}
return result.URL, nil
}
// HeadObjectPresignable implements S3Client
func (r *RealS3Client) HeadObjectPresignable(input *s3.HeadObjectInput) Presignable {
req, _ := r.client.HeadObjectRequest(input)
return req
// PresignPutObject implements S3Client
func (r *RealS3Client) PresignPutObject(ctx context.Context, input *awss3.PutObjectInput, expires time.Duration) (string, error) {
result, err := r.presign.PresignPutObject(ctx, input, func(opts *awss3.PresignOptions) {
opts.Expires = expires
})
if err != nil {
return "", err
}
return result.URL, nil
}
// PutObjectPresignable implements S3Client
func (r *RealS3Client) PutObjectPresignable(input *s3.PutObjectInput) Presignable {
req, _ := r.client.PutObjectRequest(input)
return req
// PresignUploadPart implements S3Client
func (r *RealS3Client) PresignUploadPart(ctx context.Context, input *awss3.UploadPartInput, expires time.Duration) (string, error) {
result, err := r.presign.PresignUploadPart(ctx, input, func(opts *awss3.PresignOptions) {
opts.Expires = expires
})
if err != nil {
return "", err
}
return result.URL, nil
}
// S3Service wraps an S3 client for presigned URL generation
@@ -111,28 +128,34 @@ func NewS3Service(params map[string]any) (*S3Service, error) {
return nil, fmt.Errorf("S3 bucket not configured")
}
// Build AWS config
awsConfig := &aws.Config{
Region: &region,
}
// Build config options
var configOpts []func(*config.LoadOptions) error
configOpts = append(configOpts, config.WithRegion(region))
// Add credentials if provided (allow IAM role auth if not provided)
if accessKey != "" && secretKey != "" {
awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "")
configOpts = append(configOpts, config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
))
}
// Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.)
if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" {
awsConfig.Endpoint = &endpoint
awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj
}
// Create AWS session
sess, err := session.NewSession(awsConfig)
// Load AWS config
cfg, err := config.LoadDefaultConfig(context.Background(), configOpts...)
if err != nil {
return nil, fmt.Errorf("failed to create AWS session: %w", err)
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
// Custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.)
endpoint, _ := params["regionendpoint"].(string)
// Create S3 client
client := awss3.NewFromConfig(cfg, func(o *awss3.Options) {
if endpoint != "" {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
}
})
var s3PathPrefix string
// Extract path prefix if configured (rootdirectory in S3 params)
if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" {
@@ -146,7 +169,7 @@ func NewS3Service(params map[string]any) (*S3Service, error) {
// Create S3 client wrapped in RealS3Client for interface compatibility
return &S3Service{
Client: NewRealS3Client(s3.New(sess)),
Client: NewRealS3Client(client),
Bucket: bucket,
PathPrefix: s3PathPrefix,
}, nil
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"atcr.io/scanner/internal/client"
"atcr.io/scanner/internal/config"
"atcr.io/scanner/internal/queue"
"atcr.io/scanner/internal/scan"
)
var rootCmd = &cobra.Command{
Use: "atcr-scanner",
Short: "ATCR Scanner - container image vulnerability scanner",
}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the scanner service",
Long: `Start the ATCR scanner service.
The scanner connects to a hold service via WebSocket, receives scan jobs,
generates SBOMs with Syft, scans for vulnerabilities with Grype, and sends
results back over the same WebSocket connection.
Configuration via environment variables (SCANNER_ prefix):
SCANNER_HOLD_URL Hold service URL (required)
SCANNER_SHARED_SECRET Shared secret for auth (required)
SCANNER_WORKERS Worker count (default: 2)
SCANNER_QUEUE_SIZE Max queue depth (default: 100)
SCANNER_VULN_ENABLED Enable Grype scanning (default: true)
SCANNER_VULN_DB_PATH Grype DB location (default: /var/lib/atcr-scanner/vulndb)
SCANNER_TMP_DIR Temp dir for extraction (default: /var/lib/atcr-scanner/tmp)
SCANNER_ADDR Health endpoint addr (default: :9090)`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
slog.Info("Starting ATCR scanner",
"hold_url", cfg.HoldURL,
"workers", cfg.Workers,
"queue_size", cfg.QueueSize,
"vuln_enabled", cfg.VulnEnabled)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create priority queue
q := queue.NewJobQueue(cfg.QueueSize)
// Create hold WebSocket client
holdClient := client.NewHoldClient(cfg.HoldURL, cfg.SharedSecret, q)
// Start WebSocket connection (feeds queue)
go holdClient.Connect()
// Start worker pool (drains queue)
pool := scan.NewWorkerPool(cfg, q, holdClient)
pool.Start(ctx)
// Start health endpoint
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
healthServer := &http.Server{Addr: cfg.Addr, Handler: mux}
go func() {
slog.Info("Health endpoint listening", "addr", cfg.Addr)
if err := healthServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("Health server error", "error", err)
}
}()
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
slog.Info("Received shutdown signal", "signal", sig)
// Graceful shutdown
cancel()
holdClient.Close()
q.Close()
pool.Wait()
healthServer.Close()
slog.Info("Scanner shutdown complete")
return nil
},
}
func init() {
rootCmd.AddCommand(serveCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
slog.Error("Command failed", "error", err)
os.Exit(1)
}
}
+308
View File
@@ -0,0 +1,308 @@
module atcr.io/scanner
go 1.25.4
require (
github.com/anchore/grype v0.107.1
github.com/anchore/syft v1.41.2
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/spf13/cobra v1.10.2
)
exclude google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.59.2 // indirect
cyphar.com/go-pathrs v0.2.1 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/CycloneDX/cyclonedx-go v0.9.3 // indirect
github.com/DataDog/zstd v1.5.7 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
github.com/Intevation/gval v1.3.0 // indirect
github.com/Intevation/jsonpath v0.2.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/acobaugh/osrelease v0.1.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084 // indirect
github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232 // indirect
github.com/anchore/go-collections v0.0.0-20251016125210-a3c352120e8c // indirect
github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d // indirect
github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/anchore/go-macholibre v0.0.0-20250320151634-807da7ad2331 // indirect
github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec // indirect
github.com/anchore/go-struct-converter v0.1.0 // indirect
github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1 // indirect
github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 // indirect
github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect
github.com/anchore/stereoscope v0.1.19 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aquasecurity/go-pep440-version v0.0.1 // indirect
github.com/aquasecurity/go-version v0.0.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/becheran/wildmatch-go v1.0.0 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607 // indirect
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
github.com/bmatcuk/doublestar/v2 v2.0.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.2 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
github.com/containerd/cgroups/v3 v3.1.2 // indirect
github.com/containerd/containerd/api v1.10.0 // indirect
github.com/containerd/containerd/v2 v2.2.1 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/fifo v1.1.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v1.0.0-rc.2 // indirect
github.com/containerd/plugin v1.0.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.0 // indirect
github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.1.5+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.4 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/phpserialize v1.4.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/facebookincubator/nvdtools v0.1.5 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/github/go-spdx/v2 v2.3.6 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-restruct/restruct v1.2.0-alpha // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gocsaf/csaf/v3 v3.5.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-containerregistry v0.20.7 // indirect
github.com/google/licensecheck v0.3.1 // indirect
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/gpustack/gguf-parser-go v0.23.1 // indirect
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-getter v1.8.4 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
github.com/henvic/httpretty v0.1.4 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mholt/archives v0.1.5 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect
github.com/olekukonko/tablewriter v1.1.3 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runtime-spec v1.3.0 // indirect
github.com/opencontainers/selinux v1.13.1 // indirect
github.com/openvex/go-vex v0.2.7 // indirect
github.com/package-url/packageurl-go v0.1.3 // indirect
github.com/pandatix/go-cvss v0.6.2 // indirect
github.com/pborman/indent v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pjbgf/sha1cd v0.4.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/profile v1.7.0 // indirect
github.com/pkg/xattr v0.4.12 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sassoftware/go-rpmutils v0.4.0 // indirect
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb // indirect
github.com/spdx/tools-golang v0.5.7 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/sylabs/sif/v2 v2.22.0 // indirect
github.com/sylabs/squashfs v1.0.6 // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/vbatts/go-mtree v0.7.0 // indirect
github.com/vbatts/tar-split v0.12.2 // indirect
github.com/vifraa/gopom v1.0.0 // indirect
github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect
github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zclconf/go-cty v1.16.3 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gonum.org/v1/gonum v0.16.0 // indirect
google.golang.org/api v0.256.0 // indirect
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/gorm v1.31.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
)
+1556
View File
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
// Package client implements the bidirectional WebSocket client for communicating
// with the hold service, plus HTTP helpers for downloading blobs.
package client
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"sync"
"time"
scanner "atcr.io/scanner"
"atcr.io/scanner/internal/queue"
"github.com/gorilla/websocket"
)
// HoldClient manages the WebSocket connection to a hold service
type HoldClient struct {
holdURL string
secret string
queue *queue.JobQueue
conn *websocket.Conn
mu sync.Mutex // protects conn writes
done chan struct{}
}
// NewHoldClient creates a new hold client
func NewHoldClient(holdURL, secret string, q *queue.JobQueue) *HoldClient {
return &HoldClient{
holdURL: holdURL,
secret: secret,
queue: q,
done: make(chan struct{}),
}
}
// Connect establishes the WebSocket connection with auto-reconnect
func (c *HoldClient) Connect() {
var cursor int64 = -1
for {
select {
case <-c.done:
return
default:
}
err := c.connectOnce(cursor)
if err != nil {
slog.Error("WebSocket connection failed, reconnecting",
"error", err)
}
// Exponential backoff with max 30s
select {
case <-c.done:
return
case <-time.After(5 * time.Second):
}
}
}
func (c *HoldClient) connectOnce(cursor int64) error {
// Build WebSocket URL
u, err := url.Parse(c.holdURL)
if err != nil {
return fmt.Errorf("invalid hold URL: %w", err)
}
// Convert http(s) to ws(s)
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
case "ws", "wss":
// Already correct
}
u.Path = "/xrpc/io.atcr.hold.subscribeScanJobs"
q := u.Query()
q.Set("secret", c.secret)
if cursor >= 0 {
q.Set("cursor", fmt.Sprintf("%d", cursor))
}
u.RawQuery = q.Encode()
slog.Info("Connecting to hold service", "url", u.Host)
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
slog.Info("Connected to hold service")
// Read messages from hold
for {
_, data, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
slog.Error("WebSocket read error", "error", err)
}
return err
}
var raw scanner.ScanJobRaw
if err := json.Unmarshal(data, &raw); err != nil {
slog.Error("Failed to unmarshal message", "error", err)
continue
}
if raw.Type != "job" {
slog.Warn("Unknown message type from hold", "type", raw.Type)
continue
}
// Parse config and layers from raw JSON
var config scanner.BlobDescriptor
if err := json.Unmarshal(raw.Config, &config); err != nil {
slog.Error("Failed to unmarshal config", "seq", raw.Seq, "error", err)
continue
}
var layers []scanner.BlobDescriptor
if err := json.Unmarshal(raw.Layers, &layers); err != nil {
slog.Error("Failed to unmarshal layers", "seq", raw.Seq, "error", err)
continue
}
job := &scanner.ScanJob{
Seq: raw.Seq,
ManifestDigest: raw.ManifestDigest,
Repository: raw.Repository,
Tag: raw.Tag,
UserDID: raw.UserDID,
UserHandle: raw.UserHandle,
HoldDID: raw.HoldDID,
HoldEndpoint: raw.HoldEndpoint,
Tier: raw.Tier,
Config: config,
Layers: layers,
}
// Send ack immediately
c.SendAck(job.Seq)
// Enqueue into priority queue
if !c.queue.Enqueue(job) {
slog.Warn("Queue full, sending error",
"seq", job.Seq,
"repository", job.Repository)
c.SendError(job.Seq, "scanner queue full")
}
}
}
// SendAck sends an acknowledgement for a received job
func (c *HoldClient) SendAck(seq int64) {
c.sendJSON(scanner.AckMessage{Type: "ack", Seq: seq})
}
// SendResult sends scan results back to the hold
func (c *HoldClient) SendResult(seq int64, result *scanner.ScanResult) {
msg := scanner.ResultMessage{
Type: "result",
Seq: seq,
Summary: result.Summary,
}
if result.SBOM != nil {
msg.SBOM = string(result.SBOM)
}
if result.VulnReport != nil {
msg.VulnReport = string(result.VulnReport)
}
c.sendJSON(msg)
}
// SendError sends an error message for a failed scan
func (c *HoldClient) SendError(seq int64, errMsg string) {
c.sendJSON(scanner.ErrorMessage{Type: "error", Seq: seq, Error: errMsg})
}
func (c *HoldClient) sendJSON(v any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
slog.Warn("Cannot send, no connection")
return
}
if err := c.conn.WriteJSON(v); err != nil {
slog.Error("Failed to send WebSocket message", "error", err)
}
}
// Close shuts down the client
func (c *HoldClient) Close() {
close(c.done)
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
}
c.mu.Unlock()
}
// GetBlobPresignedURL gets a presigned download URL from the hold service
func GetBlobPresignedURL(holdEndpoint, holdDID, digest string) (string, error) {
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s&method=GET",
holdEndpoint,
url.QueryEscape(holdDID),
url.QueryEscape(digest))
resp, err := http.Get(reqURL)
if err != nil {
return "", fmt.Errorf("failed to get presigned URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("hold returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
return result.URL, nil
}
// DownloadBlob downloads a blob from a presigned URL to a local file
func DownloadBlob(presignedURL, destPath string) error {
resp, err := http.Get(presignedURL)
if err != nil {
return fmt.Errorf("failed to download blob: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write blob: %w", err)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
// Package config provides environment-based configuration for the scanner service.
package config
import (
"fmt"
"os"
"strconv"
)
// Config holds all scanner configuration
type Config struct {
// Addr is the HTTP address for the health endpoint
Addr string
// HoldURL is the WebSocket URL of the hold service
HoldURL string
// SharedSecret is the shared secret for scanner authentication
SharedSecret string
// Workers is the number of concurrent scan workers
Workers int
// QueueSize is the maximum priority queue depth
QueueSize int
// VulnEnabled enables Grype vulnerability scanning
VulnEnabled bool
// VulnDBPath is the directory for the Grype vulnerability database
VulnDBPath string
// TmpDir is the directory for temporary layer extraction
TmpDir string
}
// Load reads configuration from environment variables with SCANNER_ prefix
func Load() (*Config, error) {
cfg := &Config{
Addr: envOr("SCANNER_ADDR", ":9090"),
HoldURL: os.Getenv("SCANNER_HOLD_URL"),
SharedSecret: os.Getenv("SCANNER_SHARED_SECRET"),
Workers: envIntOr("SCANNER_WORKERS", 2),
QueueSize: envIntOr("SCANNER_QUEUE_SIZE", 100),
VulnEnabled: envBoolOr("SCANNER_VULN_ENABLED", true),
VulnDBPath: envOr("SCANNER_VULN_DB_PATH", "/var/lib/atcr-scanner/vulndb"),
TmpDir: envOr("SCANNER_TMP_DIR", "/var/lib/atcr-scanner/tmp"),
}
if cfg.HoldURL == "" {
return nil, fmt.Errorf("SCANNER_HOLD_URL is required")
}
if cfg.SharedSecret == "" {
return nil, fmt.Errorf("SCANNER_SHARED_SECRET is required")
}
return cfg, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envIntOr(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func envBoolOr(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
+144
View File
@@ -0,0 +1,144 @@
// Package queue provides a priority queue for scan jobs.
// Jobs are ordered by tier priority (paid first) with FIFO tiebreaker.
package queue
import (
"container/heap"
"sync"
scanner "atcr.io/scanner"
)
// Priority levels: lower number = higher priority
const (
PriorityOwner = 0
PriorityQuartermaster = 1
PriorityBosun = 2
PriorityDeckhand = 3
)
// TierToPriority converts a tier name to a priority level
func TierToPriority(tier string) int {
switch tier {
case "owner":
return PriorityOwner
case "quartermaster":
return PriorityQuartermaster
case "bosun":
return PriorityBosun
default:
return PriorityDeckhand
}
}
// item wraps a ScanJob with priority metadata for the heap
type item struct {
job *scanner.ScanJob
priority int
seq int64 // FIFO tiebreaker
}
// priorityHeap implements heap.Interface
type priorityHeap []*item
func (h priorityHeap) Len() int { return len(h) }
func (h priorityHeap) Less(i, j int) bool {
if h[i].priority != h[j].priority {
return h[i].priority < h[j].priority
}
return h[i].seq < h[j].seq
}
func (h priorityHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *priorityHeap) Push(x any) {
*h = append(*h, x.(*item))
}
func (h *priorityHeap) Pop() any {
old := *h
n := len(old)
it := old[n-1]
old[n-1] = nil
*h = old[:n-1]
return it
}
// JobQueue is a thread-safe priority queue for scan jobs.
// Workers block on Dequeue until a job is available.
type JobQueue struct {
mu sync.Mutex
cond *sync.Cond
h priorityHeap
maxSize int
closed bool
}
// NewJobQueue creates a new priority queue with the given max size
func NewJobQueue(maxSize int) *JobQueue {
q := &JobQueue{
h: make(priorityHeap, 0),
maxSize: maxSize,
}
q.cond = sync.NewCond(&q.mu)
heap.Init(&q.h)
return q
}
// Enqueue adds a job to the priority queue.
// Returns false if the queue is full.
func (q *JobQueue) Enqueue(job *scanner.ScanJob) bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.closed {
return false
}
if q.h.Len() >= q.maxSize {
return false
}
heap.Push(&q.h, &item{
job: job,
priority: TierToPriority(job.Tier),
seq: job.Seq,
})
q.cond.Signal()
return true
}
// Dequeue blocks until a job is available and returns it.
// Returns nil if the queue is closed.
func (q *JobQueue) Dequeue() *scanner.ScanJob {
q.mu.Lock()
defer q.mu.Unlock()
for q.h.Len() == 0 && !q.closed {
q.cond.Wait()
}
if q.closed && q.h.Len() == 0 {
return nil
}
it := heap.Pop(&q.h).(*item)
return it.job
}
// Len returns the number of jobs in the queue
func (q *JobQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return q.h.Len()
}
// Close signals all waiting goroutines to stop
func (q *JobQueue) Close() {
q.mu.Lock()
defer q.mu.Unlock()
q.closed = true
q.cond.Broadcast()
}
@@ -0,0 +1,94 @@
package queue
import (
"testing"
scanner "atcr.io/scanner"
)
func TestTierToPriority(t *testing.T) {
tests := []struct {
tier string
expected int
}{
{"owner", PriorityOwner},
{"quartermaster", PriorityQuartermaster},
{"bosun", PriorityBosun},
{"deckhand", PriorityDeckhand},
{"unknown", PriorityDeckhand},
{"", PriorityDeckhand},
}
for _, tt := range tests {
if got := TierToPriority(tt.tier); got != tt.expected {
t.Errorf("TierToPriority(%q) = %d, want %d", tt.tier, got, tt.expected)
}
}
}
func TestJobQueue_PriorityOrder(t *testing.T) {
q := NewJobQueue(10)
// Enqueue jobs in reverse priority order
q.Enqueue(&scanner.ScanJob{Seq: 1, Tier: "deckhand", Repository: "free-1"})
q.Enqueue(&scanner.ScanJob{Seq: 2, Tier: "owner", Repository: "owner-1"})
q.Enqueue(&scanner.ScanJob{Seq: 3, Tier: "bosun", Repository: "bosun-1"})
q.Enqueue(&scanner.ScanJob{Seq: 4, Tier: "quartermaster", Repository: "qm-1"})
// Should dequeue in priority order: owner, quartermaster, bosun, deckhand
expected := []string{"owner-1", "qm-1", "bosun-1", "free-1"}
for _, exp := range expected {
job := q.Dequeue()
if job.Repository != exp {
t.Errorf("expected %q, got %q", exp, job.Repository)
}
}
}
func TestJobQueue_FIFOWithinSamePriority(t *testing.T) {
q := NewJobQueue(10)
q.Enqueue(&scanner.ScanJob{Seq: 10, Tier: "deckhand", Repository: "first"})
q.Enqueue(&scanner.ScanJob{Seq: 20, Tier: "deckhand", Repository: "second"})
q.Enqueue(&scanner.ScanJob{Seq: 30, Tier: "deckhand", Repository: "third"})
// Should dequeue in FIFO order within same priority
expected := []string{"first", "second", "third"}
for _, exp := range expected {
job := q.Dequeue()
if job.Repository != exp {
t.Errorf("expected %q, got %q", exp, job.Repository)
}
}
}
func TestJobQueue_Full(t *testing.T) {
q := NewJobQueue(2)
ok1 := q.Enqueue(&scanner.ScanJob{Seq: 1, Tier: "deckhand"})
ok2 := q.Enqueue(&scanner.ScanJob{Seq: 2, Tier: "deckhand"})
ok3 := q.Enqueue(&scanner.ScanJob{Seq: 3, Tier: "deckhand"})
if !ok1 || !ok2 {
t.Error("first two enqueues should succeed")
}
if ok3 {
t.Error("third enqueue should fail (queue full)")
}
}
func TestJobQueue_CloseUnblocks(t *testing.T) {
q := NewJobQueue(10)
done := make(chan struct{})
go func() {
job := q.Dequeue()
if job != nil {
t.Error("expected nil from closed queue")
}
close(done)
}()
q.Close()
<-done
}
+180
View File
@@ -0,0 +1,180 @@
package scan
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
scanner "atcr.io/scanner"
"atcr.io/scanner/internal/client"
)
// extractLayers downloads and extracts all image layers via presigned URLs
// Returns the rootfs directory path and a cleanup function
func extractLayers(job *scanner.ScanJob, tmpDir string) (string, func(), error) {
scanDir, err := os.MkdirTemp(tmpDir, "scan-*")
if err != nil {
return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
}
cleanup := func() {
if err := os.RemoveAll(scanDir); err != nil {
slog.Warn("Failed to clean up temp directory", "dir", scanDir, "error", err)
}
}
imageDir := filepath.Join(scanDir, "image")
rootfsDir := filepath.Join(imageDir, "rootfs")
layersDir := filepath.Join(imageDir, "layers")
for _, dir := range []string{rootfsDir, layersDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
// Download and validate config blob
slog.Info("Downloading config blob", "digest", job.Config.Digest)
configPath := filepath.Join(imageDir, "config.json")
if err := downloadBlobViaPresignedURL(job.HoldEndpoint, job.HoldDID, job.Config.Digest, configPath); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to download config blob: %w", err)
}
configData, err := os.ReadFile(configPath)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to read config: %w", err)
}
var configObj map[string]interface{}
if err := json.Unmarshal(configData, &configObj); err != nil {
cleanup()
return "", nil, fmt.Errorf("invalid config JSON: %w", err)
}
// Download and extract each layer
for i, layer := range job.Layers {
slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size)
layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d.tar.gz", i))
if err := downloadBlobViaPresignedURL(job.HoldEndpoint, job.HoldDID, layer.Digest, layerPath); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to download layer %d: %w", i, err)
}
if err := extractTarGz(layerPath, rootfsDir); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to extract layer %d: %w", i, err)
}
// Remove layer tar.gz to save space
os.Remove(layerPath)
}
entries, err := os.ReadDir(rootfsDir)
if err != nil {
slog.Warn("Failed to read rootfs directory", "error", err)
} else {
slog.Info("Successfully extracted image",
"layers", len(job.Layers),
"topLevelEntries", len(entries))
}
return rootfsDir, cleanup, nil
}
// downloadBlobViaPresignedURL gets a presigned URL from the hold and downloads the blob
func downloadBlobViaPresignedURL(holdEndpoint, holdDID, digest, destPath string) error {
presignedURL, err := client.GetBlobPresignedURL(holdEndpoint, holdDID, digest)
if err != nil {
return fmt.Errorf("failed to get presigned URL for %s: %w", digest, err)
}
return client.DownloadBlob(presignedURL, destPath)
}
// extractTarGz extracts a tar.gz file to a destination directory (overlayfs style)
func extractTarGz(tarGzPath, destDir string) error {
file, err := os.Open(tarGzPath)
if err != nil {
return fmt.Errorf("failed to open tar.gz: %w", err)
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar header: %w", err)
}
target := filepath.Join(destDir, filepath.Clean(header.Name))
// Security: ensure target is within destDir
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
slog.Warn("Skipping path outside destination", "path", header.Name)
continue
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
return fmt.Errorf("failed to create directory %s: %w", target, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return fmt.Errorf("failed to create file %s: %w", target, err)
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return fmt.Errorf("failed to write file %s: %w", target, err)
}
outFile.Close()
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for symlink: %w", err)
}
os.Remove(target)
if err := os.Symlink(header.Linkname, target); err != nil {
slog.Warn("Failed to create symlink", "target", target, "link", header.Linkname, "error", err)
}
case tar.TypeLink:
linkTarget := filepath.Join(destDir, filepath.Clean(header.Linkname))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for hardlink: %w", err)
}
os.Remove(target)
if err := os.Link(linkTarget, target); err != nil {
slog.Warn("Failed to create hardlink", "target", target, "link", linkTarget, "error", err)
}
default:
slog.Debug("Skipping unsupported tar entry type", "type", header.Typeflag, "name", header.Name)
}
}
return nil
}
+248
View File
@@ -0,0 +1,248 @@
package scan
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
scanner "atcr.io/scanner"
"github.com/anchore/grype/grype"
"github.com/anchore/grype/grype/db/v6/distribution"
"github.com/anchore/grype/grype/db/v6/installation"
"github.com/anchore/grype/grype/distro"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/matcher"
"github.com/anchore/grype/grype/matcher/dotnet"
"github.com/anchore/grype/grype/matcher/golang"
"github.com/anchore/grype/grype/matcher/java"
"github.com/anchore/grype/grype/matcher/javascript"
"github.com/anchore/grype/grype/matcher/python"
"github.com/anchore/grype/grype/matcher/ruby"
"github.com/anchore/grype/grype/matcher/stock"
grypePkg "github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/syft/syft/sbom"
)
// Global vulnerability database (shared across workers)
var (
vulnDB vulnerability.Provider
vulnDBLock sync.RWMutex
)
// scanVulnerabilities scans an SBOM for vulnerabilities using Grype
func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) ([]byte, string, scanner.VulnerabilitySummary, error) {
slog.Info("Scanning for vulnerabilities with Grype")
store, err := loadVulnDatabase(ctx, vulnDBPath)
if err != nil {
return nil, "", scanner.VulnerabilitySummary{}, fmt.Errorf("failed to load vulnerability database: %w", err)
}
var grypeDistro *distro.Distro
if s.Artifacts.LinuxDistribution != nil {
grypeDistro = distro.FromRelease(s.Artifacts.LinuxDistribution, nil)
}
synthesisConfig := grypePkg.SynthesisConfig{
GenerateMissingCPEs: true,
Distro: grypePkg.DistroConfig{
Override: grypeDistro,
},
}
grypePackages := grypePkg.FromCollection(s.Artifacts.Packages, synthesisConfig)
slog.Info("Converted packages for vulnerability scanning",
"syftPackages", s.Artifacts.Packages.PackageCount(),
"grypePackages", len(grypePackages))
matchers := matcher.NewDefaultMatchers(matcher.Config{
Java: java.MatcherConfig{},
Ruby: ruby.MatcherConfig{},
Python: python.MatcherConfig{},
Dotnet: dotnet.MatcherConfig{},
Javascript: javascript.MatcherConfig{},
Golang: golang.MatcherConfig{},
Stock: stock.MatcherConfig{},
})
pkgContext := grypePkg.Context{
Source: &s.Source,
Distro: grypeDistro,
}
vulnerabilityMatcher := &grype.VulnerabilityMatcher{
VulnerabilityProvider: store,
Matchers: matchers,
NormalizeByCVE: true,
}
allMatches, _, err := vulnerabilityMatcher.FindMatches(grypePackages, pkgContext)
if err != nil {
return nil, "", scanner.VulnerabilitySummary{}, fmt.Errorf("failed to find vulnerabilities: %w", err)
}
summary := countVulnerabilitiesBySeverity(*allMatches)
slog.Info("Vulnerability scan complete",
"critical", summary.Critical,
"high", summary.High,
"medium", summary.Medium,
"low", summary.Low,
"total", summary.Total)
report := map[string]interface{}{
"matches": allMatches.Sorted(),
"source": s.Source,
"distro": s.Artifacts.LinuxDistribution,
"descriptor": map[string]interface{}{
"name": "grype",
"version": "v0.107.1",
},
"summary": summary,
}
reportJSON, err := json.MarshalIndent(report, "", " ")
if err != nil {
return nil, "", scanner.VulnerabilitySummary{}, fmt.Errorf("failed to encode vulnerability report: %w", err)
}
hash := sha256.Sum256(reportJSON)
digest := fmt.Sprintf("sha256:%x", hash)
return reportJSON, digest, summary, nil
}
// loadVulnDatabase loads the Grype vulnerability database (with caching)
func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Provider, error) {
vulnDBLock.RLock()
if vulnDB != nil {
vulnDBLock.RUnlock()
return vulnDB, nil
}
vulnDBLock.RUnlock()
vulnDBLock.Lock()
defer vulnDBLock.Unlock()
if vulnDB != nil {
return vulnDB, nil
}
slog.Info("Loading Grype vulnerability database", "path", vulnDBPath)
if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err)
}
distConfig := distribution.DefaultConfig()
installConfig := installation.Config{
DBRootDir: vulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: 5 * 24 * time.Hour, // 5 days
}
store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false)
if err != nil {
return nil, fmt.Errorf("failed to load vulnerability database (status=%v): %w", status, err)
}
slog.Info("Vulnerability database loaded",
"built", status.Built,
"schemaVersion", status.SchemaVersion)
vulnDB = store
return vulnDB, nil
}
// initializeVulnDatabase downloads the vulnerability database on startup
func initializeVulnDatabase(vulnDBPath, tmpDir string) error {
slog.Info("Initializing vulnerability database", "path", vulnDBPath)
if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
return fmt.Errorf("failed to create database directory: %w", err)
}
grpeTmpDir := filepath.Join(tmpDir, "grype-dl")
if err := os.MkdirAll(grpeTmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
oldTmpDir := os.Getenv("TMPDIR")
os.Setenv("TMPDIR", grpeTmpDir)
defer func() {
if oldTmpDir != "" {
os.Setenv("TMPDIR", oldTmpDir)
} else {
os.Unsetenv("TMPDIR")
}
}()
distConfig := distribution.DefaultConfig()
installConfig := installation.Config{
DBRootDir: vulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: 5 * 24 * time.Hour,
}
downloader, err := distribution.NewClient(distConfig)
if err != nil {
return fmt.Errorf("failed to create database downloader: %w", err)
}
curator, err := installation.NewCurator(installConfig, downloader)
if err != nil {
return fmt.Errorf("failed to create database curator: %w", err)
}
status := curator.Status()
if !status.Built.IsZero() && status.Error == nil {
slog.Info("Vulnerability database already exists", "built", status.Built)
return nil
}
slog.Info("Downloading vulnerability database (this may take 5-10 minutes)...")
updated, err := curator.Update()
if err != nil {
return fmt.Errorf("failed to download vulnerability database: %w", err)
}
if updated {
slog.Info("Vulnerability database downloaded successfully")
} else {
slog.Info("Vulnerability database is up to date")
}
return nil
}
func countVulnerabilitiesBySeverity(matches match.Matches) scanner.VulnerabilitySummary {
summary := scanner.VulnerabilitySummary{}
for m := range matches.Enumerate() {
summary.Total++
if m.Vulnerability.Metadata != nil {
switch m.Vulnerability.Metadata.Severity {
case "Critical":
summary.Critical++
case "High":
summary.High++
case "Medium":
summary.Medium++
case "Low":
summary.Low++
}
}
}
return summary
}
+69
View File
@@ -0,0 +1,69 @@
package scan
import (
"context"
"crypto/sha256"
"fmt"
"log/slog"
"os"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source/directorysource"
)
// generateSBOM generates an SBOM using Syft from an extracted image directory
// Returns the SBOM object, SBOM JSON bytes, and its digest
func generateSBOM(ctx context.Context, imageDir string) (*sbom.SBOM, []byte, string, error) {
slog.Info("Generating SBOM with Syft", "imageDir", imageDir)
entries, err := os.ReadDir(imageDir)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to read image directory: %w", err)
}
slog.Info("Image directory contents", "path", imageDir, "entries", len(entries))
src, err := directorysource.NewFromPath(imageDir)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create Syft source: %w", err)
}
defer src.Close()
slog.Info("Running Syft cataloging")
sbomResult, err := syft.CreateSBOM(ctx, src, nil)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate SBOM: %w", err)
}
if sbomResult == nil {
return nil, nil, "", fmt.Errorf("Syft returned nil SBOM")
}
slog.Info("SBOM generated",
"packages", sbomResult.Artifacts.Packages.PackageCount(),
"distro", func() string {
if sbomResult.Artifacts.LinuxDistribution != nil {
return fmt.Sprintf("%s %s", sbomResult.Artifacts.LinuxDistribution.Name, sbomResult.Artifacts.LinuxDistribution.Version)
}
return "none"
}())
encoder, err := spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig())
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create SPDX encoder: %w", err)
}
sbomJSON, err := format.Encode(*sbomResult, encoder)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to encode SBOM to SPDX JSON: %w", err)
}
hash := sha256.Sum256(sbomJSON)
digest := fmt.Sprintf("sha256:%x", hash)
slog.Info("SBOM encoded", "format", "spdx-json", "size", len(sbomJSON), "digest", digest)
return sbomResult, sbomJSON, digest, nil
}
+150
View File
@@ -0,0 +1,150 @@
// Package scan implements the vulnerability scanning pipeline:
// extract layers → generate SBOM → scan vulnerabilities → send result.
package scan
import (
"context"
"fmt"
"log/slog"
"os"
"sync"
"time"
scanner "atcr.io/scanner"
"atcr.io/scanner/internal/client"
"atcr.io/scanner/internal/config"
"atcr.io/scanner/internal/queue"
)
// WorkerPool manages a pool of scan workers
type WorkerPool struct {
cfg *config.Config
queue *queue.JobQueue
client *client.HoldClient
wg sync.WaitGroup
}
// NewWorkerPool creates a new worker pool
func NewWorkerPool(cfg *config.Config, q *queue.JobQueue, c *client.HoldClient) *WorkerPool {
return &WorkerPool{
cfg: cfg,
queue: q,
client: c,
}
}
// Start launches worker goroutines
func (wp *WorkerPool) Start(ctx context.Context) {
// Initialize vuln database on startup if enabled
if wp.cfg.VulnEnabled {
go func() {
if err := initializeVulnDatabase(wp.cfg.VulnDBPath, wp.cfg.TmpDir); err != nil {
slog.Error("Failed to initialize vulnerability database", "error", err)
slog.Warn("Vulnerability scanning will be disabled until database is available")
}
}()
}
for i := 0; i < wp.cfg.Workers; i++ {
wp.wg.Add(1)
go wp.worker(ctx, i)
}
slog.Info("Scanner worker pool started", "workers", wp.cfg.Workers)
}
// Wait blocks until all workers finish
func (wp *WorkerPool) Wait() {
wp.wg.Wait()
}
func (wp *WorkerPool) worker(ctx context.Context, id int) {
defer wp.wg.Done()
slog.Info("Scanner worker started", "worker_id", id)
for {
job := wp.queue.Dequeue()
if job == nil {
slog.Info("Scanner worker shutting down", "worker_id", id)
return
}
slog.Info("Processing scan job",
"worker_id", id,
"repository", job.Repository,
"tag", job.Tag,
"digest", job.ManifestDigest,
"tier", job.Tier)
result, err := wp.processJob(ctx, job)
if err != nil {
slog.Error("Scan job failed",
"worker_id", id,
"repository", job.Repository,
"error", err)
wp.client.SendError(job.Seq, err.Error())
continue
}
wp.client.SendResult(job.Seq, result)
slog.Info("Scan job completed",
"worker_id", id,
"repository", job.Repository,
"vulnerabilities", result.Summary.Total)
}
}
func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) {
startTime := time.Now()
// Ensure tmp dir exists
if err := ensureDir(wp.cfg.TmpDir); err != nil {
return nil, fmt.Errorf("failed to create tmp dir: %w", err)
}
// Step 1: Extract image layers from hold via presigned URLs
slog.Info("Extracting image layers", "repository", job.Repository)
imageDir, cleanup, err := extractLayers(job, wp.cfg.TmpDir)
if err != nil {
return nil, fmt.Errorf("failed to extract layers: %w", err)
}
defer cleanup()
// Step 2: Generate SBOM with Syft
slog.Info("Generating SBOM", "repository", job.Repository)
sbomResult, sbomJSON, sbomDigest, err := generateSBOM(ctx, imageDir)
if err != nil {
return nil, fmt.Errorf("failed to generate SBOM: %w", err)
}
result := &scanner.ScanResult{
ManifestDigest: job.ManifestDigest,
SBOM: sbomJSON,
SBOMDigest: sbomDigest,
}
// Step 3: Scan SBOM with Grype (if enabled)
if wp.cfg.VulnEnabled {
slog.Info("Scanning for vulnerabilities", "repository", job.Repository)
vulnJSON, vulnDigest, summary, err := scanVulnerabilities(ctx, sbomResult, wp.cfg.VulnDBPath)
if err != nil {
return nil, fmt.Errorf("failed to scan vulnerabilities: %w", err)
}
result.VulnReport = vulnJSON
result.VulnDigest = vulnDigest
result.Summary = &summary
}
duration := time.Since(startTime)
slog.Info("Scan pipeline completed",
"repository", job.Repository,
"duration", duration)
return result, nil
}
func ensureDir(path string) error {
return os.MkdirAll(path, 0755)
}
+84
View File
@@ -0,0 +1,84 @@
// Package scanner defines shared types for the ATCR scanner service.
// These types are self-contained with no imports from the root module.
package scanner
import "encoding/json"
// ScanJob represents a vulnerability scanning job received from the hold service
type ScanJob struct {
Seq int64 `json:"seq"`
ManifestDigest string `json:"manifestDigest"`
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Tier string `json:"tier"`
Config BlobDescriptor `json:"config"`
Layers []BlobDescriptor `json:"layers"`
}
// ScanJobRaw is the raw WebSocket message with JSON config/layers
type ScanJobRaw struct {
Type string `json:"type"` // "job"
Seq int64 `json:"seq"`
ManifestDigest string `json:"manifestDigest"`
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle"`
HoldDID string `json:"holdDid"`
HoldEndpoint string `json:"holdEndpoint"`
Tier string `json:"tier"`
Config json.RawMessage `json:"config"`
Layers json.RawMessage `json:"layers"`
}
// BlobDescriptor describes a blob (layer or config) in a container image
type BlobDescriptor struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"mediaType"`
}
// ScanResult contains the output of a completed scan
type ScanResult struct {
ManifestDigest string `json:"manifestDigest"`
SBOM []byte `json:"sbom,omitempty"`
SBOMDigest string `json:"sbomDigest,omitempty"`
VulnReport []byte `json:"vulnReport,omitempty"`
VulnDigest string `json:"vulnDigest,omitempty"`
Summary *VulnerabilitySummary `json:"summary,omitempty"`
}
// VulnerabilitySummary contains counts of vulnerabilities by severity
type VulnerabilitySummary struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Total int `json:"total"`
}
// AckMessage is sent from scanner to hold to acknowledge job receipt
type AckMessage struct {
Type string `json:"type"` // "ack"
Seq int64 `json:"seq"`
}
// ResultMessage is sent from scanner to hold with scan results
type ResultMessage struct {
Type string `json:"type"` // "result"
Seq int64 `json:"seq"`
SBOM string `json:"sbom,omitempty"`
VulnReport string `json:"vulnReport,omitempty"`
Summary *VulnerabilitySummary `json:"summary,omitempty"`
}
// ErrorMessage is sent from scanner to hold when a scan fails
type ErrorMessage struct {
Type string `json:"type"` // "error"
Seq int64 `json:"seq"`
Error string `json:"error"`
}