Skip to content
Open
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
128 changes: 128 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# research-fingerprints handoff

lane: new fingerprint modules (extends open PR #272, cockpit + gitea). untouched
in-flight worktrees and PRs #269-280 confirmed via `git worktree list` grep and
`gh pr diff` grep across every open PR's added files for candidate names.

added 4 modules in modules/recon/, following the existing woodpecker/gocd
version-exposure schema (medium severity, tags include fingerprint/version/recon):

## nextcloud-version-exposure.yaml
- probe: GET /status.php (nextcloud's own always-public status endpoint, no auth)
- marker: `"productname":"Nextcloud"` anded with presence of `"versionstring"`
- why unique: ownCloud is the sibling fork sharing the exact same status.php json
schema (installed/maintenance/needsDbUpgrade/version/versionstring/edition/
productname) but ships `"productname":"ownCloud"`. keying on the productname
value, not just the schema shape, is what separates the two.
- version regex: `"versionstring"\s*:\s*"([^"]+)"` group 1
- real samples pulled live:
- https://cloud.nextcloud.com/status.php -> versionstring "34.0.1"
- https://demo1.nextcloud.com/status.php -> versionstring "34.0.0"
- dupe check: grep across modules/ and all worktrees/open PRs, no hit before this change
- residual risk: none identified for the productname anchor; status.php is
unconditionally public by nextcloud design (used for update checkers), so this
is correctly a fingerprint, not an auth-bypass, hence medium not high

## jellyfin-version-exposure.yaml
- probe: GET /System/Info/Public (jellyfin's own unauthenticated public info endpoint)
- marker: `"ProductName":"Jellyfin Server"` anded with presence of `"Version"`
- why unique: Emby is the ancestor jellyfin forked from and keeps the same
/System/Info/Public path and json shape (LocalAddress/ServerName/Version/
ProductName/OperatingSystem/Id), but reports `"ProductName":"Emby Server"`.
the productname value is the load-bearing anchor, not the endpoint shape.
- version regex: `"Version"\s*:\s*"([^"]+)"` group 1
- real sample pulled live: https://demo.jellyfin.org/stable/System/Info/Public ->
`{"LocalAddress":"...","ServerName":"Stable Demo","Version":"10.11.11",
"ProductName":"Jellyfin Server",...}`
- dupe check: clean, no hit
- residual risk: I could not reach a live Emby instance to pull a real negative
sample (both public demo hosts 404'd during this session); the Emby json shape
used in the trap test is reconstructed from memory of Emby's public api docs,
not independently re-verified live. if Emby ever renames the field or drops
ProductName the trap test would need updating, but the detector's own anchor
(positive match on the exact Jellyfin string) is unaffected either way.

## forgejo-version-exposure.yaml
- probe: GET /api/v1/version (same path forgejo inherited from gitea, the fork it's based on)
- marker: regex `"version"\s*:\s*"[^"]*\+gitea-[0-9]` on the body
- why unique: forgejo appends `+gitea-<compat-version>` to its own version string
to advertise gitea api compatibility; vanilla gitea does not do this. verified
against three distinct real gitea-family instances in this session:
- codeberg.org (forgejo, the reference forgejo host) -> "15.0.0-156-02d7aaa8+gitea-1.22.0"
- git.private.coffee (forgejo) -> "15.0.3+gitea-1.22.0"
- gitea.com (official gitea, dev branch) -> "1.27.0+dev-521-g840e7c6a54" (no match, +dev not +gitea)
- opendev.org (official gitea, stable release) -> "v1.26.2" (no match, no suffix at all)
three real samples across two forks and two gitea build styles (dev vs release)
confirm the +gitea- suffix is forgejo-specific, not a general gitea versioning artifact.
- version regex (full string, includes the +gitea- suffix as evidence of the
compat pin): `"version"\s*:\s*"([^"]+)"` group 1
- dupe check: clean, no hit (PR #272 fingerprinted gitea by favicon hash, not
forgejo, and did not touch this endpoint)
- residual risk: if forgejo ever drops the +gitea- compat suffix from its own
version string in a future release this detector would false-negative on new
forgejo but would not false-positive on gitea; fail-closed direction is safe

## immich-version-exposure.yaml
- probe: GET /api/server/version (immich's own unauthenticated version endpoint)
- marker: response header `access-control-allow-headers` containing
`x-immich-session-token` (immich's custom auth header, advertised in its own
cors preflight allow-list on every api response including this one), anded
with body containing all of "major"/"minor"/"patch"
- why unique: the version body itself is generic
(`{"major":3,"minor":0,"patch":1,"prerelease":null}`) and could be any small
rest api, so it is not safe to key on alone; the cors header is immich-branded
and present on every response from the real server, giving a structural anchor
instead of a bare json-shape guess
- extraction: three separate json extractors (immich_major/immich_minor/
immich_patch reading json paths major/minor/patch) since the engine's regex
extractor takes a single capture group and can't concatenate three fields into
one dotted version string; gjson multipath was considered but three named
extractors match the multi-extractor precedent already in the tree (see
modules/recon/docker-compose-exposure.yaml, harbor-api-exposure.yaml)
- real sample pulled live: https://demo.immich.app/api/server/version ->
`{"major":3,"minor":0,"patch":1,"prerelease":null}` with header
`access-control-allow-headers: x-immich-session-token, x-api-key,
Authorization, Content-Type`
- dupe check: clean, no hit
- residual risk: this is a 4th module beyond the requested 2-3; cut it if the
lane wants to stay tighter. the header marker is present unconditionally
(it's set on cors preflight regardless of auth state), so this is correctly a
fingerprint (medium), not an exposure claim

## tests
each module ships a dedicated test in internal/modules/ following the existing
*_exposure_test convention (package modules_test, a per-module run helper over
httptest + ParseYAMLModule + ExecuteHTTPModule, positive + negative subtests).
every case uses the exact real sample bytes captured live during research:

- nextcloud_version_exposure_test.go: real cloud.nextcloud.com body fires and
extracts 34.0.1; ownCloud body (same schema, productname "ownCloud"), a
generic status json, and a 404 do not fire
- jellyfin_version_exposure_test.go: real demo.jellyfin.org body fires and
extracts 10.11.11; Emby body (ProductName "Emby Server"), a generic info
json, and a 404 do not fire
- forgejo_version_exposure_test.go: real codeberg.org and git.private.coffee
bodies fire and extract the full +gitea- version; real gitea.com dev build
and opendev.org release (no +gitea- suffix) and a 404 do not fire
- immich_version_exposure_test.go: real demo.immich.app header+body fires and
extracts major 3 / patch 1; a version body without the x-immich-session-token
cors header, the header without a version body, and a 404 do not fire

## verify
```
export GO111MODULE=on GOTOOLCHAIN=local
go build ./... && go vet ./... && go test -count=1 ./... # all green
~/go/bin/golangci-lint run # 0 issues
```
the four new test funcs run 18 subtests total, all pass; full-repo test suite,
go vet, and golangci-lint all clean.

## not shipped
uptime-kuma, authelia, authentik, paperless-ngx, home assistant were scoped but
dropped: none exposes a clean unauthenticated version-bearing endpoint I could
verify live in this session (uptime-kuma has no public version api; authelia's
config/health endpoints don't carry version; paperless-ngx's api requires auth
for the endpoints that carry version; home assistant's manifest.json omits
version and its api requires a token). shipping any of those would mean
guessing at an endpoint instead of proving one, so left for a future pass with
more time to stand up local instances.
90 changes: 90 additions & 0 deletions internal/modules/forgejo_version_exposure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package modules_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/vmfunc/sif/internal/modules"
)

func runForgejoModule(t *testing.T, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/forgejo-version-exposure.yaml")
if err != nil {
t.Fatalf("parse forgejo module: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()

res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute forgejo module: %v", err)
}
return res
}

func forgejoExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}

func TestForgejoVersionExposureModule(t *testing.T) {
t.Run("an exposed forgejo version endpoint is flagged and versioned", func(t *testing.T) {
// real body from codeberg.org/api/v1/version
body := `{"version":"15.0.0-156-02d7aaa8+gitea-1.22.0"}`
res := runForgejoModule(t, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a forgejo finding")
}
if v := forgejoExtract(res, "forgejo_version"); v != "15.0.0-156-02d7aaa8+gitea-1.22.0" {
t.Errorf("forgejo_version=%q, want 15.0.0-156-02d7aaa8+gitea-1.22.0", v)
}
})

t.Run("a second forgejo instance is flagged", func(t *testing.T) {
// real body from git.private.coffee/api/v1/version
body := `{"version":"15.0.3+gitea-1.22.0"}`
res := runForgejoModule(t, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a forgejo finding")
}
if v := forgejoExtract(res, "forgejo_version"); v != "15.0.3+gitea-1.22.0" {
t.Errorf("forgejo_version=%q, want 15.0.3+gitea-1.22.0", v)
}
})

t.Run("a vanilla gitea dev build is not flagged as forgejo", func(t *testing.T) {
// real body from gitea.com/api/v1/version, no +gitea- compat suffix
body := `{"version":"1.27.0+dev-521-g840e7c6a54"}`
if res := runForgejoModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a vanilla gitea dev build should not match forgejo, got %d findings", len(res.Findings))
}
})

t.Run("a vanilla gitea release is not flagged as forgejo", func(t *testing.T) {
// real body from opendev.org/api/v1/version, no suffix at all
body := `{"version":"v1.26.2"}`
if res := runForgejoModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a vanilla gitea release should not match forgejo, got %d findings", len(res.Findings))
}
})

t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runForgejoModule(t, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
88 changes: 88 additions & 0 deletions internal/modules/immich_version_exposure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package modules_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/vmfunc/sif/internal/modules"
)

func runImmichModule(t *testing.T, status int, header, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/immich-version-exposure.yaml")
if err != nil {
t.Fatalf("parse immich module: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if header != "" {
w.Header().Set("Access-Control-Allow-Headers", header)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()

res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute immich module: %v", err)
}
return res
}

func immichExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}

func TestImmichVersionExposureModule(t *testing.T) {
// real values from demo.immich.app/api/server/version
immichHeader := "x-immich-session-token, x-api-key, Authorization, Content-Type"
immichBody := `{"major":3,"minor":0,"patch":1,"prerelease":null}`

t.Run("an exposed immich version endpoint is flagged and versioned", func(t *testing.T) {
res := runImmichModule(t, 200, immichHeader, immichBody)
if len(res.Findings) == 0 {
t.Fatal("expected an immich finding")
}
if v := immichExtract(res, "immich_major"); v != "3" {
t.Errorf("immich_major=%q, want 3", v)
}
if v := immichExtract(res, "immich_minor"); v != "0" {
t.Errorf("immich_minor=%q, want 0", v)
}
if v := immichExtract(res, "immich_patch"); v != "1" {
t.Errorf("immich_patch=%q, want 1", v)
}
})

t.Run("a generic version body without the immich cors header is not flagged", func(t *testing.T) {
// the major/minor/patch body alone is generic, only the immich session
// token in the cors allow-list makes it identifiable
header := "Authorization, Content-Type"
if res := runImmichModule(t, 200, header, immichBody); len(res.Findings) > 0 {
t.Errorf("a generic version body should not match immich, got %d findings", len(res.Findings))
}
})

t.Run("the immich header without a version body is not flagged", func(t *testing.T) {
if res := runImmichModule(t, 200, immichHeader, `{"status":"ok"}`); len(res.Findings) > 0 {
t.Errorf("the header alone should not match, got %d findings", len(res.Findings))
}
})

t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runImmichModule(t, 404, immichHeader, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
83 changes: 83 additions & 0 deletions internal/modules/jellyfin_version_exposure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package modules_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/vmfunc/sif/internal/modules"
)

func runJellyfinModule(t *testing.T, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/jellyfin-version-exposure.yaml")
if err != nil {
t.Fatalf("parse jellyfin module: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()

res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute jellyfin module: %v", err)
}
return res
}

func jellyfinExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}

func TestJellyfinVersionExposureModule(t *testing.T) {
// real body from demo.jellyfin.org/stable/System/Info/Public
jellyfinBody := `{"LocalAddress":"http://172.17.0.2:8096/stable","ServerName":"Stable Demo",` +
`"Version":"10.11.11","ProductName":"Jellyfin Server","OperatingSystem":"",` +
`"Id":"f0b3381645f04afb9a0e392e74b6a1b0","StartupWizardCompleted":true}`

t.Run("an exposed jellyfin public info endpoint is flagged and versioned", func(t *testing.T) {
res := runJellyfinModule(t, 200, jellyfinBody)
if len(res.Findings) == 0 {
t.Fatal("expected a jellyfin finding")
}
if v := jellyfinExtract(res, "jellyfin_version"); v != "10.11.11" {
t.Errorf("jellyfin_version=%q, want 10.11.11", v)
}
})

t.Run("an emby public info endpoint is not flagged as jellyfin", func(t *testing.T) {
// emby is the ancestor jellyfin forked from, sharing the same path and
// json shape but reporting a different productname value
body := `{"LocalAddress":"http://172.17.0.2:8096","ServerName":"demo",` +
`"Version":"4.8.0.0","ProductName":"Emby Server","OperatingSystem":"linux",` +
`"Id":"abc"}`
if res := runJellyfinModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("an emby public info should not match jellyfin, got %d findings", len(res.Findings))
}
})

t.Run("a generic info json is not jellyfin", func(t *testing.T) {
body := `{"ServerName":"demo","Version":"1.0.0"}`
if res := runJellyfinModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic info json should not match, got %d findings", len(res.Findings))
}
})

t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runJellyfinModule(t, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
Loading
Loading