Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/publish-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:**

Expand Down
3 changes: 2 additions & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions internal/api/console_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
7 changes: 7 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
35 changes: 35 additions & 0 deletions internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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},
Expand Down
83 changes: 83 additions & 0 deletions internal/api/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
27 changes: 27 additions & 0 deletions internal/api/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
<h1 class="brand-name">OpenSource<span class="brand-accent">OM</span></h1>
<p class="tagline">Graph-native cloud security</p>
</div>
<button id="about-btn" class="version-chip hidden" type="button" aria-haspopup="dialog" aria-controls="about-dialog">
<span id="version-label"></span>
</button>
</div>
<div id="account" class="account hidden">
<span id="account-label"></span>
Expand All @@ -41,6 +44,7 @@ <h1 class="brand-name">OpenSource<span class="brand-accent">OM</span></h1>
<button id="token-btn" type="button">Create API token</button>
<button id="security-btn" type="button">Security</button>
<button id="admins-btn" type="button">Admins</button>
<button id="about-menu-btn" type="button">About</button>
<div class="settings-sep" role="separator"></div>
<button id="logout-btn" type="button">Log out</button>
</div>
Expand Down Expand Up @@ -130,6 +134,29 @@ <h2>Graph Explorer</h2>
</section>
</main>

<dialog id="about-dialog" class="about-dialog" aria-labelledby="about-title">
<form method="dialog">
<p class="panel-kicker">About</p>
<h2 id="about-title">OpenSource<span class="brand-accent">OM</span> Core</h2>
<dl>
<div>
<dt>Version</dt>
<dd id="about-version">Unknown</dd>
</div>
<div>
<dt>Commit</dt>
<dd id="about-commit">Unknown</dd>
</div>
<div>
<dt>Date</dt>
<dd id="about-date">Unknown</dd>
</div>
</dl>
<p id="about-links" class="about-links"></p>
<button class="btn btn-secondary" type="submit">Close</button>
</form>
</dialog>

<script src="/ui/app.js"></script>
</body>
</html>
Loading
Loading