-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
401 lines (358 loc) · 12.3 KB
/
Copy pathmain.go
File metadata and controls
401 lines (358 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
// bomhort-bridge is a PoC webhook bridge between Harbor and BOMHort.
//
// Flow: Harbor fires a SCANNING_COMPLETED webhook after an SBOM scan ->
// the bridge reads sbom_repository/sbom_digest from the payload, pulls the
// SBOM accessory blob from Harbor's registry API, and drops the SBOM JSON
// into the directory BOMHort's ingestion-watcher polls (SBOM_DIR).
//
// Files are named <project>__<repo>@<artifact-digest>.spdx.json so a re-scan
// of the same artifact digest overwrites instead of duplicating, and written
// with a "_" prefix first (BOMHort's watcher ignore-prefix) then renamed, so
// the watcher never sees a half-written file. Before writing, the SBOM is
// normalized: the per-run scan metadata (creationInfo.created,
// documentNamespace, annotationDate) is pinned to fixed values, so re-scans
// of an unchanged artifact produce identical bytes and downstream
// content-hash dedup works without tracking any previous version.
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const scanningCompleted = "SCANNING_COMPLETED"
// maxSBOMSize caps blob downloads; SPDX SBOMs for large images run a few MB.
const maxSBOMSize = 100 << 20
type config struct {
listenAddr string
harborURL string // e.g. http://localhost:8090
username string // robot account or admin
password string
sbomDir string // BOMHort's watched directory
webhookAuth string // optional: must match the webhook's Authorization header
insecure bool // skip TLS verify for self-signed Harbor
}
func loadConfig() (*config, error) {
cfg := &config{
listenAddr: envOr("LISTEN_ADDR", ":9099"),
harborURL: strings.TrimRight(os.Getenv("HARBOR_URL"), "/"),
username: os.Getenv("HARBOR_USERNAME"),
password: os.Getenv("HARBOR_PASSWORD"),
sbomDir: envOr("SBOM_DIR", "./sboms"),
webhookAuth: os.Getenv("WEBHOOK_AUTH"),
insecure: os.Getenv("INSECURE_SKIP_VERIFY") == "true",
}
if cfg.harborURL == "" {
return nil, fmt.Errorf("HARBOR_URL is required")
}
if cfg.username == "" || cfg.password == "" {
return nil, fmt.Errorf("HARBOR_USERNAME and HARBOR_PASSWORD are required")
}
return cfg, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// --- Harbor webhook payload (subset we need) ---
type webhookPayload struct {
Type string `json:"type"`
EventData struct {
Resources []struct {
Digest string `json:"digest"`
Tag string `json:"tag"`
SBOMOverview map[string]any `json:"sbom_overview"`
} `json:"resources"`
Repository struct {
RepoFullName string `json:"repo_full_name"`
} `json:"repository"`
} `json:"event_data"`
}
// --- OCI manifest (subset we need) ---
type ociManifest struct {
Layers []struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
} `json:"layers"`
}
type bridge struct {
cfg *config
client *http.Client
}
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatalf("config: %v", err)
}
if err := os.MkdirAll(cfg.sbomDir, 0o755); err != nil {
log.Fatalf("creating SBOM_DIR %s: %v", cfg.sbomDir, err)
}
b := &bridge{cfg: cfg, client: newHTTPClient(cfg.insecure)}
mux := http.NewServeMux()
mux.HandleFunc("POST /webhook", b.handleWebhook)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Printf("bomhort-bridge listening on %s, dropping SBOMs into %s", cfg.listenAddr, cfg.sbomDir)
log.Fatal(http.ListenAndServe(cfg.listenAddr, mux))
}
func newHTTPClient(insecure bool) *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
if insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
return &http.Client{Timeout: 60 * time.Second, Transport: tr}
}
func (b *bridge) handleWebhook(w http.ResponseWriter, r *http.Request) {
if b.cfg.webhookAuth != "" && r.Header.Get("Authorization") != b.cfg.webhookAuth {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var payload webhookPayload
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
if payload.Type != scanningCompleted {
log.Printf("ignoring event type %q", payload.Type)
w.WriteHeader(http.StatusOK)
return
}
repoFullName := payload.EventData.Repository.RepoFullName
processed := 0
for _, res := range payload.EventData.Resources {
sbomRepo, _ := res.SBOMOverview["sbom_repository"].(string)
sbomDigest, _ := res.SBOMOverview["sbom_digest"].(string)
if sbomRepo == "" || sbomDigest == "" {
// Vulnerability-scan completion events have no sbom_overview.
log.Printf("resource %s@%s: no sbom_overview, skipping", repoFullName, res.Digest)
continue
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
err := b.fetchAndDrop(ctx, sbomRepo, sbomDigest, repoFullName, res.Digest)
cancel()
if err != nil {
log.Printf("ERROR: %s@%s: %v", repoFullName, res.Digest, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
processed++
}
log.Printf("event %s: handled %d SBOM resource(s)", payload.Type, processed)
w.WriteHeader(http.StatusOK)
}
// fetchAndDrop pulls the SBOM accessory (an OCI manifest whose single layer
// blob is the raw SBOM JSON) and writes it atomically into SBOM_DIR.
func (b *bridge) fetchAndDrop(ctx context.Context, sbomRepo, sbomDigest, repoFullName, artifactDigest string) error {
manifestURL := fmt.Sprintf("%s/v2/%s/manifests/%s", b.cfg.harborURL, sbomRepo, sbomDigest)
manifestBytes, err := b.registryGet(ctx, manifestURL, sbomRepo, "application/vnd.oci.image.manifest.v1+json")
if err != nil {
return fmt.Errorf("fetching accessory manifest: %w", err)
}
var manifest ociManifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
return fmt.Errorf("parsing accessory manifest: %w", err)
}
if len(manifest.Layers) == 0 {
return fmt.Errorf("accessory manifest %s has no layers", sbomDigest)
}
blobURL := fmt.Sprintf("%s/v2/%s/blobs/%s", b.cfg.harborURL, sbomRepo, manifest.Layers[0].Digest)
sbomBytes, err := b.registryGet(ctx, blobURL, sbomRepo, "")
if err != nil {
return fmt.Errorf("fetching SBOM blob: %w", err)
}
normalized, err := normalizeSBOM(sbomBytes, artifactDigest)
if err != nil {
return fmt.Errorf("normalizing SBOM: %w", err)
}
name := sbomFileName(repoFullName, artifactDigest)
if existing, err := os.ReadFile(filepath.Join(b.cfg.sbomDir, name)); err == nil && bytes.Equal(existing, normalized) {
log.Printf("skipped %s (no change after normalization)", name)
return nil
}
if err := writeAtomic(b.cfg.sbomDir, name, normalized); err != nil {
return fmt.Errorf("writing %s: %w", name, err)
}
log.Printf("dropped %s (%d bytes)", name, len(normalized))
return nil
}
// scannerRunEpoch replaces the per-run timestamps the scanner stamps into
// every SBOM. SPDX requires these fields to exist, so they are pinned to a
// fixed value rather than removed.
const scannerRunEpoch = "1970-01-01T00:00:00Z"
// normalizeSBOM pins the fields the scanner regenerates on every run even
// when the artifact is unchanged: creationInfo.created, the random
// documentNamespace UUID, and the annotationDate on every annotation. With
// them pinned, scanning the same artifact digest always produces identical
// bytes, so downstream content-hash dedup (BOMHort's watcher) works without
// the bridge having to remember any previous version. Keys are re-marshalled
// in sorted order, which makes the output deterministic.
func normalizeSBOM(data []byte, artifactDigest string) ([]byte, error) {
var doc map[string]any
if err := json.Unmarshal(data, &doc); err != nil {
return nil, err
}
if _, ok := doc["documentNamespace"]; ok {
doc["documentNamespace"] = "urn:harbor:sbom:" + artifactDigest
}
if ci, ok := doc["creationInfo"].(map[string]any); ok {
ci["created"] = scannerRunEpoch
}
pinAnnotationDates(doc)
return json.Marshal(doc)
}
// pinAnnotationDates sets every annotationDate at any depth to the fixed value.
func pinAnnotationDates(v any) {
switch t := v.(type) {
case map[string]any:
if _, ok := t["annotationDate"]; ok {
t["annotationDate"] = scannerRunEpoch
}
for _, child := range t {
pinAnnotationDates(child)
}
case []any:
for _, child := range t {
pinAnnotationDates(child)
}
}
}
// sbomFileName derives a stable, filesystem/S3-safe identity for the SBOM:
// project/repo@sha256:abc -> project__repo@sha256-abc.spdx.json.
// Re-scanning the same artifact digest maps to the same file name.
func sbomFileName(repoFullName, artifactDigest string) string {
repo := strings.ReplaceAll(repoFullName, "/", "__")
digest := strings.ReplaceAll(artifactDigest, ":", "-")
if repo == "" {
repo = "unknown"
}
if digest == "" {
digest = "unknown"
}
return fmt.Sprintf("%s@%s.spdx.json", repo, digest)
}
// writeAtomic writes under a "_"-prefixed temp name (which BOMHort's
// ingestion-watcher skips) and renames into place.
func writeAtomic(dir, name string, data []byte) error {
tmp := filepath.Join(dir, "_incoming-"+name)
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, filepath.Join(dir, name)); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// registryGet performs an authenticated GET against Harbor's registry API.
// It tries basic auth first and, on a 401 bearer challenge, fetches a pull
// token from the advertised realm (Harbor's /service/token) and retries.
func (b *bridge) registryGet(ctx context.Context, rawURL, repo, accept string) ([]byte, error) {
do := func(authHeader string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
if accept != "" {
req.Header.Set("Accept", accept)
}
req.Header.Set("Authorization", authHeader)
return b.client.Do(req)
}
basic := "Basic " + basicAuth(b.cfg.username, b.cfg.password)
resp, err := do(basic)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusUnauthorized {
challenge := resp.Header.Get("Www-Authenticate")
resp.Body.Close()
token, err := b.fetchBearerToken(ctx, challenge, repo)
if err != nil {
return nil, fmt.Errorf("bearer token: %w", err)
}
resp, err = do("Bearer " + token)
if err != nil {
return nil, err
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("GET %s: %s: %s", rawURL, resp.Status, strings.TrimSpace(string(body)))
}
return io.ReadAll(io.LimitReader(resp.Body, maxSBOMSize))
}
// fetchBearerToken parses a `Bearer realm="...",service="..."` challenge and
// exchanges basic credentials for a pull-scoped registry token.
func (b *bridge) fetchBearerToken(ctx context.Context, challenge, repo string) (string, error) {
realm, service := parseBearerChallenge(challenge)
if realm == "" {
return "", fmt.Errorf("no bearer realm in challenge %q", challenge)
}
q := url.Values{}
if service != "" {
q.Set("service", service)
}
q.Set("scope", fmt.Sprintf("repository:%s:pull", repo))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, realm+"?"+q.Encode(), nil)
if err != nil {
return "", err
}
req.SetBasicAuth(b.cfg.username, b.cfg.password)
resp, err := b.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint %s: %s", realm, resp.Status)
}
var tok struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", err
}
if tok.Token != "" {
return tok.Token, nil
}
return tok.AccessToken, nil
}
func parseBearerChallenge(header string) (realm, service string) {
rest, ok := strings.CutPrefix(header, "Bearer ")
if !ok {
return "", ""
}
for _, part := range strings.Split(rest, ",") {
key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
val = strings.Trim(val, `"`)
switch key {
case "realm":
realm = val
case "service":
service = val
}
}
return realm, service
}
func basicAuth(user, pass string) string {
req, _ := http.NewRequest(http.MethodGet, "http://x", nil)
req.SetBasicAuth(user, pass)
return strings.TrimPrefix(req.Header.Get("Authorization"), "Basic ")
}