Skip to content

Dependency-Track

Dependency-Track keeps a software bill of materials for every image the cluster runs and re-checks each one against the vulnerability feeds every day, so a new CVE shows up against images that were already scanned, not only the next time Trivy looks. Upstream publishes the dependency-track chart, so this is that chart with an external database, per Conventions → Official upstream sources only; the network policy, the database, the Authentik blueprint and the upload job come from a second source pointing at this repository.

The SBOMs are not generated here. The platform's Trivy Operator writes one CycloneDX SbomReport per running container, and the upload job hands them over once a night. Trivy answers what is in the images today; Dependency-Track answers what changed, which images share a component, and whether a policy holds across the portfolio.

At a glance

URL sbom.k8s.wlkr.ch
Authentication Authentik OIDC, code flow with PKCE; admin kept as break-glass
Storage A PVC for uploaded BOM files and the mirrored feeds
Database CloudNativePG Cluster dependency-track-db
Secrets kv/dependency-track/config and kv/dependency-track/sbom-upload in OpenBao
Files dependency-track/

Configuration

The chart values in application.yaml that are not defaults:

Setting Why
database.existingSecret: dependency-track-db-app CloudNativePG's generated credentials carry the username and password keys the chart mounts as files; see the contract. The JDBC URL names the -rw Service
secretManagement.database.kek from dependency-track-config The key that encrypts every secret Dependency-Track stores in its database, API tokens for the feeds included. It must survive a rebuild, so it is generated into OpenBao, never by the chart. See Secrets
fileStorage.local.existingClaim The chart's own PVC carries only helm.sh/resource-policy: keep, which a prune ignores; pvc.yaml carries Prune=false like every other volume here
DT_TELEMETRY_SUBMISSION_DEFAULT_ENABLED: "false" No phone-home. The property seeds the setting once, so it has to be off before the first start; the egress policy would drop the request anyway
DT_OIDC_* and the frontend's OIDC_* Both halves must name the same issuer and client ID; the API server validates the ID token the browser obtained. preferred_username is Authentik's username claim, which is safe only while users cannot change it (see Conventions → Authentication); Dependency-Track also stores each account's first sub and refuses a later login under another, so a rename could only claim an account that has never logged in. groups arrives with the profile scope, so team membership follows Authentik's groups. See OIDC
apiServer.web.resources, below upstream's advice The JVM sizes its heap from the limit. Upstream recommends 8Gi for production; the portfolio here is a few hundred small images
serviceMonitor.namespace: dependency-track The chart defaults to the monitoring namespace, which the apps project may not write to. Prometheus reads it from here like every other application's
httpRoute on apps-gateway The chart's route sends /api to the API server and everything else to the frontend on one hostname, so the frontend's API_BASE_URL stays empty and the browser uses relative URLs
apiServer.image.tag and frontend.image.tag pinned by digest, though the chart derives both from its appVersion A tag re-pushed upstream would otherwise change what runs without a commit, and Renovate sees a rebuild only through a digest. The chart and the two pins are one Renovate group, so a chart bump cannot leave a pod on the previous release.
revisionHistoryLimit: 2 on both Deployments Superseded ReplicaSets keep their Trivy config-audit reports until garbage collection; the chart keeps five

Network policy

Every rule in dependency-track/networkpolicy.yaml, and why it is there. The shape is Conventions → Ship a CiliumNetworkPolicy.

Rule Why
Ingress on 8080 from ingress The Gateway reaches the API server and the frontend directly; the login is OIDC, so no outpost sits in front
Ingress from Prometheus on 9000 and 9187, GET /metrics only The API server's management port and the database's exporter
Ingress from cnpg-system on 8000 The operator polls the instance manager there; without the rule the Cluster never goes Healthy. See Sync stuck on the database
Egress from the API server to Authentik Discovery, the signing keys and the userinfo endpoint, on the public name, because the iss claim is that name
Egress from the API server to nvd.nist.gov, storage.googleapis.com, api.github.com, epss.empiricalsecurity.com, www.cisa.gov, raw.githubusercontent.com The feeds the internal analyzer mirrors: NVD, OSV, GitHub Advisories, EPSS, and the CISA and ENISA known-exploited lists (ENISA publishes on GitHub). GitHub stays off until a token is entered in the UI; the rule is there so entering one is the only step
Egress from the API server to proxy.golang.org, registry.npmjs.org, pypi.org, repo1.maven.org, api.nuget.org, crates.io, index.crates.io, rubygems.org The registries Dependency-Track asks for the latest version of a component, for the ecosystems the images here contain. The other default repositories are disabled at first login rather than allowed
Egress from the upload job to kube-apiserver It lists the SbomReport objects; the API it posts to is in the namespace
Egress from the database pod to kube-apiserver The instance manager reports its status there; its liveness check fails otherwise

SBOM upload

