diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..f2162d3d --- /dev/null +++ b/HANDOFF.md @@ -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-` 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. diff --git a/internal/modules/forgejo_version_exposure_test.go b/internal/modules/forgejo_version_exposure_test.go new file mode 100644 index 00000000..657f6fd3 --- /dev/null +++ b/internal/modules/forgejo_version_exposure_test.go @@ -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)) + } + }) +} diff --git a/internal/modules/immich_version_exposure_test.go b/internal/modules/immich_version_exposure_test.go new file mode 100644 index 00000000..47d7c907 --- /dev/null +++ b/internal/modules/immich_version_exposure_test.go @@ -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)) + } + }) +} diff --git a/internal/modules/jellyfin_version_exposure_test.go b/internal/modules/jellyfin_version_exposure_test.go new file mode 100644 index 00000000..9ed2ad9f --- /dev/null +++ b/internal/modules/jellyfin_version_exposure_test.go @@ -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)) + } + }) +} diff --git a/internal/modules/nextcloud_version_exposure_test.go b/internal/modules/nextcloud_version_exposure_test.go new file mode 100644 index 00000000..e7fa8b54 --- /dev/null +++ b/internal/modules/nextcloud_version_exposure_test.go @@ -0,0 +1,83 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runNextcloudModule(t *testing.T, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule("../../modules/recon/nextcloud-version-exposure.yaml") + if err != nil { + t.Fatalf("parse nextcloud 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 nextcloud module: %v", err) + } + return res +} + +func nextcloudExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestNextcloudVersionExposureModule(t *testing.T) { + // real body from cloud.nextcloud.com/status.php + nextcloudBody := `{"installed":true,"maintenance":false,"needsDbUpgrade":false,` + + `"version":"34.0.1.2","versionstring":"34.0.1","edition":"","productname":"Nextcloud",` + + `"extendedSupport":false}` + + t.Run("an exposed nextcloud status endpoint is flagged and versioned", func(t *testing.T) { + res := runNextcloudModule(t, 200, nextcloudBody) + if len(res.Findings) == 0 { + t.Fatal("expected a nextcloud finding") + } + if v := nextcloudExtract(res, "nextcloud_version"); v != "34.0.1" { + t.Errorf("nextcloud_version=%q, want 34.0.1", v) + } + }) + + t.Run("an owncloud status endpoint is not flagged as nextcloud", func(t *testing.T) { + // owncloud is the fork sharing the identical status.php schema but a + // different productname value + body := `{"installed":true,"maintenance":false,"needsDbUpgrade":false,` + + `"version":"10.11.0.10","versionstring":"10.11.0","edition":"community",` + + `"productname":"ownCloud"}` + if res := runNextcloudModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("an owncloud status should not match nextcloud, got %d findings", len(res.Findings)) + } + }) + + t.Run("a generic status json is not nextcloud", func(t *testing.T) { + body := `{"installed":true,"maintenance":false}` + if res := runNextcloudModule(t, 200, body); len(res.Findings) > 0 { + t.Errorf("a generic status json should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runNextcloudModule(t, 404, "File not found."); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/modules/recon/forgejo-version-exposure.yaml b/modules/recon/forgejo-version-exposure.yaml new file mode 100644 index 00000000..3c0c4bad --- /dev/null +++ b/modules/recon/forgejo-version-exposure.yaml @@ -0,0 +1,32 @@ +id: forgejo-version-exposure +info: + name: Forgejo Version Exposure + author: sif + severity: medium + description: Detects a Forgejo instance that discloses its version, distinguished from upstream Gitea by the +gitea- compatibility suffix Forgejo appends to its own version string, over the unauthenticated version api + tags: [forgejo, git, vcs, fingerprint, version, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/api/v1/version" + + matchers: + - type: regex + part: body + regex: + - '"version"\s*:\s*"[^"]*\+gitea-[0-9]' + + - type: status + status: + - 200 + + extractors: + - type: regex + name: forgejo_version + part: body + regex: + - '"version"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/immich-version-exposure.yaml b/modules/recon/immich-version-exposure.yaml new file mode 100644 index 00000000..86f1e2cd --- /dev/null +++ b/modules/recon/immich-version-exposure.yaml @@ -0,0 +1,51 @@ +id: immich-version-exposure +info: + name: Immich Version Exposure + author: sif + severity: medium + description: Detects an Immich photo server that discloses its version over the unauthenticated server version api, confirmed by the x-immich-session-token cors header immich alone advertises + tags: [immich, photos, media, fingerprint, version, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/api/server/version" + + matchers: + - type: word + part: header + words: + - "x-immich-session-token" + + - type: word + part: body + words: + - "\"major\"" + - "\"minor\"" + - "\"patch\"" + condition: and + + - type: status + status: + - 200 + + extractors: + - type: json + name: immich_major + part: body + json: + - "major" + + - type: json + name: immich_minor + part: body + json: + - "minor" + + - type: json + name: immich_patch + part: body + json: + - "patch" diff --git a/modules/recon/jellyfin-version-exposure.yaml b/modules/recon/jellyfin-version-exposure.yaml new file mode 100644 index 00000000..68a45045 --- /dev/null +++ b/modules/recon/jellyfin-version-exposure.yaml @@ -0,0 +1,37 @@ +id: jellyfin-version-exposure +info: + name: Jellyfin Version Exposure + author: sif + severity: medium + description: Detects a Jellyfin media server that discloses its version over the unauthenticated public system info endpoint + tags: [jellyfin, media, streaming, fingerprint, version, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/System/Info/Public" + + matchers: + - type: word + part: body + words: + - "\"ProductName\":\"Jellyfin Server\"" + + - type: word + part: body + words: + - "\"Version\"" + + - type: status + status: + - 200 + + extractors: + - type: regex + name: jellyfin_version + part: body + regex: + - '"Version"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/nextcloud-version-exposure.yaml b/modules/recon/nextcloud-version-exposure.yaml new file mode 100644 index 00000000..c9bc1f7f --- /dev/null +++ b/modules/recon/nextcloud-version-exposure.yaml @@ -0,0 +1,37 @@ +id: nextcloud-version-exposure +info: + name: Nextcloud Version Exposure + author: sif + severity: medium + description: Detects a Nextcloud instance that discloses its version over the unauthenticated status endpoint + tags: [nextcloud, storage, cloud, fingerprint, version, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/status.php" + + matchers: + - type: word + part: body + words: + - "\"productname\":\"Nextcloud\"" + + - type: word + part: body + words: + - "\"versionstring\"" + + - type: status + status: + - 200 + + extractors: + - type: regex + name: nextcloud_version + part: body + regex: + - '"versionstring"\s*:\s*"([^"]+)"' + group: 1