Skip to content

Commit 6c8fb35

Browse files
authored
Merge pull request #626 from simonchrz/feat/project-inventory
Add a project inventory built from uploaded SBOMs
2 parents fba9e03 + 903fd94 commit 6c8fb35

41 files changed

Lines changed: 3500 additions & 66 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,6 +1343,17 @@ Registered when `hosted.enabled` is set. Pushing and deleting require `Authoriza
13431343
| `GET /api/hosted/{ecosystem}/{name}` | List the archives pushed for a package |
13441344
| `DELETE /api/hosted/{ecosystem}/{name}/{version}` | Delete a pushed version |
13451345

1346+
### Projects API
1347+
1348+
Registered when `projects.enabled` is set. Uploading and deleting require `Authorization: Bearer <projects.token>`. See [Project inventory](docs/configuration.md#project-inventory).
1349+
1350+
| Endpoint | Description |
1351+
|----------|-------------|
1352+
| `PUT /api/projects/{project}/sbom` | Replace a project's recorded package versions with the ones the SBOM in the body lists |
1353+
| `GET /api/projects` | List projects, or with `?purl=pkg:npm/lodash@4.17.20` the projects using a package or version |
1354+
| `GET /api/projects/{project}` | A project's recorded package versions |
1355+
| `DELETE /api/projects/{project}` | Delete a project |
1356+
13461357
### Cache API
13471358

13481359
Registered when `cache_delete` is set. It has no authentication of its own.
@@ -1546,6 +1557,7 @@ The proxy serves a web UI under `/ui`. No separate frontend build is needed -- t
15461557
- **Version detail** (`/ui/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts.
15471558
- **Source browser** (`/ui/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews.
15481559
- **Version diff** (`/ui/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files.
1560+
- **Projects** (`/ui/projects`) -- with the [project inventory](docs/configuration.md#project-inventory) enabled, the package versions each repository's uploaded SBOM lists, marked when outdated. Package pages then show which projects use each version.
15491561
15501562
## Monitoring
15511563

‎cmd/proxy/main.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// @securityDefinitions.apikey BearerAuth
99
// @in header
1010
// @name Authorization
11-
// @description "Bearer " followed by hosted.token
11+
// @description "Bearer " followed by hosted.token, or projects.token for /api/projects
1212
//
1313
// The proxy caches package artifacts from upstream registries (npm, cargo, etc.)
1414
// providing faster, more reliable access for development teams.
@@ -93,6 +93,9 @@
9393
// PROXY_HOSTED_MAX_UPLOAD_SIZE - Max size of one pushed archive (default "100MB")
9494
// PROXY_HOSTED_NPM_SCOPES - npm scopes served from pushed packages only
9595
// PROXY_HOSTED_COMPOSER_VENDORS - Composer vendors served from pushed packages only
96+
// PROXY_PROJECTS_ENABLED - Enable the project inventory (/api/projects, /ui/projects)
97+
// PROXY_PROJECTS_TOKEN - Bearer token for uploading and deleting project SBOMs
98+
// PROXY_PROJECTS_MAX_AGE - Remove projects not uploaded for this long (e.g. "90d")
9699
// PROXY_URL - Proxy URL for proxy hosted
97100
//
98101
// Example:

‎config.example.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,15 @@ gradle:
451451
# npm_scopes: ["@acme"]
452452
# composer_vendors: ["acme"]
453453

454+
# Project inventory (disabled by default). Repositories upload an SBOM to
455+
# PUT /api/projects/{project}/sbom, and the UI shows which project uses
456+
# which package versions. Project names are visible on the unauthenticated UI.
457+
# projects:
458+
# enabled: true
459+
# token: "${PROXY_PROJECTS_TOKEN}"
460+
# # Drop projects that have not uploaded for this long ("" keeps them)
461+
# max_age: "90d"
462+
454463
# Re-mirror container image tags on a schedule, so a moved tag is picked up
455464
# even when nobody pulls it. An unchanged tag costs one HEAD request.
456465
# mirror:

‎docs/architecture.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@ Selective package mirroring for pre-populating the proxy cache. Supports multipl
367367

368368
The package also provides a `MetadataCache` for storing raw upstream metadata blobs so the proxy can serve metadata responses offline. The `JobStore` manages async mirror jobs exposed via the `/api/mirror` endpoints.
369369

370+
### `internal/inventory`
371+
372+
Turns an SBOM uploaded to `/api/projects` into the package versions recorded for the project. Package URLs are mapped to the ecosystem names and unversioned PURLs the handlers record packages under, so a project's components join with `packages` by `purl`. Also compares versions for the outdated marker on the project pages.
373+
370374
### `internal/config`
371375

372376
Configuration loading.

‎docs/configuration.md‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1647,6 +1647,89 @@ Generic files never come from an upstream: anything not pushed answers 404. Whil
16471647

16481648
Pushed archives are stored like cached ones but marked as local. The cache size limit and storage retention never remove them; only a delete does. Scanning, when enabled, checks pushed archives like fetched ones.
16491649

1650+
## Project inventory
1651+
1652+
The project inventory answers "which repository uses which version of a package?". Each repository uploads an SBOM of its default branch, and the proxy records the package versions it lists. The UI then shows:
1653+
1654+
- `/ui/projects`: every project, and per project its package versions, marked when the proxy knows a newer version.
1655+
- On a package page: which projects use each version, including versions the proxy hasn't cached.
1656+
1657+
It is disabled by default. Uploads and deletes require a token:
1658+
1659+
```yaml
1660+
projects:
1661+
enabled: true
1662+
token: "${PROXY_PROJECTS_TOKEN}"
1663+
max_age: "90d"
1664+
```
1665+
1666+
| Config | Environment | Description |
1667+
|--------|-------------|-------------|
1668+
| `projects.enabled` | `PROXY_PROJECTS_ENABLED` | Register the `/api/projects` endpoints and the UI pages (default `false`) |
1669+
| `projects.token` | `PROXY_PROJECTS_TOKEN` | Bearer token required to upload and delete; required when enabled, supports `${VAR}` |
1670+
| `projects.max_age` | `PROXY_PROJECTS_MAX_AGE` | Remove projects that haven't uploaded for this long, e.g. `90d` or `720h`; empty or `0` keeps them (default) |
1671+
1672+
Removing old projects runs hourly. With several instances on one database, one of them does it.
1673+
1674+
### Uploading an SBOM
1675+
1676+
`PUT /api/projects/{project}/sbom` takes a CycloneDX (JSON or XML) or SPDX (JSON or tag-value) document of up to 32 MiB and replaces everything recorded for the project with what it lists. The optional `ref` and `branch` query parameters are stored and shown with the project.
1677+
1678+
The project name is a path such as `group/sub/repo`: segments of letters, digits, `.`, `_` and `-`, at most 255 characters. It can be sent as path segments (`/api/projects/group/sub/repo/sbom`) or URL-encoded as one segment (`/api/projects/group%2Fsub%2Frepo/sbom`).
1679+
1680+
```bash
1681+
syft dir:. -o cyclonedx-json > sbom.json
1682+
curl -X PUT --data-binary @sbom.json \
1683+
-H "Authorization: Bearer $PROXY_PROJECTS_TOKEN" \
1684+
"https://proxy.example.com/api/projects/acme/shop/web/sbom?ref=$(git rev-parse HEAD)&branch=main"
1685+
```
1686+
1687+
The response counts the recorded package versions and the skipped entries:
1688+
1689+
```json
1690+
{"project":"acme/shop/web","components":9,"skipped":6}
1691+
```
1692+
1693+
Entries are recorded by their package URL, with the package named the way the proxy caches it: NuGet IDs lowercased, PyPI names normalized as in PEP 503 (`zope.interface` becomes `zope-interface`), the distribution dropped from `deb`, `rpm` and `apk` package URLs. Entries without a package URL (files, the operating system), package types the proxy doesn't serve (such as `pkg:github`), entries without a version, and conda entries without a `channel` qualifier (the proxy records conda packages as `channel/name`) are skipped. So is the project's own package: the root of a lock file such as `package-lock.json`, and with syft, the Go module its binaries were built from. Duplicates are recorded once.
1694+
1695+
A GitLab CI job on the default branch could look like this:
1696+
1697+
```yaml
1698+
sbom:
1699+
image: alpine:3.20
1700+
rules:
1701+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
1702+
script:
1703+
- apk add --no-cache curl
1704+
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
1705+
- syft dir:. -o cyclonedx-json > sbom.json
1706+
- >
1707+
curl -sSf -X PUT --data-binary @sbom.json
1708+
-H "Authorization: Bearer $PROXY_PROJECTS_TOKEN"
1709+
"https://proxy.example.com/api/projects/$CI_PROJECT_PATH/sbom?ref=$CI_COMMIT_SHA&branch=$CI_COMMIT_BRANCH"
1710+
```
1711+
1712+
### Querying
1713+
1714+
```bash
1715+
curl https://proxy.example.com/api/projects # all projects
1716+
curl https://proxy.example.com/api/projects/acme/shop/web # one project's package versions
1717+
curl 'https://proxy.example.com/api/projects?purl=pkg:npm/lodash' # projects using lodash
1718+
curl 'https://proxy.example.com/api/projects?purl=pkg:npm/lodash@4.17.20' # projects using lodash 4.17.20
1719+
curl -X DELETE -H "Authorization: Bearer $PROXY_PROJECTS_TOKEN" \
1720+
https://proxy.example.com/api/projects/acme/shop/web
1721+
```
1722+
1723+
Reading needs no token, like the rest of `/api`. With `?purl=`, each project carries the `version` it uses and no `component_count`. A delete names the project itself: `DELETE /api/projects/acme/shop/web/sbom` is answered with 400 unless a project `acme/shop/web/sbom` exists.
1724+
1725+
A project's page in the UI also has a delete button. It asks for the token and sends it to `DELETE /ui/api/projects/{project}`, which works like the API endpoint and also requires the token; the token is kept for the browser session. Like `/ui/api/cache`, it sits under `/ui`, so rules for the UI at an ingress cover it.
1726+
1727+
### What it shows
1728+
1729+
The inventory reflects what the projects' SBOMs list, not what was downloaded through the proxy: a project that installs from another registry still shows up, and the proxy may not have cached any of its packages. The outdated marker compares the version a project uses with the latest version the proxy has recorded for the package; a package the proxy has no record of has none. Versions are compared as the SBOM gives them: a version spelled differently from how the proxy caches it, such as Composer's `v6.4.1` for `6.4.1` or a Debian version with or without its epoch, shows as "not cached" on the package page.
1730+
1731+
Project names and the packages they use are shown on `/ui` and `/api/projects` without authentication. If that is sensitive, restrict both at your ingress or reverse proxy.
1732+
16501733
## Mirror API
16511734

16521735
The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP:

0 commit comments

Comments
 (0)