sbom-upload.yaml is a CronJob, a ConfigMap holding the script, and a ServiceAccount. Nightly, on the CronJob's schedule, it lists every SbomReport in the cluster and sends each distinct image that is running, once, to PUT /api/v1/bom with autoCreate.

Detail Why
Project name is the image reference without its tag, version is the tag A project's version history is then Renovate's bump history, and two namespaces running the same image share one project
Namespaces become namespace:<name> tags on the project Where a finding runs is the first question when it fires
Reports owned by a ReplicaSet scaled to zero are skipped A report outlives its workload until the ReplicaSet is garbage-collected, and a superseded revision is not part of what runs. If the platform's grant lacks replicasets, the job says so and uploads everything
isLatest goes to the highest version that is running Two versions of one image can run at once, and the flag otherwise lands on whichever was uploaded last
Two digests under one tag are one upload They are one project version; the namespaces of both become its tags
Reads report.components from the report, unchanged Trivy Operator already writes CycloneDX; Dependency-Track accepts the same document, specVersion 1.7 included
The ClusterRole that lets it list reports and ReplicaSets is the platform's The apps project may not create cluster-scoped RBAC, and who may read Trivy's findings is the platform's call. It is sbom-readers.yaml next to the operator in the homelab repository
The API key is optional on the container, and its ExternalSecret is at sync wave 1 A missing Secret would leave the pod in CreateContainerConfigError, and a Forbid CronJob never runs again behind a Job that never finishes; the script exits 1 with the reason instead. The wave is the same argument one level up: this is the one secret only a running Dependency-Track can issue, so it is fetched after the application, never in a wave that gates it. make bao-secrets writes the path empty rather than leaving it absent, for the same reason
Runs before the platform's findings-history job Both read the same reports; neither depends on the other
activeDeadlineSeconds ends the Job before findings-history starts concurrencyPolicy: Forbid never starts tonight's run behind one still hanging on a request, and the later job reads the same reports; a run that overran is killed in time for both

False positives

false-positives.json in the same ConfigMap names findings that are not findings: a project, an advisory, a component and the reason. After the uploads the job records each as a suppressed FALSE_POSITIVE analysis.

Detail Why
The job records them, not a person An analysis belongs to one project version, so every new tag of the image arrives with the finding open again
A rule names the component by PURL without its version The version moves with the image; the name is what collides
A finding raised by tonight's upload is suppressed tomorrow Dependency-Track analyses a BOM after it has accepted it, and the job does not wait
Without VULNERABILITY_ANALYSIS_UPDATE the job says so and succeeds The uploads are what the job is for

Retired projects

A project the job created and did not upload in a run is marked inactive, since its image no longer runs. Administration → Configuration → Maintenance then deletes it: retention there only ever acts on inactive projects.

Detail Why
Only after a run with no failed upload and no skipped report A run that found nothing to upload would retire the portfolio
Only projects with a namespace: tag That is the job's mark; a project created by hand is not the job's to retire
Every upload asks for an active project An image that runs again after a rollback is part of the portfolio again
Without PORTFOLIO_MANAGEMENT_UPDATE the job says so and succeeds The uploads are what the job is for

How many retired versions of a project stay is the retention setting's to say, not the job's.

OIDC

The provider is dependency-track/authentik-blueprint.yaml, a ConfigMap targeted at the authentik namespace, mounted and discovered as Conventions → Authentication describes. The fields that matter:

Blueprint field Why
client_type: public, no client_secret The frontend is a browser application and authenticates with authorization code plus PKCE; there is nowhere to keep a secret and the API server only validates the resulting ID token
redirect_uris ends in /static/oidc-callback.html The page the frontend ships for the return leg
client_id from !Env Authentik reads it from its own ExternalSecret on kv/dependency-track/config, the same value both Deployments read
A dependency-track-admins group Mapped to the Administrators team, so the groups claim is what grants admin, in the shape ArgoCD and Grafana already use. The group is in the blueprint because it is the contract; who is in it stays in Authentik and out of Git

The issuer is https://auth.k8s.wlkr.ch/application/o/dependency-track/, trailing slash included: Dependency-Track compares the discovered issuer with the configured one character for character, and Authentik's carries the slash. Use auth.k8s.wlkr.ch, Authentik's only hostname; see One hostname.

Secrets

Two paths. kv/dependency-track/config is written once by the PushSecret in secrets.yaml and never overwritten — see Generated secrets; its template turns 32 generated characters into the base64 the chart wants for kek. kv/dependency-track/sbom-upload is written by make bao-secrets in the platform repository.

Path Key Read by
kv/dependency-track/config kek The API server, as the key encryption key for the secrets it stores
kv/dependency-track/config oidc-client-id The API server, the frontend and Authentik, through two ExternalSecrets
kv/dependency-track/sbom-upload api-key The upload job

The API key is its own path because Dependency-Track issues it, after the first start, and a bao kv put replaces a path wholesale: writing it later into the first path would have deleted the KEK. The script asks for it and accepts an empty answer, so the path exists before Dependency-Track can issue a key. The database password is not here; CloudNativePG generates it.

