diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index f69f239..e778e3b 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -53,6 +53,18 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Resolve image version + id: buildmeta + run: | + version="${VERSION_REF#v}" + { + echo "version=${version}" + echo "commit=$(git rev-parse HEAD)" + echo "date=$(git log -1 --format=%cI)" + } >> "$GITHUB_OUTPUT" + env: + VERSION_REF: ${{ inputs.tag }} + - name: Build and push image id: push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 @@ -61,6 +73,10 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.buildmeta.outputs.version }} + COMMIT=${{ steps.buildmeta.outputs.commit }} + DATE=${{ steps.buildmeta.outputs.date }} - name: Sign image env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42ccc79..b4d416c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,18 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Resolve image version + id: buildmeta + run: | + version="${VERSION_REF#v}" + { + echo "version=${version}" + echo "commit=$(git rev-parse HEAD)" + echo "date=$(git log -1 --format=%cI)" + } >> "$GITHUB_OUTPUT" + env: + VERSION_REF: ${{ github.ref_name }} + - name: Build and push image id: push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 @@ -71,6 +83,10 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.buildmeta.outputs.version }} + COMMIT=${{ steps.buildmeta.outputs.commit }} + DATE=${{ steps.buildmeta.outputs.date }} - name: Sign image env: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 49a6770..12f471d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -10,6 +10,11 @@ builds: binary: om env: - CGO_ENABLED=0 + ldflags: + - -s -w + - -X github.com/OpenSourceOM/core/internal/version.version={{ .Version }} + - -X github.com/OpenSourceOM/core/internal/version.commit={{ .FullCommit }} + - -X github.com/OpenSourceOM/core/internal/version.date={{ .CommitDate }} goos: - linux - darwin diff --git a/Dockerfile b/Dockerfile index 0b08cb6..f60bab5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,21 @@ # SPDX-License-Identifier: Apache-2.0 FROM golang:1.27.3-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c AS builder +RUN apk add --no-cache git WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o /om ./cmd/om +ARG VERSION= +ARG COMMIT= +ARG DATE= +# Empty VERSION, COMMIT, and DATE fall through to the VCS metadata Go stamps. +# safe.directory lets that work when the build context is owned by another user. +# Release workflows pass all three. +RUN git config --global --add safe.directory /src \ + && CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X github.com/OpenSourceOM/core/internal/version.version=${VERSION} -X github.com/OpenSourceOM/core/internal/version.commit=${COMMIT} -X github.com/OpenSourceOM/core/internal/version.date=${DATE}" \ + -o /om ./cmd/om FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 RUN apk add --no-cache ca-certificates diff --git a/README.md b/README.md index ab36397..27ab6b5 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Full design: [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) ## Repository layout ``` -cmd/om/ `om` CLI (migrate, serve, scan, enrich, rules, identity, export, graph, paths) +cmd/om/ `om` CLI (version, migrate, serve, scan, enrich, rules, identity, export, graph, paths) sdk/collector/ Public SDK for external collector plugins examples/collector/ Sample plugin (`om scan plugin`) internal/collectors/ AWS, Azure, GCP, Kubernetes, demo graph @@ -87,6 +87,9 @@ docker compose up -d # Build the CLI go build -o om ./cmd/om +# Print this build. A release says 0.5.0. Uncommitted changes add +dirty. +./om version + # Load the sample graph (no cloud credentials) ./om scan demo @@ -128,13 +131,15 @@ open http://localhost:8080 The API image compiles `github.com/aws/aws-sdk-go-v2/service/ec2`. Give Docker at least 6 GiB of RAM. +The header shows the version of the running build. Open it, or Settings → About, for the git commit and a link to that commit. `GET /v1/version` returns the same information. A release shows its version, such as 0.5.0. A git checkout of that release shows the same version, with `+dirty` when the tree has uncommitted changes. + The console signs people in with an email and password. While no admin exists, the page asks you to create one. Keep the API port private until you do. Set `OM_ADMIN_EMAIL` when that first address should be fixed in advance. `om admin create --email you@example.com` does the same from the machine before the port is reachable. An admin adds the next person from the console. That person gets a temporary password, shown once, and chooses their own at the next sign-in. SMTP is optional. When it is set, the temporary password is emailed, and a forgotten password can be reset from a one-hour link. Google and GitHub buttons appear only when those client credentials are set, and the provider email must match an existing admin. Every console account is an admin. Two-factor authentication is optional per admin. -An API token is created only when an admin asks for one, from the console or with `om token create --email you@example.com`. It is printed once. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins. `om token rotate --email you@example.com` replaces it. +An API token is created only when an admin asks for one, from the console or with `om token create --email you@example.com`. It is printed once. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, version, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins. `om token rotate --email you@example.com` replaces it. -**API endpoints:** `GET /v1/health`, `POST /v1/ingest`, `GET /v1/findings`, `GET /v1/graph/snapshot`, `POST /v1/rules/run`, `GET /v1/identity/blast-radius` +**API endpoints:** `GET /v1/health`, `GET /v1/version`, `POST /v1/ingest`, `GET /v1/findings`, `GET /v1/graph/snapshot`, `POST /v1/rules/run`, `GET /v1/identity/blast-radius` **Multi-cloud scan:** diff --git a/api/README.md b/api/README.md index f6fe98b..6db21df 100644 --- a/api/README.md +++ b/api/README.md @@ -13,5 +13,6 @@ HTTP and GraphQL API for the web UI, collectors, and integrations. - `GET /v1/graph/query` — attack path and neighborhood queries - `GET /v1/findings` — prioritized findings with path context - `GET /v1/health` — readiness for orchestration +- `GET /v1/version` — version, git commit, and commit date of this build -Authentication: every `/v1/*` route except `GET /v1/health`, `GET /v1/auth/session`, `POST /v1/auth/logout`, `POST /v1/auth/setup`, `POST /v1/auth/login`, and `POST /v1/auth/totp` requires a console session cookie or an API token that admin created (`Authorization: Bearer` or `X-API-Key`). Admin routes (`/v1/admins`, including token create and rotation) require the session. People sign in with email and password. The first account is created from the setup form or `om admin create`. A token is created from the console or `om token create`, and is not issued at sign-in. Google and GitHub are optional and must match an existing admin email. Enterprise SAML and custom identity providers live outside this repo. +Authentication: every `/v1/*` route except `GET /v1/health`, `GET /v1/version`, `GET /v1/auth/session`, `POST /v1/auth/logout`, `POST /v1/auth/setup`, `POST /v1/auth/login`, and `POST /v1/auth/totp` requires a console session cookie or an API token that admin created (`Authorization: Bearer` or `X-API-Key`). Admin routes (`/v1/admins`, including token create and rotation) require the session. People sign in with email and password. The first account is created from the setup form or `om admin create`. A token is created from the console or `om token create`, and is not issued at sign-in. Google and GitHub are optional and must match an existing admin email. Enterprise SAML and custom identity providers live outside this repo. diff --git a/internal/api/console_auth.go b/internal/api/console_auth.go index 65e9d26..ad492f3 100644 --- a/internal/api/console_auth.go +++ b/internal/api/console_auth.go @@ -474,6 +474,8 @@ func publicV1(r *http.Request) bool { switch { case r.Method == http.MethodGet && r.URL.Path == "/v1/health": return true + case r.Method == http.MethodGet && r.URL.Path == "/v1/version": + return true case r.Method == http.MethodGet && r.URL.Path == "/v1/auth/session": return true case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/logout": diff --git a/internal/api/server.go b/internal/api/server.go index b02baf7..a0b87f0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -20,6 +20,7 @@ import ( "github.com/OpenSourceOM/core/internal/export" "github.com/OpenSourceOM/core/internal/graph" "github.com/OpenSourceOM/core/internal/rules" + "github.com/OpenSourceOM/core/internal/version" ) const ( @@ -58,6 +59,7 @@ func NewServer(store *graph.Store) *Server { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /v1/health", s.handleHealth) + mux.HandleFunc("GET /v1/version", s.handleVersion) mux.HandleFunc("POST /v1/ingest", s.handleIngest) mux.HandleFunc("GET /v1/graph/stats", s.handleStats) mux.HandleFunc("GET /v1/graph/query", s.handleQuery) @@ -131,6 +133,11 @@ func (s *Server) ListenAndServe(addr string) error { return server.ListenAndServe() } +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, version.Current()) +} + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if err := s.health.Ping(r.Context()); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{ diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f77bdc8..b0b87f6 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -77,6 +77,40 @@ func TestHandleHealth(t *testing.T) { } } +func TestVersionIsPublic(t *testing.T) { + handler := (&Server{}).Handler() + request := httptest.NewRequest(http.MethodGet, "/v1/version", nil) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if got := recorder.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("cache-control = %q", got) + } + var body map[string]any + if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["name"] != "OpenSourceOM Core" { + t.Fatalf("name = %v", body["name"]) + } + for _, key := range []string{"version", "commit", "commit_short", "date", "modified"} { + if _, ok := body[key]; !ok { + t.Fatalf("missing %s in %#v", key, body) + } + } + if body["version"] == "" || body["commit"] == "" { + t.Fatalf("empty build identity: %#v", body) + } + if body["version"] == "dev" { + if _, ok := body["release_url"]; ok { + t.Fatal("dev builds must not advertise a release") + } + } +} + func TestPersonalTokenGatesV1Routes(t *testing.T) { directory := newMemoryDirectory() directory.rememberToken("secret", auth.Admin{ID: "admin-1", Provider: auth.ProviderGitHub, Subject: "1", Login: "ada"}) @@ -92,6 +126,7 @@ func TestPersonalTokenGatesV1Routes(t *testing.T) { wantStatus int }{ {name: "health open without key", method: http.MethodGet, path: "/v1/health", wantStatus: http.StatusOK}, + {name: "version open without key", method: http.MethodGet, path: "/v1/version", wantStatus: http.StatusOK}, {name: "rules list denied without key", method: http.MethodGet, path: "/v1/rules", wantStatus: http.StatusUnauthorized}, {name: "rules list allowed with api key", method: http.MethodGet, path: "/v1/rules", header: "X-API-Key", value: "secret", wantStatus: http.StatusOK}, {name: "rules list allowed with bearer", method: http.MethodGet, path: "/v1/rules", header: "Authorization", value: "Bearer secret", wantStatus: http.StatusOK}, diff --git a/internal/api/web/app.js b/internal/api/web/app.js index f2e913f..a4356f4 100644 --- a/internal/api/web/app.js +++ b/internal/api/web/app.js @@ -1023,7 +1023,90 @@ document.getElementById("admins-btn").addEventListener("click", () => { renderAdmins().catch(showConsoleError); }); +function versionLabel(info) { + const version = info.version || "dev"; + if (version !== "dev" || !info.commit_short || info.commit_short === "unknown") return version; + const short = info.modified ? `${info.commit_short}-dirty` : info.commit_short; + return `dev · ${short}`; +} + +function formatVersionDate(value) { + if (!value || value === "unknown") return "unknown"; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short", + }); +} + +function fillAbout(info) { + const version = info.version || "dev"; + const label = document.getElementById("version-label"); + label.textContent = versionLabel(info); + const button = document.getElementById("about-btn"); + button.classList.remove("hidden"); + button.setAttribute("aria-label", `About OpenSourceOM Core, version ${version}`); + document.getElementById("about-version").textContent = version; + + const commit = document.getElementById("about-commit"); + commit.replaceChildren(); + const short = info.commit_short || info.commit || "unknown"; + if (info.commit_url) { + const link = document.createElement("a"); + link.href = info.commit_url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = short; + if (info.commit) link.title = info.commit; + commit.appendChild(link); + } else { + commit.textContent = short; + } + if (info.modified && short !== "unknown" && !version.endsWith("+dirty")) { + commit.appendChild(document.createTextNode(" (modified)")); + } + + document.getElementById("about-date").textContent = formatVersionDate(info.date); + const links = document.getElementById("about-links"); + links.replaceChildren(); + if (info.release_url) { + const notes = document.createElement("a"); + notes.href = info.release_url; + notes.target = "_blank"; + notes.rel = "noopener noreferrer"; + notes.textContent = "Release notes"; + links.appendChild(notes); + } +} + +function openAbout() { + closeSettingsMenu(); + const dialog = document.getElementById("about-dialog"); + if (!dialog.open) dialog.showModal(); +} + +async function loadVersion() { + try { + fillAbout(await fetchJSON("/v1/version")); + } catch { + return; + } +} + +document.getElementById("about-btn").addEventListener("click", openAbout); +document.getElementById("about-menu-btn").addEventListener("click", openAbout); +document.getElementById("about-dialog").addEventListener("click", (event) => { + if (event.target === event.currentTarget) event.currentTarget.close(); +}); + (async function init() { + loadVersion(); localStorage.removeItem("om_api_key"); try { const session = await fetchJSON("/v1/auth/session"); diff --git a/internal/api/web/index.html b/internal/api/web/index.html index 0ed57aa..f22abf8 100644 --- a/internal/api/web/index.html +++ b/internal/api/web/index.html @@ -29,6 +29,9 @@
Graph-native cloud security
+ @@ -130,6 +134,29 @@