Usage

First login

The first start seeds admin with the password admin and asks for a new one. Keep the account as break-glass; everything after is Authentik.

  1. Add yourself to dependency-track-admins in Authentik. The blueprint creates the group and the mapping to the Administrators team is under Administration → Access Management → OpenID Connect Groups; team synchronization grants it on the next login.

    A team with no mapped group is emptied at every login

    synchronizeTeamMembership removes an OIDC user from every team that has no mapped OpenID Connect group, so adding someone to a team by hand holds only until they log in again. The mapping, not the membership, is what lasts.

    1. Administration → Access Management → Teams: a team sbom-upload with the BOM_UPLOAD, PROJECT_CREATION_UPLOAD, VIEW_PORTFOLIO, VULNERABILITY_ANALYSIS_UPDATE and PORTFOLIO_MANAGEMENT_UPDATE permissions, and an API key on it. make bao-secrets in the platform repository writes it to kv/dependency-track/sbom-upload; the job picks it up at its next run, or sooner:
    kubectl -n dependency-track create job --from=cronjob/sbom-upload sbom-upload-now
    kubectl -n dependency-track logs job/sbom-upload-now
    
  2. Administration → Vulnerability Sources: OSV is off by default. Enable it with the Debian, Alpine, Go, npm, PyPI, Maven, NuGet, crates.io and RubyGems ecosystems; NVD and EPSS are on already. GitHub Advisories needs a token.

    Without OSV the portfolio has components and no findings

    NVD describes what a vulnerability affects as a CPE, and the internal analyzer skips any component without one. Trivy's SBOMs are mostly operating-system and language packages, which carry a PURL and no CPE, so NVD alone matches almost nothing here however many hundred thousand advisories it has mirrored. OSV and GitHub Advisories are the ones that match on PURL. A full portfolio reporting zero vulnerabilities is this, not a clean estate.

    OSV mirrors at 03:00 and NVD at 04:00, so findings appear the morning after the source is switched on, not the minute it is. 4. Administration → Configuration → Maintenance: enable inactive project deletion, by versions. Without it a retired project stays forever. 5. Administration → Repositories: disable the repositories the egress policy does not allow, or every analysis logs a failed lookup for them.

Reading it

The portfolio is one project per image; the namespace: tags say where it runs. A vulnerability's Affected Projects view is the cross-image question Trivy's per-container reports cannot answer directly. The same objects are in the API, with $KEY an API key of a team created under Administration → Access Management → Teams, or the upload job's:

KEY="$(kubectl -n dependency-track get secret sbom-upload -o jsonpath='{.data.api-key}' | base64 -d)"

# Every project and its version, newest upload first
curl -s -H "X-Api-Key: $KEY" "https://sbom.k8s.wlkr.ch/api/v1/project?sortName=lastBomImport&sortOrder=desc" \
  | jq -r '.[] | [.name, .version, .lastBomImport] | @tsv'

# Findings for one project
curl -s -H "X-Api-Key: $KEY" "https://sbom.k8s.wlkr.ch/api/v1/finding/project/<uuid>" \
  | jq -r '.[] | [.vulnerability.severity, .vulnerability.vulnId, .component.purl] | @tsv'

Health check

kubectl -n dependency-track get pods,cronjob
kubectl -n dependency-track get cluster dependency-track-db
curl -s https://sbom.k8s.wlkr.ch/api/version | jq .version

The database should report Cluster in healthy state, the version endpoint answers without a login, and the last sbom-upload job should have succeeded with its closing line of images uploaded, failed, reports skipped and superseded revisions, the first three of which should read 0. In the UI, the Dashboard's portfolio count should match the images number.

Pitfalls

The KEK is the database

Every secret Dependency-Track stores, feed tokens and notification credentials among them, is encrypted with the key in kv/dependency-track/config. Deleting that path makes the PushSecret write a new key and leaves the database unreadable in those places. A restore of the database is only a restore together with the key.

  • A login that fails on iss is a mismatch between the issuer in application.yaml and Authentik's, usually the trailing slash. The value is written twice there, as DT_OIDC_ISSUER and the frontend's OIDC_ISSUER; change both.
  • A first login that lands nowhere is a user with no team. Provisioning creates the account; the group mapping in First login is what grants it anything.
  • A job that reports no API key yet is the first-login step not done, or the ExternalSecret not refreshed since it was; the Secret carries a data-hash annotation that moves when the value does.
  • A sync that fails at the sbom-upload ExternalSecret with could not get secret data from provider is kv/dependency-track/sbom-upload missing entirely. Run make bao-secrets in the platform repository, which writes the path empty when there is no key yet; an absent path fails the ExternalSecret. It sits at wave 1, so Dependency-Track itself is up, only the job's key is not.
  • Old image versions pile up as projects when inactive project deletion is off under Maintenance, or the team lacks the permission to retire them. The job never deletes.