├── logo.png ├── .img ├── docker_uuid_gen.png └── docker_openssl_generate.png ├── .dockerignore ├── cmd ├── admin │ ├── static │ │ ├── img │ │ │ ├── logo.png │ │ │ ├── brand.png │ │ │ ├── favicon.png │ │ │ └── spinner.svg │ │ ├── fonts │ │ │ ├── osquery.eot │ │ │ ├── osquery.ttf │ │ │ ├── osquery.woff │ │ │ └── osquery.woff2 │ │ ├── js │ │ │ ├── tables.js │ │ │ ├── login.js │ │ │ ├── stats.js │ │ │ ├── settings.js │ │ │ ├── environments.js │ │ │ ├── functions.js │ │ │ ├── enrolls.js │ │ │ └── profile.js │ │ └── css │ │ │ └── osquery.css │ ├── oauth.go │ ├── types-server.go │ ├── oidc.go │ ├── handlers │ │ ├── json-tags.go │ │ ├── json-stats.go │ │ └── json-audit.go │ ├── saml.go │ ├── utils.go │ ├── templates │ │ ├── components │ │ │ ├── page-header.html │ │ │ ├── page-head-offline.html │ │ │ └── page-js-offline.html │ │ └── login.html │ └── settings.go ├── cli │ ├── api-audit.go │ ├── utils.go │ └── api-login.go ├── api │ ├── utils.go │ ├── handlers │ │ ├── audit.go │ │ ├── utils.go │ │ ├── login.go │ │ ├── get.go │ │ └── platforms.go │ ├── settings.go │ └── auth.go └── tls │ ├── handlers │ ├── get.go │ ├── get_test.go │ └── metrics.go │ ├── settings.go │ └── utils.go ├── osctrl-dev.code-workspace ├── deploy ├── config │ ├── jwt.json │ ├── redis.json │ ├── service.json │ ├── db.json │ ├── systemd.service │ └── tls.yaml ├── cicd │ ├── deb │ │ ├── deb-conffiles │ │ ├── pre-remove.sh │ │ ├── post-install.sh │ │ └── pre-install.sh │ └── docker │ │ ├── Dockerfile-osctrl-api │ │ ├── Dockerfile-osctrl-tls │ │ ├── Dockerfile-osctrl-cli │ │ └── Dockerfile-osctrl-admin ├── docker │ ├── conf │ │ ├── tls │ │ │ └── openssl.cnf.example │ │ ├── dev │ │ │ └── air │ │ │ │ ├── .air-osctrl-cli.toml │ │ │ │ ├── .air-osctrl-api.toml │ │ │ │ ├── .air-osctrl-tls.toml │ │ │ │ └── .air-osctrl-admin.toml │ │ ├── osquery │ │ │ └── entrypoint.sh │ │ ├── cli │ │ │ └── entrypoint.sh │ │ └── nginx │ │ │ ├── osctrl.conf │ │ │ └── nginx.conf │ └── dockerfiles │ │ ├── Dockerfile-nginx │ │ ├── Dockerfile-osquery │ │ ├── Dockerfile-dev-api │ │ ├── Dockerfile-dev-tls │ │ ├── Dockerfile-dev-admin │ │ └── Dockerfile-dev-cli ├── osquery │ ├── decorators.json │ └── packs │ │ └── osctrl-windows-application-security.conf ├── redis │ └── redis.conf └── nginx │ ├── ssl.conf │ └── nginx.conf ├── tools ├── bruno │ ├── collection.bru │ ├── bruno.json │ ├── nodes │ │ ├── get-nodes.bru │ │ ├── get -nodes--env--all.bru │ │ ├── get -nodes--env--active.bru │ │ ├── get -nodes--env--inactive.bru │ │ ├── get -nodes-node--identifier-.bru │ │ └── post -nodes--env--delete.bru │ ├── tags │ │ ├── get -tags.bru │ │ └── get -tags--env-.bru │ ├── users │ │ ├── get -users.bru │ │ └── get -users--username-.bru │ ├── settings │ │ ├── get -settings.bru │ │ ├── get -settings--service-.bru │ │ ├── get -settings--service--json.bru │ │ ├── get -settings--service---env-.bru │ │ └── get -settings--service--json--env-.bru │ ├── platforms │ │ ├── get -platforms.bru │ │ └── get -platforms--env-.bru │ ├── environments │ │ ├── get -environments.bru │ │ ├── get -environments--env-.bru │ │ ├── get -environments--env--enroll--target-.bru │ │ ├── get -environments--env--remove--target-.bru │ │ ├── post -environments--env--enroll--target-.bru │ │ ├── post -environments--env--remove--action-.bru │ │ └── post -environments--env--enroll--action-.bru │ └── queries │ │ ├── post -carves--env-.bru │ │ ├── get -carves--env-.bru │ │ ├── get -queries--env-.bru │ │ ├── get -all-queries--env-.bru │ │ ├── get -carves--env---name-.bru │ │ ├── get -queries--env---name-.bru │ │ ├── get -queries--env--results--name-.bru │ │ ├── post - create queries.bru │ │ └── post -queries--env-.bru ├── .gitignore ├── update-modules.sh └── fake_logging.py ├── .flake8 ├── pkg ├── version │ ├── version.go │ └── version_test.go ├── cache │ ├── utils.go │ ├── cache.go │ └── metrics.go ├── queries │ ├── utils.go │ └── saved.go ├── users │ ├── utils.go │ └── utils_test.go ├── logging │ ├── debughttp.go │ ├── utils.go │ ├── dispatch.go │ ├── stdout.go │ ├── none.go │ ├── s3.go │ ├── file.go │ └── kinesis.go ├── carves │ ├── db.go │ └── utils.go ├── environments │ ├── decorators.go │ ├── env-cache.go │ ├── flags_test.go │ ├── oneliners_test.go │ └── util.go ├── utils │ ├── string-utils_test.go │ ├── utils_test.go │ ├── string-utils.go │ ├── utils.go │ └── time-utils.go ├── auditlog │ ├── utils_test.go │ └── utils.go ├── tags │ └── utils_test.go └── nodes │ ├── models.go │ ├── node-cache.go │ └── utils.go ├── CHANGELOG.md ├── .env.example ├── .golangci.yml ├── .pre-commit-config.yaml ├── .gitignore ├── LICENSE ├── .github ├── workflows │ ├── test-release.yml │ ├── golangci-lint.yml │ ├── build_and_test_pr.yml │ └── release.yml └── actions │ ├── build │ └── binaries │ │ └── action.yml │ ├── test │ └── binaries │ │ └── action.yml │ └── tagged_release │ ├── docker │ └── codesign │ │ └── action.yml │ └── github │ └── action.yml ├── CONTRIBUTING.md └── README.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/logo.png -------------------------------------------------------------------------------- /.img/docker_uuid_gen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/.img/docker_uuid_gen.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.md 2 | LICENSE 3 | Vagrantfile 4 | *.png 5 | tmp/ 6 | bin/ 7 | docker-compose-dev.yml 8 | -------------------------------------------------------------------------------- /cmd/admin/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/cmd/admin/static/img/logo.png -------------------------------------------------------------------------------- /.img/docker_openssl_generate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/.img/docker_openssl_generate.png -------------------------------------------------------------------------------- /cmd/admin/static/img/brand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/cmd/admin/static/img/brand.png -------------------------------------------------------------------------------- /cmd/admin/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/cmd/admin/static/img/favicon.png -------------------------------------------------------------------------------- /cmd/admin/static/fonts/osquery.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/cmd/admin/static/fonts/osquery.eot -------------------------------------------------------------------------------- /cmd/admin/static/fonts/osquery.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/cmd/admin/static/fonts/osquery.ttf -------------------------------------------------------------------------------- /osctrl-dev.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {}, 8 | } -------------------------------------------------------------------------------- /cmd/admin/static/fonts/osquery.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/cmd/admin/static/fonts/osquery.woff -------------------------------------------------------------------------------- /cmd/admin/static/fonts/osquery.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmpsec/osctrl/HEAD/cmd/admin/static/fonts/osquery.woff2 -------------------------------------------------------------------------------- /deploy/config/jwt.json: -------------------------------------------------------------------------------- 1 | { 2 | "jwt": { 3 | "jwtSecret": "_JWT_SECRET", 4 | "hoursToExpire": 3 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /deploy/cicd/deb/deb-conffiles: -------------------------------------------------------------------------------- 1 | /opt/osctrl/config/{{ OSCTRL_COMPONENT }}.json 2 | /etc/systemd/system/osctrl-{{ OSCTRL_COMPONENT }}.service 3 | -------------------------------------------------------------------------------- /tools/bruno/collection.bru: -------------------------------------------------------------------------------- 1 | vars:pre-request { 2 | env: 1a026f60-edc1-4189-ab70-be99d541a473 3 | baseUrl: http://localhost:9002 4 | } 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | __pycache__, 5 | docs/source/conf.py, 6 | old, 7 | build, 8 | dist 9 | max-line-length = 100 10 | -------------------------------------------------------------------------------- /deploy/config/redis.json: -------------------------------------------------------------------------------- 1 | { 2 | "redis": { 3 | "host": "_REDIS_HOST", 4 | "port": "_REDIS_PORT", 5 | "password": "_REDIS_PASSWORD", 6 | "db": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tools/bruno/bruno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "osctrl-api", 4 | "type": "collection", 5 | "ignore": [ 6 | "node_modules", 7 | ".git" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tools/bruno/nodes/get-nodes.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get-nodes 3 | type: http 4 | seq: 6 5 | } 6 | 7 | get { 8 | url: {{baseUrl}} /api/v1/nodes/{{env}}/all 9 | body: none 10 | auth: none 11 | } 12 | -------------------------------------------------------------------------------- /deploy/cicd/deb/pre-remove.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ################### Remove osctrl user and group ################### 6 | if id -u osctrl &>/dev/null 7 | then 8 | deluser --remove-home osctrl 9 | fi 10 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const ( 4 | // OsctrlVersion to have the version for all components 5 | OsctrlVersion = "0.4.8" 6 | // OsqueryVersion to have the version for osquery defined 7 | OsqueryVersion = "5.20.0" 8 | ) 9 | -------------------------------------------------------------------------------- /tools/bruno/tags/get -tags.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -tags 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/tags 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | auth:bearer { 14 | token: {{token}} 15 | } 16 | -------------------------------------------------------------------------------- /tools/bruno/users/get -users.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -users 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/users 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | auth:bearer { 14 | token: {{token}} 15 | } 16 | -------------------------------------------------------------------------------- /tools/bruno/settings/get -settings.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -settings 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/settings 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | auth:bearer { 14 | token: {{token}} 15 | } 16 | -------------------------------------------------------------------------------- /deploy/config/service.json: -------------------------------------------------------------------------------- 1 | { 2 | "_SERVICE_NAME": { 3 | "listener": "_LISTENER", 4 | "port": "_SERVICE_PORT", 5 | "host": "_SERVICE_HOST", 6 | "auth": "_SERVICE_AUTH", 7 | "logger": "_SERVICE_LOGGING", 8 | "carver": "_SERVICE_CARVER" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tools/bruno/platforms/get -platforms.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -platforms 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/platforms 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | auth:bearer { 14 | token: {{token}} 15 | } 16 | -------------------------------------------------------------------------------- /tools/bruno/environments/get -environments.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -environments 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/environments 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | auth:bearer { 14 | token: {{token}} 15 | } 16 | -------------------------------------------------------------------------------- /tools/bruno/queries/post -carves--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post -carves--env- 3 | type: http 4 | seq: 7 5 | } 6 | 7 | post { 8 | url: https://osctrl.net/api/v1/carves/{env} 9 | body: json 10 | auth: bearer 11 | } 12 | 13 | auth:bearer { 14 | token: {{token}} 15 | } 16 | -------------------------------------------------------------------------------- /pkg/cache/utils.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jmpsec/osctrl/pkg/config" 7 | ) 8 | 9 | // PrepareAddr to generate redis connection string 10 | func PrepareAddr(cfg config.YAMLConfigurationRedis) string { 11 | return fmt.Sprintf("%s:%s", cfg.Host, cfg.Port) 12 | } 13 | -------------------------------------------------------------------------------- /tools/bruno/tags/get -tags--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -tags--env- 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/tags/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/queries/get -carves--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -carves--env- 3 | type: http 4 | seq: 6 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/carves/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/queries/get -queries--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -queries--env- 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/queries/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/nodes/get -nodes--env--all.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -nodes--env--all 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/nodes/{env}/all 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/platforms/get -platforms--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -platforms--env- 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/platforms/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /pkg/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestOsqueryVersion(t *testing.T) { 10 | assert.Equal(t, "5.20.0", OsqueryVersion) 11 | } 12 | 13 | func TestOsctrlVersion(t *testing.T) { 14 | assert.Equal(t, "0.4.8", OsctrlVersion) 15 | } 16 | -------------------------------------------------------------------------------- /tools/bruno/nodes/get -nodes--env--active.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -nodes--env--active 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/nodes/{env}/active 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/queries/get -all-queries--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -all-queries--env- 3 | type: http 4 | seq: 5 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/all-queries/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/users/get -users--username-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -users--username- 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/users/{username} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | username: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/environments/get -environments--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -environments--env- 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/environments/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/nodes/get -nodes--env--inactive.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -nodes--env--inactive 3 | type: http 4 | seq: 3 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/nodes/{env}/inactive 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/settings/get -settings--service-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -settings--service- 3 | type: http 4 | seq: 2 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/settings/{service} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | service: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/queries/get -carves--env---name-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -carves--env---name- 3 | type: http 4 | seq: 8 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/carves/{env}/{name} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | name: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /tools/bruno/nodes/get -nodes-node--identifier-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -nodes-node--identifier- 3 | type: http 4 | seq: 4 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/nodes/node/{identifier} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | identifier: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/queries/get -queries--env---name-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -queries--env---name- 3 | type: http 4 | seq: 3 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/queries/{env}/{name} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | name: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /tools/bruno/settings/get -settings--service--json.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -settings--service--json 3 | type: http 4 | seq: 4 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/settings/{service}/json 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | service: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/queries/get -queries--env--results--name-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -queries--env--results--name- 3 | type: http 4 | seq: 4 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/queries/{env}/results/{name} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | name: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/settings/get -settings--service---env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -settings--service---env- 3 | type: http 4 | seq: 3 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/settings/{service}/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | service: 15 | env: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /cmd/admin/oauth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // JSONConfigurationOAuth to keep all OAuth details for auth 4 | type JSONConfigurationOAuth struct { 5 | ClientID string `json:"clientid"` 6 | ClientSecret string `json:"clientsecret"` 7 | RedirectURL string `json:"redirecturl"` 8 | Scopes []string `json:"scopes"` 9 | EndpointURL string `json:"endpointurl"` 10 | } 11 | -------------------------------------------------------------------------------- /deploy/config/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "db": { 3 | "type": "postgres", 4 | "host": "_DB_HOST", 5 | "port": "_DB_PORT", 6 | "name": "_DB_NAME", 7 | "username": "_DB_USERNAME", 8 | "password": "_DB_PASSWORD", 9 | "sslmode": "disable", 10 | "maxIdleConns": 20, 11 | "maxOpenConns": 100, 12 | "connMaxLifetime": 30, 13 | "connRetry": 10 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tools/bruno/queries/post - create queries.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post - create queries 3 | type: http 4 | seq: 9 5 | } 6 | 7 | post { 8 | url: {{baseUrl}}/api/v1/queries/{{env}} 9 | body: json 10 | auth: none 11 | } 12 | 13 | body:json { 14 | { 15 | "environment_list": ["dev"], 16 | "query": "SELECT * FROM system_info;", 17 | "exp_hours": 1 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tools/bruno/settings/get -settings--service--json--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -settings--service--json--env- 3 | type: http 4 | seq: 5 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/settings/{service}/json/{env} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | service: 15 | env: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /tools/bruno/queries/post -queries--env-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post -queries--env- 3 | type: http 4 | seq: 2 5 | } 6 | 7 | post { 8 | url: https://osctrl.net/api/v1/queries/{env} 9 | body: json 10 | auth: bearer 11 | } 12 | 13 | auth:bearer { 14 | token: {{token}} 15 | } 16 | 17 | body:json { 18 | { 19 | "uuid": "", 20 | "query": "", 21 | "hidden": "" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # osctrl Changelog 2 | 3 | ## 🔖 Release [0.4.8](https://github.com/jmpsec/osctrl/releases/tag/v0.4.8) 4 | 5 | ### 🚨 Breaking Changes 6 | 7 | ### ✨ New Features 8 | 9 | ### 🛠 Improvements and ⚡️ Performance 10 | 11 | ### 🐛 Bug Fixes 12 | 13 | ### 🔒 Security 14 | 15 | ### ♻️ Refactoring and 🏗 Chores 16 | 17 | ### 📚 Documentation 18 | 19 | ### 📦 Build 20 | 21 | ### 🚦 Test 22 | -------------------------------------------------------------------------------- /tools/bruno/environments/get -environments--env--enroll--target-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -environments--env--enroll--target- 3 | type: http 4 | seq: 3 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/environments/{env}/enroll/{target} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | target: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /tools/bruno/environments/get -environments--env--remove--target-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: get -environments--env--remove--target- 3 | type: http 4 | seq: 6 5 | } 6 | 7 | get { 8 | url: https://osctrl.net/api/v1/environments/{env}/remove/{target} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | target: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /tools/bruno/environments/post -environments--env--enroll--target-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post -environments--env--enroll--target- 3 | type: http 4 | seq: 4 5 | } 6 | 7 | post { 8 | url: https://osctrl.net/api/v1/environments/{env}/enroll/{target} 9 | body: none 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | target: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /tools/bruno/environments/post -environments--env--remove--action-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post -environments--env--remove--action- 3 | type: http 4 | seq: 7 5 | } 6 | 7 | post { 8 | url: https://osctrl.net/api/v1/environments/{env}/remove/{action} 9 | body: json 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | action: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | -------------------------------------------------------------------------------- /tools/bruno/nodes/post -nodes--env--delete.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post -nodes--env--delete 3 | type: http 4 | seq: 5 5 | } 6 | 7 | post { 8 | url: https://osctrl.net/api/v1/nodes/{env}/delete 9 | body: json 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | } 16 | 17 | auth:bearer { 18 | token: {{token}} 19 | } 20 | 21 | body:json { 22 | { 23 | "uuid": "" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /deploy/docker/conf/tls/openssl.cnf.example: -------------------------------------------------------------------------------- 1 | [req] 2 | default_bits = 4096 3 | prompt = no 4 | default_md = sha256 5 | x509_extensions = v3_req 6 | distinguished_name = dn 7 | 8 | [dn] 9 | emailAddress = dev@${ENV:BASE_DOMAIN} 10 | CN = osctrl.${ENV:BASE_DOMAIN} 11 | 12 | [v3_req] 13 | subjectAltName = @alt_names 14 | 15 | [alt_names] 16 | DNS.1 = osctrl.${ENV:BASE_DOMAIN} 17 | DNS.2 = osctrl-nginx 18 | DNS.3 = nginx 19 | DNS.4 = osctrl 20 | -------------------------------------------------------------------------------- /deploy/docker/dockerfiles/Dockerfile-nginx: -------------------------------------------------------------------------------- 1 | ARG NGINX_VERSION 2 | FROM nginx:${NGINX_VERSION} 3 | 4 | ### Copy NGINX configs ### 5 | COPY deploy/docker/conf/nginx/nginx.conf /etc/nginx/nginx.conf 6 | COPY deploy/docker/conf/nginx/osctrl.conf /etc/nginx/conf.d/osctrl.conf 7 | 8 | ### Copy TLS public cert and private key ### 9 | COPY deploy/docker/conf/tls/*.crt /etc/ssl/certs/osctrl.crt 10 | COPY deploy/docker/conf/tls/*.key /etc/ssl/private/osctrl.key 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OSCTRL_VERSION=0.4.8 2 | OSQUERY_VERSION=5.20.0 3 | NGINX_VERSION=1.21.6-alpine 4 | POSTGRES_VERSION=13.5-alpine 5 | POSTGRES_DB_NAME=osctrl 6 | POSTGRES_DB_USERNAME=osctrl 7 | POSTGRES_DB_PASSWORD=osctrl 8 | REDIS_VERSION=6.2.6-alpine3.15 9 | JWT_SECRET=0000000000000000000000000000000000000000000000000000000000000000 10 | SESSION_KEY=sessionkey 11 | OSCTRL_USER=admin 12 | OSCTRL_PASS=Changeme123! 13 | GOLANG_VERSION=1.25.4 14 | OSCTRL_TLS_LOGGER=db 15 | -------------------------------------------------------------------------------- /pkg/queries/utils.go: -------------------------------------------------------------------------------- 1 | package queries 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jmpsec/osctrl/pkg/utils" 7 | ) 8 | 9 | // Helper to generate a random query name 10 | func GenQueryName() string { 11 | return "query_" + utils.RandomForNames() 12 | } 13 | 14 | // Helper to generate the time.Time for the expiration of a query or carve based on hours 15 | func QueryExpiration(exp int) time.Time { 16 | return time.Now().Add(time.Duration(exp) * time.Hour) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/admin/types-server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // JSONAdminUsers to keep all admin users for auth JSON 4 | type JSONAdminUsers struct { 5 | Username string `json:"username"` 6 | Password string `json:"password"` 7 | Fullname string `json:"fullname"` 8 | Admin bool `json:"admin"` 9 | } 10 | 11 | // JWTData to return all the fields from a JWT token 12 | type JWTData struct { 13 | Subject string 14 | Email string 15 | Display string 16 | Username string 17 | } 18 | -------------------------------------------------------------------------------- /tools/.gitignore: -------------------------------------------------------------------------------- 1 | # Files used by packages 2 | osquery.flags 3 | osquery.secret 4 | 5 | # Generated packages 6 | *.pkg 7 | *.deb 8 | 9 | # Ignore temporary directories matching the date format YYYYMMDD-HHMMSS and YYYYMMDD-HHMMSS-scripts 10 | [0-9][0-9][0-9][0-9][0-1][0-9][0-3][0-9]-[0-2][0-9][0-5][0-9][0-5][0-9]/ 11 | [0-9][0-9][0-9][0-9][0-1][0-9][0-3][0-9]-[0-2][0-9][0-5][0-9][0-5][0-9]-scripts/ 12 | 13 | # Fake news files 14 | fake_news_go/fake_news 15 | fake_news_go/state.json 16 | fake_news_go/http_errors.log 17 | -------------------------------------------------------------------------------- /cmd/admin/oidc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // JSONConfigurationOIDC to keep all OIDC details for auth 4 | type JSONConfigurationOIDC struct { 5 | IssuerURL string `json:"issuerurl"` 6 | ClientID string `json:"clientid"` 7 | ClientSecret string `json:"clientsecret"` 8 | RedirectURL string `json:"redirecturl"` 9 | Scope []string `json:"scope"` 10 | Nonce string `json:"nonce"` 11 | ResponseType string `json:"responsetype"` 12 | AuthorizationCode string `json:"authorizationcode"` 13 | } 14 | -------------------------------------------------------------------------------- /tools/bruno/environments/post -environments--env--enroll--action-.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: post -environments--env--enroll--action- 3 | type: http 4 | seq: 5 5 | } 6 | 7 | post { 8 | url: https://osctrl.net/api/v1/environments/{env}/enroll/{action} 9 | body: json 10 | auth: bearer 11 | } 12 | 13 | params:path { 14 | env: 15 | action: 16 | } 17 | 18 | auth:bearer { 19 | token: {{token}} 20 | } 21 | 22 | body:json { 23 | { 24 | "Certificate": "", 25 | "MacPkgURL": "", 26 | "MsiPkgURL": "", 27 | "RpmPkgURL": "", 28 | "DebPkgURL": "" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | issues-exit-code: 1 4 | linters: 5 | enable: 6 | - errorlint 7 | - gocritic 8 | - misspell 9 | exclusions: 10 | generated: lax 11 | presets: 12 | - comments 13 | - common-false-positives 14 | - legacy 15 | - std-error-handling 16 | paths: 17 | - third_party$ 18 | - builtin$ 19 | - examples$ 20 | formatters: 21 | enable: 22 | - gofmt 23 | - goimports 24 | exclusions: 25 | generated: lax 26 | paths: 27 | - third_party$ 28 | - builtin$ 29 | - examples$ 30 | -------------------------------------------------------------------------------- /deploy/osquery/decorators.json: -------------------------------------------------------------------------------- 1 | { 2 | "always": [ 3 | "SELECT username AS osquery_user FROM users WHERE uid = (SELECT uid FROM processes WHERE pid = (SELECT pid FROM osquery_info) LIMIT 1);", 4 | "SELECT hostname, local_hostname FROM system_info;", 5 | "SELECT user || ' (' || tty || ')' AS username FROM logged_in_users WHERE type = 'user' ORDER BY time LIMIT 1;", 6 | "SELECT version AS osquery_version, config_hash FROM osquery_info WHERE config_valid = 1;", 7 | "SELECT md5 AS osquery_md5 FROM hash WHERE path = (SELECT path FROM processes WHERE pid = (SELECT pid FROM osquery_info));" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /deploy/docker/dockerfiles/Dockerfile-osquery: -------------------------------------------------------------------------------- 1 | ARG OSCTRL_VERSION 2 | FROM jmpsec/osctrl-cli:v${OSCTRL_VERSION} 3 | 4 | ARG OSQUERY_VERSION=5.20.0 5 | 6 | USER root 7 | 8 | # Install Osquery 9 | RUN apt-get update -y -qq && apt-get install -y curl host 10 | RUN curl -L https://pkg.osquery.io/deb/osquery_${OSQUERY_VERSION}-1.linux_amd64.deb \ 11 | --output /tmp/osquery_${OSQUERY_VERSION}-1.linux_amd64.deb 12 | RUN dpkg -i /tmp/osquery_${OSQUERY_VERSION}-1.linux_amd64.deb 13 | 14 | 15 | # Entrypoint 16 | COPY deploy/docker/conf/osquery/entrypoint.sh /entrypoint.sh 17 | RUN chmod 755 /entrypoint.sh 18 | ENTRYPOINT [ "/entrypoint.sh" ] 19 | -------------------------------------------------------------------------------- /deploy/config/systemd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=_NAME 3 | ConditionPathExists=_DEST 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=_UU 9 | Group=_GG 10 | Restart=on-failure 11 | RestartSec=10 12 | 13 | WorkingDirectory=_DEST 14 | ExecStart=_DEST/_NAME _ARGS 15 | 16 | # make sure log directory exists and owned by syslog 17 | PermissionsStartOnly=true 18 | ExecStartPre=/bin/mkdir -p /var/log/_NAME 19 | ExecStartPre=/bin/chown _UU:_GG /var/log/_NAME 20 | ExecStartPre=/bin/chmod 755 /var/log/_NAME 21 | StandardOutput=syslog 22 | StandardError=syslog 23 | SyslogIdentifier=_NAME 24 | 25 | [Install] 26 | WantedBy=multi-user.target 27 | -------------------------------------------------------------------------------- /pkg/users/utils.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | // Helper to compare two set of permissions 4 | func SameAccess(acc1, acc2 EnvAccess) bool { 5 | return (acc1.Admin == acc2.Admin) && (acc1.Query == acc2.Query) && (acc1.Carve == acc2.Carve) && (acc1.User == acc2.User) 6 | } 7 | 8 | // Helper to convert received permissions into struct 9 | func GenEnvAccess(admin, carve, query, user bool) EnvAccess { 10 | if admin { 11 | return EnvAccess{ 12 | User: true, 13 | Query: true, 14 | Carve: true, 15 | Admin: true, 16 | } 17 | } 18 | return EnvAccess{ 19 | User: user, 20 | Query: query, 21 | Carve: carve, 22 | Admin: admin, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cmd/admin/static/js/tables.js: -------------------------------------------------------------------------------- 1 | // Function to pause/refresh the table fetching data 2 | function changeTableRefresh(value_id, button_id) { 3 | if (document.getElementById(value_id).value === 'yes') { 4 | document.getElementById(value_id).value = 'no'; 5 | document.getElementById(button_id).innerHTML = ''; 6 | } else { 7 | document.getElementById(value_id).value = 'yes'; 8 | document.getElementById(button_id).innerHTML = ''; 9 | } 10 | return; 11 | } 12 | 13 | // Function to refresh table when clicked 14 | function refreshTableNow(table_id) { 15 | var table = $('#' + table_id).DataTable(); 16 | table.ajax.reload(); 17 | return; 18 | } 19 | -------------------------------------------------------------------------------- /cmd/cli/api-audit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "path" 7 | 8 | "github.com/jmpsec/osctrl/pkg/auditlog" 9 | ) 10 | 11 | // GetAuditLogs to retrieve all audit logs from osctrl 12 | func (api *OsctrlAPI) GetAuditLogs() ([]auditlog.AuditLog, error) { 13 | var als []auditlog.AuditLog 14 | reqURL := fmt.Sprintf("%s%s", api.Configuration.URL, path.Join(APIPath, APIAuditLogs)) 15 | rawAls, err := api.GetGeneric(reqURL, nil) 16 | if err != nil { 17 | return als, fmt.Errorf("error api request - %w - %s", err, string(rawAls)) 18 | } 19 | if err := json.Unmarshal(rawAls, &als); err != nil { 20 | return als, fmt.Errorf("can not parse body - %w", err) 21 | } 22 | return als, nil 23 | } 24 | -------------------------------------------------------------------------------- /deploy/docker/dockerfiles/Dockerfile-dev-api: -------------------------------------------------------------------------------- 1 | ARG GOLANG_VERSION=${GOLANG_VERSION:-1.25.4} 2 | FROM golang:${GOLANG_VERSION} AS osctrl-api-dev 3 | 4 | WORKDIR /usr/src/app 5 | 6 | ENV GO111MODULE="on" 7 | ENV GOOS="linux" 8 | ENV CGO_ENABLED=0 9 | 10 | # Hot reloading mod 11 | RUN go install github.com/cosmtrek/air@v1.41.0 12 | RUN go install github.com/go-delve/delve/cmd/dlv@v1.20.1 13 | 14 | # Copy code 15 | COPY . /usr/src/app 16 | 17 | # Download deps 18 | RUN go mod download 19 | RUN go mod verify 20 | 21 | ### Copy osctrl-api bin and configs ### 22 | RUN mkdir -p /opt/osctrl/bin 23 | RUN mkdir -p /opt/osctrl/config 24 | 25 | EXPOSE 9002 26 | ENTRYPOINT ["air", "-c", "deploy/docker/conf/dev/air/.air-osctrl-api.toml"] 27 | -------------------------------------------------------------------------------- /deploy/docker/dockerfiles/Dockerfile-dev-tls: -------------------------------------------------------------------------------- 1 | ARG GOLANG_VERSION=${GOLANG_VERSION:-1.25.4} 2 | FROM golang:${GOLANG_VERSION} AS osctrl-tls-dev 3 | 4 | WORKDIR /usr/src/app 5 | 6 | ENV GO111MODULE="on" 7 | ENV GOOS="linux" 8 | ENV CGO_ENABLED=0 9 | 10 | # Hot reloading mod 11 | RUN go install github.com/cosmtrek/air@v1.41.0 12 | RUN go install github.com/go-delve/delve/cmd/dlv@v1.20.1 13 | 14 | # Copy code 15 | COPY . /usr/src/app 16 | 17 | # Download deps 18 | RUN go mod download 19 | RUN go mod verify 20 | 21 | ### Copy osctrl-api bin and configs ### 22 | RUN mkdir -p /opt/osctrl/bin 23 | RUN mkdir -p /opt/osctrl/config 24 | 25 | EXPOSE 9000 26 | ENTRYPOINT ["air", "-c", "deploy/docker/conf/dev/air/.air-osctrl-tls.toml"] 27 | -------------------------------------------------------------------------------- /cmd/api/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/jmpsec/osctrl/pkg/config" 4 | 5 | // Helper to compose paths for API 6 | func _apiPath(target string) string { 7 | return apiPrefixPath + apiVersionPath + target 8 | } 9 | 10 | // Helper to convert YAML settings loaded from file to settings 11 | func loadedYAMLToServiceParams(yml config.APIConfiguration, loadedFile string) *config.ServiceParameters { 12 | return &config.ServiceParameters{ 13 | ConfigFlag: true, 14 | ServiceConfigFile: loadedFile, 15 | Service: &yml.Service, 16 | DB: &yml.DB, 17 | Redis: &yml.Redis, 18 | JWT: &yml.JWT, 19 | TLS: &yml.TLS, 20 | Debug: &yml.Debug, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/admin/static/js/login.js: -------------------------------------------------------------------------------- 1 | function sendLogin() { 2 | var _user = $("#login_user").val(); 3 | var _password = $("#login_password").val(); 4 | 5 | var _url = '/login'; 6 | var data = { 7 | username: _user, 8 | password: _password 9 | }; 10 | sendPostRequest(data, _url, '', false, function(_data){ 11 | window.location.replace(_data.message); 12 | }); 13 | } 14 | 15 | function sendLogout() { 16 | var _csrf = $("#csrftoken").val(); 17 | 18 | var _url = '/logout'; 19 | var data = { 20 | csrftoken: _csrf 21 | }; 22 | sendPostRequest(data, _url, '/logout', false); 23 | } 24 | 25 | $("#login_password").keyup(function(event) { 26 | if (event.keyCode === 13) { 27 | $("#login_button").click(); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /deploy/cicd/docker/Dockerfile-osctrl-api: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | ARG COMPONENT=api 4 | ARG GOOS=linux 5 | ARG GOARCH=amd64 6 | 7 | # Install software 8 | RUN apt-get update -y -q && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # Install/Setup osctrl 12 | RUN useradd -ms /usr/sbin/nologin osctrl-${COMPONENT} 13 | RUN mkdir -p /opt/osctrl/bin && \ 14 | mkdir -p /opt/osctrl/config && \ 15 | mkdir -p /opt/osctrl/script && \ 16 | chown osctrl-${COMPONENT}:osctrl-${COMPONENT} -R /opt/osctrl 17 | COPY osctrl-${COMPONENT}-${GOOS}-${GOARCH}.bin /opt/osctrl/bin/osctrl-${COMPONENT} 18 | RUN chmod 755 /opt/osctrl/bin/osctrl-${COMPONENT} 19 | USER osctrl-${COMPONENT} 20 | WORKDIR /opt/osctrl 21 | EXPOSE 9002/tcp 22 | CMD ["/opt/osctrl/bin/osctrl-api"] 23 | -------------------------------------------------------------------------------- /deploy/cicd/docker/Dockerfile-osctrl-tls: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | ARG COMPONENT=tls 4 | ARG GOOS=linux 5 | ARG GOARCH=amd64 6 | 7 | # Install software 8 | RUN apt-get update -y -q && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # Install/Setup osctrl 12 | RUN useradd -ms /usr/sbin/nologin osctrl-${COMPONENT} 13 | RUN mkdir -p /opt/osctrl/bin && \ 14 | mkdir -p /opt/osctrl/config && \ 15 | mkdir -p /opt/osctrl/script && \ 16 | chown osctrl-${COMPONENT}:osctrl-${COMPONENT} -R /opt/osctrl 17 | COPY osctrl-${COMPONENT}-${GOOS}-${GOARCH}.bin /opt/osctrl/bin/osctrl-${COMPONENT} 18 | RUN chmod 755 /opt/osctrl/bin/osctrl-${COMPONENT} 19 | USER osctrl-${COMPONENT} 20 | WORKDIR /opt/osctrl 21 | EXPOSE 9000/tcp 22 | CMD ["/opt/osctrl/bin/osctrl-tls"] 23 | -------------------------------------------------------------------------------- /cmd/admin/static/img/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /cmd/tls/handlers/get.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/jmpsec/osctrl/pkg/utils" 7 | ) 8 | 9 | // RootHandler to be used as health check 10 | func (h *HandlersTLS) RootHandler(w http.ResponseWriter, r *http.Request) { 11 | // Send response 12 | utils.HTTPResponse(w, "", http.StatusOK, []byte("💥")) 13 | } 14 | 15 | // HealthHandler for health requests 16 | func (h *HandlersTLS) HealthHandler(w http.ResponseWriter, r *http.Request) { 17 | // Send response 18 | utils.HTTPResponse(w, "", http.StatusOK, []byte("✅")) 19 | } 20 | 21 | // ErrorHandler for error requests 22 | func (h *HandlersTLS) ErrorHandler(w http.ResponseWriter, r *http.Request) { 23 | // Send response 24 | utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("uh oh...")) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/logging/debughttp.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "github.com/jmpsec/osctrl/pkg/config" 5 | "github.com/rs/zerolog" 6 | lumberjack "gopkg.in/natefinch/lumberjack.v2" 7 | ) 8 | 9 | const ( 10 | // Default time format for loggers 11 | LoggerTimeFormat string = "2006-01-02T15:04:05.999Z07:00" 12 | ) 13 | 14 | // CreateDebugHTTP to initialize the debug HTTP logger 15 | func CreateDebugHTTP(cfg config.LocalLogger) (*zerolog.Logger, error) { 16 | zerolog.TimeFieldFormat = LoggerTimeFormat 17 | z := zerolog.New(&lumberjack.Logger{ 18 | Filename: cfg.FilePath, 19 | MaxSize: cfg.MaxSize, 20 | MaxBackups: cfg.MaxBackups, 21 | MaxAge: cfg.MaxAge, 22 | Compress: cfg.Compress, 23 | }) 24 | logger := z.With().Caller().Timestamp().Logger() 25 | return &logger, nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/logging/utils.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "github.com/jmpsec/osctrl/pkg/config" 5 | ) 6 | 7 | const ( 8 | // NotReturned - Value not returned from agent 9 | NotReturned = "not returned" 10 | // Mismatched - Value mismatched in log entries 11 | Mismatched = "mismatched" 12 | ) 13 | 14 | // Helper to check if two DB configurations are the same 15 | func sameConfigDB(loggerOne, loggerTwo config.YAMLConfigurationDB) bool { 16 | return (loggerOne.Host == loggerTwo.Host) && (loggerOne.Port == loggerTwo.Port) && (loggerOne.Name == loggerTwo.Name) 17 | } 18 | 19 | // Helper to be used preparing metadata for each decorator 20 | func metadataVerification(dst, src string) string { 21 | if src != dst { 22 | if dst == "" { 23 | return src 24 | } 25 | if src == "" { 26 | return dst 27 | } 28 | } 29 | return src 30 | } 31 | -------------------------------------------------------------------------------- /deploy/cicd/docker/Dockerfile-osctrl-cli: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | ARG COMPONENT=cli 4 | ARG GOOS=linux 5 | ARG GOARCH=amd64 6 | 7 | # Install software 8 | RUN apt-get update -y -q && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # Install/Setup osctrl 12 | RUN useradd -ms /usr/sbin/nologin osctrl-${COMPONENT} 13 | RUN mkdir -p /opt/osctrl/bin && \ 14 | mkdir -p /opt/osctrl/config && \ 15 | mkdir -p /opt/osctrl/script && \ 16 | chown osctrl-${COMPONENT}:osctrl-${COMPONENT} -R /opt/osctrl 17 | 18 | COPY osctrl-${COMPONENT}-${GOOS}-${GOARCH}.bin /opt/osctrl/bin/osctrl-${COMPONENT} 19 | RUN chmod 755 /opt/osctrl/bin/osctrl-${COMPONENT} 20 | 21 | 22 | COPY deploy/docker/conf/cli/entrypoint.sh /entrypoint.sh 23 | RUN chmod 755 /entrypoint.sh 24 | 25 | USER osctrl-${COMPONENT} 26 | WORKDIR /opt/osctrl 27 | ENTRYPOINT [ "/entrypoint.sh" ] 28 | -------------------------------------------------------------------------------- /cmd/cli/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/jmpsec/osctrl/pkg/nodes" 7 | "github.com/jmpsec/osctrl/pkg/utils" 8 | ) 9 | 10 | // Helper to convert boolean to string 11 | func stringifyBool(b bool) string { 12 | if b { 13 | return "True" 14 | } 15 | return "False" 16 | } 17 | 18 | // Helper to get what is the last seen time for a node 19 | func nodeLastSeen(n nodes.OsqueryNode) string { 20 | return utils.PastFutureTimes(n.LastSeen) 21 | } 22 | 23 | // Helper to prepare the header for output 24 | func stringSliceToAnySlice(header []string) []any { 25 | result := make([]any, len(header)) 26 | for i, v := range header { 27 | result[i] = v 28 | } 29 | return result 30 | } 31 | 32 | // Helper to get the shell username 33 | func getShellUsername() string { 34 | user := os.Getenv("USER") 35 | if user == "" { 36 | user = os.Getenv("USERNAME") 37 | } 38 | if user == "" { 39 | user = "cli-user" 40 | } else { 41 | user = "env: " + user 42 | } 43 | return user 44 | } 45 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-ast 7 | - id: check-json 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: check-toml 11 | - id: check-xml 12 | - id: detect-aws-credentials 13 | - id: detect-private-key 14 | - id: check-yaml 15 | - id: end-of-file-fixer 16 | - id: trailing-whitespace 17 | - id: check-added-large-files 18 | args: [--maxkb=800] 19 | - id: check-docstring-first 20 | - id: requirements-txt-fixer 21 | 22 | 23 | - repo: https://github.com/Bahjat/pre-commit-golang 24 | rev: v1.0.5 25 | hooks: 26 | - id: go-fmt-import 27 | - id: go-static-check # install https://staticcheck.io/docs/ 28 | - id: golangci-lint # requires github.com/golangci/golangci-lint 29 | args: [--config=.golangci.yml, --allow-parallel-runners] # optional 30 | - id: go-unit-tests 31 | -------------------------------------------------------------------------------- /deploy/redis/redis.conf: -------------------------------------------------------------------------------- 1 | bind 127.0.0.1 2 | daemonize no 3 | supervised systemd 4 | requirepass REDIS_PASSWORD 5 | 6 | # This limit will depend on the memory available in your system 7 | maxmemory 1G 8 | 9 | # MAXMEMORY POLICY: how Redis will select what to remove when maxmemory 10 | # is reached. You can select one from the following behaviors: 11 | # 12 | # volatile-lru -> Evict using approximated LRU, only keys with an expire set. 13 | # allkeys-lru -> Evict any key using approximated LRU. 14 | # volatile-lfu -> Evict using approximated LFU, only keys with an expire set. 15 | # allkeys-lfu -> Evict any key using approximated LFU. 16 | # volatile-random -> Remove a random key having an expire set. 17 | # allkeys-random -> Remove a random key, any key. 18 | # volatile-ttl -> Remove the key with the nearest expire time (minor TTL) 19 | # noeviction -> Don't evict anything, just return an error on write operations. 20 | # LRU means Least Recently Used 21 | # LFU means Least Frequently Used 22 | maxmemory-policy allkeys-lfu 23 | -------------------------------------------------------------------------------- /tools/update-modules.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Helper to update all go.mod files with the latest commit of osctrl 4 | 5 | # If more than one parameter, show usage 6 | if [ $# -gt 1 ] ; then 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | declare -a MODULES=() 12 | 13 | # If one parameter, that is the module directory 14 | if [ $# -eq 1 ] ; then 15 | echo "[+] Using module $1" 16 | MODULES+=( "$1" ) 17 | fi 18 | 19 | # If no parameters, recursively find all go.mod files 20 | if [ $# -eq 0 ] ; then 21 | echo "[+] Finding all go.mod files..." 22 | MODULES+=( $(find . -name "go.mod" | sed 's/\/go.mod//g' | grep -v "\.$") ) 23 | echo "[+] Found ${#MODULES[@]} modules" 24 | fi 25 | 26 | # Iterate over all modules 27 | for module in "${MODULES[@]}" 28 | do 29 | echo "[+] Updating module $module" 30 | cd "$module" || exit 1 31 | go get -u 32 | cd - || exit 1 33 | done 34 | 35 | # Run go mod tidy 36 | echo "[+] Running go mod tidy" 37 | go mod tidy 38 | 39 | echo "[+] Done" 40 | exit 0 41 | -------------------------------------------------------------------------------- /cmd/cli/api-login.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "path" 8 | 9 | "github.com/jmpsec/osctrl/pkg/types" 10 | ) 11 | 12 | // PostLogin to login into API to retrieve a token 13 | func (api *OsctrlAPI) PostLogin(env, username, password string, expHours int) (types.ApiLoginResponse, error) { 14 | var res types.ApiLoginResponse 15 | l := types.ApiLoginRequest{ 16 | Username: username, 17 | Password: password, 18 | ExpHours: expHours, 19 | } 20 | jsonMessage, err := json.Marshal(l) 21 | if err != nil { 22 | return res, fmt.Errorf("error marshaling data %w", err) 23 | } 24 | jsonParam := bytes.NewReader(jsonMessage) 25 | reqURL := fmt.Sprintf("%s%s", api.Configuration.URL, path.Join(APIPath, APILogin, env)) 26 | rawRes, err := api.PostGeneric(reqURL, jsonParam) 27 | if err != nil { 28 | return res, fmt.Errorf("error api request - %w - %s", err, string(rawRes)) 29 | } 30 | if err := json.Unmarshal(rawRes, &res); err != nil { 31 | return res, fmt.Errorf("can not parse body - %w", err) 32 | } 33 | return res, nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/carves/db.go: -------------------------------------------------------------------------------- 1 | package carves 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // CarvedFile to keep track of carved files from nodes 10 | type CarvedFile struct { 11 | gorm.Model 12 | CarveID string `gorm:"unique;index"` 13 | RequestID string 14 | SessionID string 15 | QueryName string 16 | UUID string `gorm:"index"` 17 | NodeID uint 18 | Environment string 19 | Path string 20 | CarveSize int 21 | BlockSize int 22 | TotalBlocks int 23 | CompletedBlocks int 24 | Status string 25 | CompletedAt time.Time 26 | Carver string 27 | Archived bool 28 | ArchivePath string 29 | EnvironmentID uint 30 | } 31 | 32 | // CarvedBlock to store each block from a carve 33 | type CarvedBlock struct { 34 | gorm.Model 35 | RequestID string `gorm:"index"` 36 | SessionID string `gorm:"index"` 37 | Environment string 38 | BlockID int 39 | Data string 40 | Size int 41 | Carver string 42 | EnvironmentID uint 43 | } 44 | -------------------------------------------------------------------------------- /deploy/cicd/docker/Dockerfile-osctrl-admin: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | ARG COMPONENT=admin 4 | ARG GOOS=linux 5 | ARG GOARCH=amd64 6 | 7 | # Install software 8 | RUN apt-get update -y -q && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | # Install/Setup osctrl 12 | RUN useradd -ms /usr/sbin/nologin osctrl-${COMPONENT} 13 | RUN mkdir -p /opt/osctrl/bin && \ 14 | mkdir -p /opt/osctrl/config && \ 15 | mkdir -p /opt/osctrl/script && \ 16 | mkdir -p /opt/osctrl/tmpl_admin/components && \ 17 | mkdir -p /opt/osctrl/static && \ 18 | mkdir -p /opt/osctrl/data && \ 19 | chown osctrl-${COMPONENT}:osctrl-${COMPONENT} -R /opt/osctrl 20 | COPY osctrl-${COMPONENT}-${GOOS}-${GOARCH}.bin /opt/osctrl/bin/osctrl-${COMPONENT} 21 | RUN chmod 755 /opt/osctrl/bin/osctrl-${COMPONENT} 22 | 23 | ### Copy osctrl-admin web templates ### 24 | USER osctrl-${COMPONENT} 25 | COPY cmd/admin/templates/ /opt/osctrl/tmpl_admin 26 | COPY cmd/admin/static/ /opt/osctrl/static 27 | COPY deploy/osquery/data/*.json /opt/osctrl/data/ 28 | 29 | WORKDIR /opt/osctrl 30 | EXPOSE 9001/tcp 31 | CMD ["/opt/osctrl/bin/osctrl-admin"] 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | 8 | # IDE metadata 9 | .vscode 10 | .idea 11 | 12 | # Logs 13 | *.log 14 | 15 | # Golang 16 | vendor 17 | glide.lock 18 | 19 | # Directories to ignore 20 | bin 21 | build 22 | !.github/actions/build 23 | vendor 24 | dist 25 | ignore 26 | 27 | # Plugins 28 | *.so 29 | 30 | # Deployment related files 31 | osctrl.sh 32 | 33 | # Temp directory for tests 34 | tmp 35 | 36 | # Various packages 37 | *.deb 38 | *.pkg 39 | *.rpm 40 | 41 | # Certificates 42 | certs 43 | osctrl.csr 44 | osctrl.pem 45 | osctrl.key 46 | osctrl.crt 47 | osctrl.secret 48 | dhparam.pem 49 | 50 | # Environment file for docker 51 | .env 52 | 53 | # Customization 54 | #brand.png 55 | 56 | # Random notes for debug 57 | notes.txt 58 | 59 | # Generated bins 60 | *.bin 61 | 62 | # Tests 63 | coverage.out 64 | 65 | # Configuration 66 | osctrl-api.json 67 | 68 | # Go Workspace 69 | go.work 70 | go.work.sum 71 | 72 | deploy/docker/conf/tls/* 73 | .env 74 | !deploy/docker/conf/tls/openssl.cnf.example 75 | tls.env 76 | 77 | # bruno 78 | tools/bruno/collection.bru 79 | -------------------------------------------------------------------------------- /pkg/environments/decorators.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | const ( 4 | // DecoratorUsers to append osquery user as result decorator 5 | DecoratorUsers = "SELECT username AS osquery_user FROM users WHERE uid = (SELECT uid FROM processes WHERE pid = (SELECT pid FROM osquery_info) LIMIT 1);" 6 | // DecoratorHostname to append hostnames as result decorator 7 | DecoratorHostname = "SELECT hostname, local_hostname FROM system_info;" 8 | // DecoratorLoggedInUser to append the first logged in user as result decorator 9 | DecoratorLoggedInUser = "SELECT user || ' (' || tty || ')' AS username FROM logged_in_users WHERE type = 'user' ORDER BY time LIMIT 1;" 10 | // DecoratorOsqueryVersionHash to append the osquery version and the configuration hash as result decorator 11 | DecoratorOsqueryVersionHash = "SELECT version AS osquery_version, config_hash FROM osquery_info WHERE config_valid = 1;" 12 | // DecoratorMD5Process to append the MD5 of the running osquery binary as result decorator 13 | DecoratorMD5Process = "SELECT md5 AS osquery_md5 FROM hash WHERE path = (SELECT path FROM processes WHERE pid = (SELECT pid FROM osquery_info));" 14 | ) 15 | -------------------------------------------------------------------------------- /deploy/docker/dockerfiles/Dockerfile-dev-admin: -------------------------------------------------------------------------------- 1 | ######################################## osctrl-dev-base ######################################## 2 | ARG GOLANG_VERSION=${GOLANG_VERSION:-1.25.4} 3 | FROM golang:${GOLANG_VERSION} AS osctrl-admin-dev 4 | 5 | WORKDIR /usr/src/app 6 | 7 | ARG OSQUERY_VERSION 8 | ENV GO111MODULE="on" 9 | ENV GOOS="linux" 10 | ENV CGO_ENABLED=0 11 | 12 | # Hot reloading mod 13 | RUN go install github.com/cosmtrek/air@v1.49.0 14 | RUN go install github.com/go-delve/delve/cmd/dlv@v1.22.1 15 | 16 | # Copy code 17 | COPY . /usr/src/app 18 | 19 | # Download deps 20 | RUN go mod download 21 | RUN go mod verify 22 | 23 | ### Copy osctrl-admin bin and configs ### 24 | RUN mkdir -p /opt/osctrl/bin 25 | RUN mkdir -p /opt/osctrl/config 26 | RUN mkdir -p /opt/osctrl/carved_files 27 | 28 | ### Copy osctrl-admin web templates ### 29 | COPY cmd/admin/templates/ /opt/osctrl/tmpl_admin 30 | COPY cmd/admin/static/ /opt/osctrl/static 31 | COPY deploy/osquery/data/${OSQUERY_VERSION}.json /opt/osctrl/data/${OSQUERY_VERSION}.json 32 | 33 | EXPOSE 9001 34 | ENTRYPOINT ["air", "-c", "deploy/docker/conf/dev/air/.air-osctrl-admin.toml"] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Javier Marcos de Prado 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/admin/handlers/json-tags.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/jmpsec/osctrl/cmd/admin/sessions" 8 | "github.com/jmpsec/osctrl/pkg/users" 9 | "github.com/jmpsec/osctrl/pkg/utils" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | // JSONTagsHandler for platform/environment stats in JSON 14 | func (h *HandlersAdmin) JSONTagsHandler(w http.ResponseWriter, r *http.Request) { 15 | if h.DebugHTTPConfig.EnableHTTP { 16 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 17 | } 18 | // Get context data 19 | ctx := r.Context().Value(sessions.ContextKey(sessions.CtxSession)).(sessions.ContextValue) 20 | // Check permissions 21 | if !h.Users.CheckPermissions(ctx[sessions.CtxUser], users.AdminLevel, users.NoEnvironment) { 22 | adminErrorResponse(w, fmt.Sprintf("%s has insufficient permissions", ctx[sessions.CtxUser]), http.StatusForbidden, nil) 23 | return 24 | } 25 | // Get tags 26 | tags, err := h.Tags.All() 27 | if err != nil { 28 | log.Err(err).Msg("error getting tags") 29 | return 30 | } 31 | // Serve JSON 32 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tags) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/utils/string-utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGenRandomString(t *testing.T) { 10 | generatedLen := 10 11 | generated := GenRandomString(generatedLen) 12 | assert.NotEmpty(t, generated) 13 | assert.Equal(t, generatedLen, len(generated)) 14 | } 15 | 16 | func TestGenKSUID(t *testing.T) { 17 | assert.NotEmpty(t, GenKSUID()) 18 | } 19 | 20 | func TestGenUUID(t *testing.T) { 21 | assert.NotEmpty(t, GenUUID()) 22 | } 23 | 24 | func TestCheckUUID(t *testing.T) { 25 | assert.True(t, CheckUUID(GenUUID())) 26 | } 27 | 28 | func TestStringToInteger(t *testing.T) { 29 | assert.Equal(t, int64(123), StringToInteger("123")) 30 | } 31 | 32 | func TestStringToIntegerError(t *testing.T) { 33 | assert.Equal(t, int64(0), StringToInteger("aaa")) 34 | } 35 | 36 | func TestStringToBooleanYes(t *testing.T) { 37 | assert.Equal(t, true, StringToBoolean("yes")) 38 | } 39 | 40 | func TestStringToBooleanTrue(t *testing.T) { 41 | assert.Equal(t, true, StringToBoolean("true")) 42 | } 43 | 44 | func TestStringToBooleanWhatever(t *testing.T) { 45 | assert.Equal(t, false, StringToBoolean("whatever")) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/auditlog/utils_test.go: -------------------------------------------------------------------------------- 1 | package auditlog 2 | 3 | import "testing" 4 | 5 | func TestLogTypeToString(t *testing.T) { 6 | tests := []struct { 7 | input uint 8 | expected string 9 | }{ 10 | {1, "Login"}, 11 | {2, "Logout"}, 12 | {3, "Node"}, 13 | {4, "Query"}, 14 | {5, "Carve"}, 15 | {6, "Tag"}, 16 | {7, "Environment"}, 17 | {8, "Setting"}, 18 | {9, "Visit"}, 19 | {10, "User"}, 20 | {0, "Unknown"}, 21 | {99, "Unknown"}, 22 | } 23 | al := AuditLogManager{} 24 | for _, tt := range tests { 25 | result := al.LogTypeToString(tt.input) 26 | if result != tt.expected { 27 | t.Errorf("LogTypeToString(%d) = %q; want %q", tt.input, result, tt.expected) 28 | } 29 | } 30 | } 31 | 32 | func TestSeverityToString(t *testing.T) { 33 | tests := []struct { 34 | input uint 35 | expected string 36 | }{ 37 | {1, "Info"}, 38 | {2, "Warning"}, 39 | {3, "Error"}, 40 | {0, "Unknown"}, 41 | {99, "Unknown"}, 42 | } 43 | al := AuditLogManager{} 44 | for _, tt := range tests { 45 | result := al.SeverityToString(tt.input) 46 | if result != tt.expected { 47 | t.Errorf("SeverityToString(%d) = %q; want %q", tt.input, result, tt.expected) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cmd/admin/static/js/stats.js: -------------------------------------------------------------------------------- 1 | function statsRefresh(_target, _identifier) { 2 | $.ajax({ 3 | url: '/json/stats/' + _target + '/' + _identifier, 4 | dataType: 'json', 5 | type: 'GET', 6 | contentType: 'application/json', 7 | success: function (data, textStatus, jQxhr) { 8 | $('.stats-' + _target + '-' + _identifier + '-active').text(data.active); 9 | $('.stats-' + _target + '-' + _identifier + '-inactive').text(data.inactive); 10 | $('.stats-' + _target + '-' + _identifier + '-total').text(data.total); 11 | }, 12 | error: function (jqXhr, textStatus, errorThrown) { 13 | var _clientmsg = 'Client: ' + errorThrown; 14 | var _serverJSON = $.parseJSON(jqXhr.responseText); 15 | var _servermsg = 'Server: ' + _serverJSON.message; 16 | console.log('Error getting stats...'); 17 | console.log(_clientmsg); 18 | console.log(_servermsg); 19 | } 20 | }); 21 | } 22 | 23 | function beginStats() { 24 | var _stats = ['environment']; 25 | for (var i = 0; i < _stats.length; i++) { 26 | $('input[type="hidden"].stats-' + _stats[i] + '-value').each(function () { 27 | statsRefresh(_stats[i], $(this).val()); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/tls/handlers/get_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRootHandler(t *testing.T) { 12 | req, _ := http.NewRequest("GET", "/", nil) 13 | h := CreateHandlersTLS() 14 | rr := httptest.NewRecorder() 15 | handler := http.HandlerFunc(h.RootHandler) 16 | handler.ServeHTTP(rr, req) 17 | assert.Equal(t, http.StatusOK, rr.Code) 18 | assert.Equal(t, "💥", rr.Body.String()) 19 | } 20 | 21 | func TestHealthHandler(t *testing.T) { 22 | req, _ := http.NewRequest("GET", "/health", nil) 23 | h := CreateHandlersTLS() 24 | rr := httptest.NewRecorder() 25 | handler := http.HandlerFunc(h.HealthHandler) 26 | handler.ServeHTTP(rr, req) 27 | assert.Equal(t, http.StatusOK, rr.Code) 28 | assert.Equal(t, "✅", rr.Body.String()) 29 | } 30 | 31 | func TestErrorHandler(t *testing.T) { 32 | req, _ := http.NewRequest("GET", "/error", nil) 33 | h := CreateHandlersTLS() 34 | rr := httptest.NewRecorder() 35 | handler := http.HandlerFunc(h.ErrorHandler) 36 | handler.ServeHTTP(rr, req) 37 | assert.Equal(t, http.StatusInternalServerError, rr.Code) 38 | assert.Equal(t, "uh oh...", rr.Body.String()) 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/test-release.yml: -------------------------------------------------------------------------------- 1 | name: Test a new release with GoReleaser 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | GOLANG_VERSION: 1.25.4 16 | 17 | jobs: 18 | test-build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@8e57b58e57be52ac95949151e2777ffda8501267 # v5.5.0 28 | with: 29 | go-version: ${{ env.GOLANG_VERSION }} 30 | 31 | - name: Run GoReleaser build 32 | uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 33 | with: 34 | distribution: goreleaser 35 | version: latest 36 | args: build --snapshot --clean --single-target 37 | 38 | - name: Upload build artifacts 39 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 40 | with: 41 | name: osctrl-binaries 42 | path: dist/ 43 | retention-days: 1 44 | -------------------------------------------------------------------------------- /pkg/users/utils_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSameAccessTrue(t *testing.T) { 10 | acc1 := EnvAccess{ 11 | User: true, 12 | Query: true, 13 | Carve: false, 14 | Admin: false, 15 | } 16 | acc2 := EnvAccess{ 17 | User: true, 18 | Query: true, 19 | Carve: false, 20 | Admin: false, 21 | } 22 | assert.Equal(t, true, SameAccess(acc1, acc2)) 23 | } 24 | 25 | func TestSameAccessFalse(t *testing.T) { 26 | acc1 := EnvAccess{ 27 | User: true, 28 | Query: true, 29 | Carve: false, 30 | Admin: false, 31 | } 32 | acc2 := EnvAccess{ 33 | User: true, 34 | Query: false, 35 | Carve: false, 36 | Admin: false, 37 | } 38 | assert.Equal(t, false, SameAccess(acc1, acc2)) 39 | } 40 | 41 | func TestGenEnvAccessAdmin(t *testing.T) { 42 | acc := EnvAccess{ 43 | User: true, 44 | Query: true, 45 | Carve: true, 46 | Admin: true, 47 | } 48 | assert.Equal(t, acc, GenEnvAccess(true, false, false, false)) 49 | } 50 | 51 | func TestGenEnvAccessQueryUser(t *testing.T) { 52 | acc := EnvAccess{ 53 | User: true, 54 | Query: true, 55 | Carve: false, 56 | Admin: false, 57 | } 58 | assert.Equal(t, acc, GenEnvAccess(false, false, true, true)) 59 | } 60 | -------------------------------------------------------------------------------- /deploy/docker/conf/dev/air/.air-osctrl-cli.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format for osctrl-cli 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root 5 | root = "." 6 | tmp_dir = "/tmp" 7 | 8 | [build] 9 | bin = "./bin/osctrl-cli" 10 | cmd = "go build -ldflags \"-s -w -X main.buildCommit=$(git rev-parse HEAD) -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)\" -o /opt/osctrl/bin/osctrl-cli /usr/src/app/cmd/cli/*.go" 11 | # It's not necessary to trigger build each time file changes if it's too frequent. 12 | delay = 1000 13 | exclude_dir = ["tmp", "vendor", "testdata", "deploy", "admin", "tls", "api"] 14 | exclude_file = [] 15 | exclude_regex = ["_test\\.go"] 16 | exclude_unchanged = false 17 | follow_symlink = false 18 | full_bin = "/bin/bash" 19 | include_dir = [] 20 | include_ext = ["go"] 21 | kill_delay = "0s" 22 | log = "build-errors.log" 23 | send_interrupt = false 24 | stop_on_error = true 25 | 26 | [color] 27 | app = "" 28 | build = "yellow" 29 | main = "magenta" 30 | runner = "green" 31 | watcher = "cyan" 32 | 33 | [log] 34 | time = true 35 | 36 | [misc] 37 | clean_on_exit = false 38 | 39 | [screen] 40 | clear_on_rebuild = false 41 | -------------------------------------------------------------------------------- /deploy/docker/conf/dev/air/.air-osctrl-api.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format for osctrl-api 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root 5 | root = "." 6 | tmp_dir = "/tmp" 7 | 8 | [build] 9 | bin = "./bin/osctrl-api" 10 | cmd = "go build -ldflags \"-s -w -X main.buildCommit=$(git rev-parse HEAD) -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)\" -o /opt/osctrl/bin/osctrl-api /usr/src/app/cmd/api/*.go" 11 | # It's not necessary to trigger build each time file changes if it's too frequent. 12 | delay = 1000 13 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "deploy", "admin", "tls", "cli"] 14 | exclude_file = [] 15 | exclude_regex = ["_test.go"] 16 | exclude_unchanged = false 17 | follow_symlink = false 18 | full_bin = "cd /opt/osctrl && ./bin/osctrl-api" 19 | include_dir = [] 20 | include_ext = ["go"] 21 | kill_delay = "0s" 22 | log = "build-errors.log" 23 | send_interrupt = false 24 | stop_on_error = true 25 | 26 | [color] 27 | app = "" 28 | build = "yellow" 29 | main = "magenta" 30 | runner = "green" 31 | watcher = "cyan" 32 | 33 | [log] 34 | time = true 35 | 36 | [misc] 37 | clean_on_exit = false 38 | 39 | [screen] 40 | clear_on_rebuild = false 41 | -------------------------------------------------------------------------------- /deploy/docker/conf/dev/air/.air-osctrl-tls.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format for osctrl-tls 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root 5 | root = "." 6 | tmp_dir = "/tmp" 7 | 8 | [build] 9 | bin = "./bin/osctrl-tls" 10 | cmd = "go build -ldflags \"-s -w -X main.buildCommit=$(git rev-parse HEAD) -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)\" -o /opt/osctrl/bin/osctrl-tls /usr/src/app/cmd/tls/*.go" 11 | # It's not necessary to trigger build each time file changes if it's too frequent. 12 | delay = 1000 13 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "deploy", "admin", "api", "cli"] 14 | exclude_file = [] 15 | exclude_regex = ["_test.go"] 16 | exclude_unchanged = false 17 | follow_symlink = false 18 | full_bin = "cd /opt/osctrl && ./bin/osctrl-tls" 19 | include_dir = [] 20 | include_ext = ["go"] 21 | kill_delay = "0s" 22 | log = "build-errors.log" 23 | send_interrupt = false 24 | stop_on_error = true 25 | 26 | [color] 27 | app = "" 28 | build = "yellow" 29 | main = "magenta" 30 | runner = "green" 31 | watcher = "cyan" 32 | 33 | [log] 34 | time = true 35 | 36 | [misc] 37 | clean_on_exit = false 38 | 39 | [screen] 40 | clear_on_rebuild = false 41 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: Go linting on PRs pushed to osctrl 2 | 3 | on: 4 | pull_request: 5 | branches: [main, master, develop] 6 | paths: 7 | - "**/*.go" 8 | - "go.mod" 9 | - "go.sum" 10 | - ".golangci.yml" 11 | - ".github/workflows/golangci-lint.yml" 12 | 13 | permissions: 14 | contents: read 15 | 16 | env: 17 | GOLANG_VERSION: 1.25.4 18 | 19 | jobs: 20 | golangci: 21 | name: lint 22 | runs-on: ubuntu-latest 23 | steps: 24 | ########################### Checkout code ########################### 25 | - name: Checkout code 26 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 27 | 28 | ########################### Set up Go ############################### 29 | - name: Set up Go 30 | uses: actions/setup-go@8e57b58e57be52ac95949151e2777ffda8501267 # v5.5.0 31 | with: 32 | go-version: "${{ env.GOLANG_VERSION }}" 33 | cache: true 34 | 35 | ########################### Run golangci-lint ####################### 36 | - name: golangci-lint 37 | uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v9.1.0 38 | with: 39 | version: latest 40 | working-directory: ./ 41 | args: --timeout=5m 42 | only-new-issues: true 43 | -------------------------------------------------------------------------------- /cmd/api/handlers/audit.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/jmpsec/osctrl/pkg/auditlog" 9 | "github.com/jmpsec/osctrl/pkg/users" 10 | "github.com/jmpsec/osctrl/pkg/utils" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // AuditLogsHandler - GET Handler for all audit logs 15 | func (h *HandlersApi) AuditLogsHandler(w http.ResponseWriter, r *http.Request) { 16 | // Debug HTTP if enabled 17 | if h.DebugHTTPConfig.EnableHTTP { 18 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 19 | } 20 | // Get context data and check access 21 | ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) 22 | if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { 23 | apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) 24 | return 25 | } 26 | // Get audit logs 27 | auditLogs, err := h.AuditLog.GetAll() 28 | if err != nil { 29 | log.Err(err).Msg("error getting audit logs") 30 | return 31 | } 32 | // Serialize and serve JSON 33 | log.Debug().Msgf("Returned %d audit log entries", len(auditLogs)) 34 | h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) 35 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, auditLogs) 36 | } 37 | -------------------------------------------------------------------------------- /cmd/api/handlers/utils.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/jmpsec/osctrl/pkg/logging" 7 | "github.com/jmpsec/osctrl/pkg/types" 8 | "github.com/jmpsec/osctrl/pkg/utils" 9 | "github.com/rs/zerolog/log" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | // ContextValue to hold session data in the context 14 | type ContextValue map[string]string 15 | 16 | // ContextKey to help with the context key, to pass session data 17 | type ContextKey string 18 | 19 | // APIQueryData to hold query result data 20 | type APIQueryData map[string]string 21 | 22 | const ( 23 | // Key to identify request context 24 | contextAPI string = "osctrl-api-context" 25 | ctxUser string = "user" 26 | ) 27 | 28 | // Function to retrieve the query log by name 29 | func postgresQueryLogs(db *gorm.DB, name string) (APIQueryData, error) { 30 | var logs []logging.OsqueryQueryData 31 | data := make(APIQueryData) 32 | if err := db.Where("name = ?", name).Find(&logs).Error; err != nil { 33 | return data, err 34 | } 35 | for _, l := range logs { 36 | data[l.UUID] = l.Data 37 | } 38 | return data, nil 39 | } 40 | 41 | // Helper to handle API error responses 42 | func apiErrorResponse(w http.ResponseWriter, msg string, code int, err error) { 43 | log.Debug().Msgf("apiErrorResponse %s: %v", msg, err) 44 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, code, types.ApiErrorResponse{Error: msg}) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/logging/dispatch.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/jmpsec/osctrl/pkg/nodes" 7 | "github.com/jmpsec/osctrl/pkg/types" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // DispatchLogs - Helper to dispatch logs 12 | func (l *LoggerTLS) DispatchLogs(data []byte, uuid, logType, environment string, metadata nodes.NodeMetadata, debug bool) { 13 | // Use metadata to update record 14 | if err := l.Nodes.UpdateMetadataByUUID(uuid, metadata); err != nil { 15 | log.Err(err).Msg("error updating metadata") 16 | } 17 | // Send data to storage 18 | // FIXME allow multiple types of logging 19 | if debug { 20 | log.Debug().Msgf("dispatching logs to %s", l.Logging) 21 | } 22 | l.Log(logType, data, environment, uuid, debug) 23 | } 24 | 25 | // DispatchQueries - Helper to dispatch queries 26 | func (l *LoggerTLS) DispatchQueries(queryData types.QueryWriteData, node nodes.OsqueryNode, debug bool) { 27 | // Prepare data to send 28 | data, err := json.Marshal(queryData) 29 | if err != nil { 30 | log.Err(err).Msg("error preparing data") 31 | } 32 | // Send data to storage 33 | // FIXME allow multiple types of logging 34 | if debug { 35 | log.Debug().Msgf("dispatching queries to %s", l.Logging) 36 | } 37 | l.QueryLog( 38 | types.QueryLog, 39 | data, 40 | node.Environment, 41 | node.UUID, 42 | queryData.Name, 43 | queryData.Status, 44 | debug) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | 6 | redis "github.com/go-redis/redis/v8" 7 | "github.com/jmpsec/osctrl/pkg/config" 8 | ) 9 | 10 | const ( 11 | // RedisKey to identify the configuration JSON key 12 | RedisKey = "redis" 13 | ) 14 | 15 | // RedisManager have access to cached data 16 | type RedisManager struct { 17 | Config *config.YAMLConfigurationRedis 18 | Client *redis.Client 19 | } 20 | 21 | // GetRedis to get redis client ready 22 | func (rm *RedisManager) GetRedis() *redis.Client { 23 | opt, err := redis.ParseURL(rm.Config.ConnectionString) 24 | if err != nil { 25 | // use current behavior 26 | return redis.NewClient(&redis.Options{ 27 | Addr: PrepareAddr(*rm.Config), 28 | Password: rm.Config.Password, 29 | DB: rm.Config.DB, 30 | }) 31 | } 32 | return redis.NewClient(opt) 33 | } 34 | 35 | // Check to verify if connection is open and ready 36 | func (rm *RedisManager) Check() error { 37 | ctx := context.TODO() 38 | if err := rm.Client.Ping(ctx).Err(); err != nil { 39 | return err 40 | } 41 | return nil 42 | } 43 | 44 | // CreateRedisManager to initialize the redis manager struct 45 | func CreateRedisManager(cfg config.YAMLConfigurationRedis) (*RedisManager, error) { 46 | rm := &RedisManager{} 47 | rm.Config = &cfg 48 | rm.Client = rm.GetRedis() 49 | if err := rm.Check(); err != nil { 50 | return nil, err 51 | } 52 | return rm, nil 53 | } 54 | -------------------------------------------------------------------------------- /deploy/cicd/deb/post-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ################### Reload osctrl-{{ OSCTRL_COMPONENT }} service ################### 6 | systemctl daemon-reload 7 | echo "osctrl-{{ OSCTRL_COMPONENT }} service daemon reloaded successfully." 8 | 9 | ################### Enable osctrl-{{ OSCTRL_COMPONENT }} service ################### 10 | systemctl enable osctrl-{{ OSCTRL_COMPONENT }}.service 11 | echo "osctrl-{{ OSCTRL_COMPONENT }} service enabled successfully." 12 | 13 | ################### Start osctrl-{{ OSCTRL_COMPONENT }} service ################### 14 | systemctl start osctrl-{{ OSCTRL_COMPONENT }}.service 15 | echo "osctrl-{{ OSCTRL_COMPONENT }} service started successfully." 16 | 17 | ################### Check osctrl-{{ OSCTRL_COMPONENT }} service status ################### 18 | systemctl status osctrl-{{ OSCTRL_COMPONENT }}.service --no-pager 19 | echo "osctrl-{{ OSCTRL_COMPONENT }} service status checked successfully." 20 | 21 | ################### Print osctrl-{{ OSCTRL_COMPONENT }} service logs ################### 22 | journalctl -u osctrl-{{ OSCTRL_COMPONENT }}.service --no-pager --since "10 minutes ago" 23 | echo "osctrl-{{ OSCTRL_COMPONENT }} service logs printed successfully." 24 | 25 | ################### Print osctrl-{{ OSCTRL_COMPONENT }} version ################### 26 | /opt/osctrl/bin/osctrl-{{ OSCTRL_COMPONENT }} --version 27 | echo "osctrl-{{ OSCTRL_COMPONENT }} version printed successfully." 28 | -------------------------------------------------------------------------------- /deploy/docker/conf/dev/air/.air-osctrl-admin.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format for osctrl-admin 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root 5 | root = "." 6 | tmp_dir = "/tmp" 7 | 8 | [build] 9 | bin = "./bin/osctrl-admin" 10 | pre_cmd = ["cp -R /usr/src/app/cmd/admin/templates/ /opt/osctrl/tmpl_admin", "cp -R /usr/src/app/cmd/admin/static/ /opt/osctrl/static"] 11 | cmd = "go build -ldflags \"-s -w -X main.buildCommit=$(git rev-parse HEAD) -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)\" -o /opt/osctrl/bin/osctrl-admin /usr/src/app/cmd/admin/*.go" 12 | # It's not necessary to trigger build each time file changes if it's too frequent. 13 | delay = 1000 14 | exclude_dir = ["tmp", "vendor", "testdata", "deploy", "api", "cli", "tls"] 15 | exclude_file = [] 16 | exclude_regex = ["_test\\.go"] 17 | exclude_unchanged = false 18 | follow_symlink = false 19 | full_bin = "cd /opt/osctrl && ./bin/osctrl-admin" 20 | include_dir = [] 21 | include_ext = ["go"] 22 | kill_delay = "0s" 23 | log = "build-errors.log" 24 | send_interrupt = false 25 | stop_on_error = true 26 | 27 | [color] 28 | app = "" 29 | build = "yellow" 30 | main = "magenta" 31 | runner = "green" 32 | watcher = "cyan" 33 | 34 | [log] 35 | time = true 36 | 37 | [misc] 38 | clean_on_exit = false 39 | 40 | [screen] 41 | clear_on_rebuild = false 42 | -------------------------------------------------------------------------------- /deploy/docker/conf/osquery/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ENV_NAME="${ENV_NAME:=dev}" 4 | HOST="${HOST:=nginx}" 5 | WAIT="${WAIT:=5}" 6 | 7 | if [ ! -f "/etc/osquery/osquery.secret" ]; then 8 | ######################################### Wait until DB is up ######################################### 9 | until /opt/osctrl/bin/osctrl-cli check-db 10 | do 11 | echo "DB is not ready" 12 | sleep $WAIT 13 | done 14 | 15 | ######################################### Osquery config ######################################### 16 | # Wait until for env to exist 17 | until /opt/osctrl/bin/osctrl-cli --db env show --name "${ENV_NAME}" 18 | do 19 | echo "${ENV_NAME} does not exist" 20 | sleep 3 21 | done 22 | 23 | # Get enroll secret 24 | /opt/osctrl/bin/osctrl-cli --db env node-actions --name "${ENV_NAME}" secret > /etc/osquery/osquery.secret 25 | 26 | # Get server cert 27 | echo "" | openssl s_client -connect ${HOST}:443 2>/dev/null | sed -n -e '/BEGIN\ CERTIFICATE/,/END\ CERTIFICATE/ p' > /etc/osquery/osctrl.crt 28 | 29 | # Get and set Osquery flags 30 | /opt/osctrl/bin/osctrl-cli --db env node-actions --name "${ENV_NAME}" show-flags > /etc/osquery/osquery.flags 31 | sed -i "s#__SECRET_FILE__#/etc/osquery/osquery.secret#g" /etc/osquery/osquery.flags 32 | echo "--tls_server_certs=/etc/osquery/osctrl.crt" >> /etc/osquery/osquery.flags 33 | fi 34 | 35 | # Run Osquery 36 | /opt/osquery/bin/osqueryd --flagfile=/etc/osquery/osquery.flags --verbose 37 | -------------------------------------------------------------------------------- /pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/jmpsec/osctrl/pkg/utils" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBytesReceivedConversionBytes(t *testing.T) { 11 | assert.NotEmpty(t, utils.BytesReceivedConversion(123)) 12 | assert.Equal(t, "123 bytes", utils.BytesReceivedConversion(123)) 13 | } 14 | 15 | func TestBytesReceivedConversionKBytes(t *testing.T) { 16 | assert.NotEmpty(t, utils.BytesReceivedConversion(1024)) 17 | assert.Equal(t, "1.0 KB", utils.BytesReceivedConversion(1024)) 18 | } 19 | 20 | func TestBytesReceivedConversionMBytes(t *testing.T) { 21 | assert.NotEmpty(t, utils.BytesReceivedConversion(1048576)) 22 | assert.Equal(t, "1.0 MB", utils.BytesReceivedConversion(1048576)) 23 | } 24 | 25 | func TestRandomForNames(t *testing.T) { 26 | assert.NotEmpty(t, utils.RandomForNames()) 27 | assert.Equal(t, 32, len(utils.RandomForNames())) 28 | } 29 | 30 | func TestIntersect(t *testing.T) { 31 | var slice1 = []uint{1, 2, 3, 4, 5} 32 | var slice2 = []uint{3, 4, 5, 6, 7} 33 | var expected = []uint{3, 4, 5} 34 | assert.Equal(t, expected, utils.Intersect(slice1, slice2)) 35 | slice1 = utils.Intersect(slice1, slice2) 36 | assert.Equal(t, expected, slice1) 37 | } 38 | 39 | func TestIntersectEmpty(t *testing.T) { 40 | var slice1 = []uint{} 41 | var slice2 = []uint{3, 4, 5, 6, 7} 42 | var expected = []uint{3, 4, 5, 6, 7} 43 | assert.Equal(t, expected, utils.Intersect(slice1, slice2)) 44 | } 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Like any other open source projects, there are multiple ways to contribute to osctrl: 4 | 5 | * As a developer, depending on your skills and experience, 6 | * As a user who enjoys the project and wants to help. 7 | 8 | ##### Reporting Bugs 9 | 10 | If you found something broken or not working properly, feel free to create an issue in Github with as much information as possible, such as logs and how to reproduce the problem. Before opening the issue, make sure that: 11 | 12 | * You have read this documentation, 13 | * You are using the latest stable version of osctrl, 14 | * You already searched other issues to see if your problem or request was already reported. 15 | 16 | ##### Improving the Documentation 17 | 18 | You can improve this documentation by forking its repository, updating the content and sending a pull request. 19 | 20 | 21 | #### We ❤️ Pull Requests 22 | 23 | A pull request does not need to be a fix for a bug or implementing something new. Software can always be improved, legacy code removed and tests are always welcome! 24 | 25 | Please do not be afraid of contributing code, make sure it follows these rules: 26 | 27 | * Your code compiles, does not break any of the existing code in the master branch and does not cause conflicts, 28 | * The code is readable and has comments, that aren’t superfluous or unnecessary, 29 | * An overview or context is provided as body of the Pull Request. It does not need to be too extensive. 30 | 31 | Extra points if your code comes with tests! 32 | -------------------------------------------------------------------------------- /cmd/admin/saml.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/crewjam/saml" 12 | "github.com/crewjam/saml/samlsp" 13 | "github.com/jmpsec/osctrl/pkg/config" 14 | ) 15 | 16 | // Structure to keep all SAML related data 17 | type samlThings struct { 18 | RootURL *url.URL 19 | IdpMetadataURL *url.URL 20 | IdpMetadata *saml.EntityDescriptor 21 | KeyPair tls.Certificate 22 | } 23 | 24 | // Function to initialize variables when using SAML for authentication 25 | func keypairSAML(config config.YAMLConfigurationSAML) (samlThings, error) { 26 | var data samlThings 27 | var err error 28 | data.KeyPair, err = tls.LoadX509KeyPair(config.CertPath, config.KeyPath) 29 | if err != nil { 30 | return data, fmt.Errorf("loadX509KeyPair %w", err) 31 | } 32 | data.KeyPair.Leaf, err = x509.ParseCertificate(data.KeyPair.Certificate[0]) 33 | if err != nil { 34 | return data, fmt.Errorf("parseCertificate %w", err) 35 | } 36 | data.IdpMetadataURL, err = url.Parse(config.MetaDataURL) 37 | if err != nil { 38 | return data, fmt.Errorf("parse MetadataURL %w", err) 39 | } 40 | data.IdpMetadata, err = samlsp.FetchMetadata(context.Background(), http.DefaultClient, *data.IdpMetadataURL) 41 | if err != nil { 42 | return data, fmt.Errorf("fetch Metadata %w", err) 43 | } 44 | data.RootURL, err = url.Parse(config.RootURL) 45 | if err != nil { 46 | return data, fmt.Errorf("parse RootURL %w", err) 47 | } 48 | return data, nil 49 | } 50 | -------------------------------------------------------------------------------- /deploy/config/tls.yaml: -------------------------------------------------------------------------------- 1 | # YAML configuration for osctrl-tls 2 | 3 | service: 4 | listener: "127.0.0.1" 5 | port: 1234 6 | # Valid values: "debug", "info", "warn", "error" 7 | logLevel: "info" 8 | # Valid values: "json", "console" 9 | logFormat: "json" 10 | host: "osctrl-tls-dev" 11 | # Valid values: "none", "json", "db", "saml", "oidc", "oauth" 12 | auth: "none" 13 | 14 | db: 15 | host: "127.0.0.1" 16 | port: 5432 17 | name: "osctrl" 18 | username: "osctrl-db-user" 19 | password: "osctrl-db-pass" 20 | sslmode: "disable" 21 | maxIdleConns: 20 22 | maxOpenConns: 100 23 | connMaxLifetime: 30 24 | connRetry: 10 25 | 26 | batchWriter: 27 | writerBatchSize: 50 28 | writerTimeout: 60 29 | writerBufferSize: 2000 30 | 31 | redis: 32 | host: "127.0.0.1" 33 | port: 6379 34 | password: "osctrl-redis-pass" 35 | connectionString: "" 36 | db: 0 37 | connRetry: 10 38 | 39 | metrics: 40 | enabled: false 41 | listener: "0.0.0.0" 42 | port: 9090 43 | 44 | osctrld: 45 | enabled: false 46 | 47 | tls: 48 | termination: false 49 | certificateFile: "/path/to/osctrl.pem" 50 | keyFile: "/path/to/osctrl.key" 51 | 52 | logger: 53 | # Valid values: "none", "stdout", "file", "db", "graylog", "splunk", "logstash", "kinesis", "s3", "kafka", "elastic" 54 | type: "db" 55 | loggerDBSame: false 56 | alwaysLog: false 57 | 58 | 59 | 60 | carver: 61 | # Valid values: "none", "local", "db", "s3" 62 | type: "db" 63 | 64 | debug: 65 | enableHttp: false 66 | httpFile: "debug-http-tls.log" 67 | showBody: false 68 | -------------------------------------------------------------------------------- /cmd/admin/static/css/osquery.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'osquery'; 3 | src: url('/static/fonts/osquery.eot?zcdbov'); 4 | src: url('/static/fonts/osquery.eot?zcdbov#iefix') format('embedded-opentype'), 5 | url('/static/fonts/osquery.ttf?zcdbov') format('truetype'), 6 | url('/static/fonts/osquery.woff?zcdbov') format('woff'), 7 | url('/static/fonts/osquery.woff2?zcdbov') format('woff2'), 8 | url('/static/fonts/osquery.svg?zcdbov#osquery') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | [class^="icon-"], [class*=" icon-"] { 14 | /* use !important to prevent issues with browser extensions that change fonts */ 15 | font-family: 'osquery' !important; 16 | speak: none; 17 | font-style: normal; 18 | font-weight: normal; 19 | font-variant: normal; 20 | text-transform: none; 21 | line-height: 1; 22 | 23 | /* Better Font Rendering =========== */ 24 | -webkit-font-smoothing: antialiased; 25 | -moz-osx-font-smoothing: grayscale; 26 | } 27 | 28 | .icon-osquery_gray .path1:before { 29 | content: "\e903"; 30 | color: rgb(0, 0, 0); 31 | } 32 | .icon-osquery_gray .path2:before { 33 | content: "\e904"; 34 | margin-left: -1em; 35 | color: rgb(160, 160, 160); 36 | } 37 | .icon-osquery .path1:before { 38 | content: "\e900"; 39 | color: rgb(0, 0, 0); 40 | } 41 | .icon-osquery .path2:before { 42 | content: "\e901"; 43 | margin-left: -1em; 44 | color: rgb(111, 101, 171); 45 | } 46 | .icon-osquery .path3:before { 47 | content: "\e902"; 48 | margin-left: -1em; 49 | color: rgb(165, 150, 255); 50 | } 51 | -------------------------------------------------------------------------------- /pkg/auditlog/utils.go: -------------------------------------------------------------------------------- 1 | package auditlog 2 | 3 | const ( 4 | // Log type strings 5 | LogTypeLoginStr = "Login" 6 | LogTypeLogoutStr = "Logout" 7 | LogTypeNodeStr = "Node" 8 | LogTypeQueryStr = "Query" 9 | LogTypeCarveStr = "Carve" 10 | LogTypeTagStr = "Tag" 11 | LogTypeEnvStr = "Environment" 12 | LogTypeSettingStr = "Setting" 13 | LogTypeVisitStr = "Visit" 14 | LogTypeUserStr = "User" 15 | LogTypeUnknown = "Unknown" 16 | // Severity strings 17 | SeverityInfoStr = "Info" 18 | SeverityWarningStr = "Warning" 19 | SeverityErrorStr = "Error" 20 | SeverityUnknownStr = "Unknown" 21 | ) 22 | 23 | // LogTypeToString to convert log type to string 24 | func (m *AuditLogManager) LogTypeToString(logType uint) string { 25 | switch logType { 26 | case 1: 27 | return LogTypeLoginStr 28 | case 2: 29 | return LogTypeLogoutStr 30 | case 3: 31 | return LogTypeNodeStr 32 | case 4: 33 | return LogTypeQueryStr 34 | case 5: 35 | return LogTypeCarveStr 36 | case 6: 37 | return LogTypeTagStr 38 | case 7: 39 | return LogTypeEnvStr 40 | case 8: 41 | return LogTypeSettingStr 42 | case 9: 43 | return LogTypeVisitStr 44 | case 10: 45 | return LogTypeUserStr 46 | default: 47 | return LogTypeUnknown 48 | } 49 | } 50 | 51 | // SeverityToString to convert severity to string 52 | func (m *AuditLogManager) SeverityToString(severity uint) string { 53 | switch severity { 54 | case 1: 55 | return SeverityInfoStr 56 | case 2: 57 | return SeverityWarningStr 58 | case 3: 59 | return SeverityErrorStr 60 | default: 61 | return SeverityUnknownStr 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/utils/string-utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "strconv" 7 | 8 | "github.com/google/uuid" 9 | "github.com/segmentio/ksuid" 10 | ) 11 | 12 | const ( 13 | errorRandomString string = "SomethingRandomWentWrong" 14 | ) 15 | 16 | // GenRandomString - Helper to generate a random string of n characters 17 | func GenRandomString(n int) string { 18 | b := make([]byte, n) 19 | _, err := rand.Read(b) 20 | // Note that err == nil only if we read len(b) bytes. 21 | if err != nil { 22 | return errorRandomString 23 | } 24 | return base64.URLEncoding.EncodeToString(b)[:n] 25 | } 26 | 27 | // GenKSUID - Helper to generate a KSUID 28 | // See https://github.com/segmentio/ksuid for more info about KSUIDs 29 | func GenKSUID() string { 30 | return ksuid.New().String() 31 | } 32 | 33 | // GenUUID - Helper to generate a UUID 34 | // See https://github.com/google/uuid for more info about UUIDs 35 | func GenUUID() string { 36 | return uuid.New().String() 37 | } 38 | 39 | // CheckUUID - Helper to check if a string is a valid UUID 40 | func CheckUUID(s string) bool { 41 | _, err := uuid.Parse(s) 42 | return err == nil 43 | } 44 | 45 | // StringToInteger - Helper to convert a string into integer 46 | func StringToInteger(s string) int64 { 47 | v, err := strconv.ParseInt(s, 10, 64) 48 | if err != nil { 49 | return 0 50 | } 51 | return v 52 | } 53 | 54 | // StringToBoolean - Helper to convert a string into boolean 55 | func StringToBoolean(s string) bool { 56 | if s == "yes" || s == "true" || s == "1" { 57 | return true 58 | } 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /cmd/admin/static/js/settings.js: -------------------------------------------------------------------------------- 1 | function addSetting() { 2 | $("#addSettingModal").modal(); 3 | } 4 | 5 | function confirmAddSetting() { 6 | var _csrftoken = $("#csrftoken").val(); 7 | 8 | var _url = window.location.pathname; 9 | 10 | var _name = $("#setting_name").val(); 11 | var _type = $("#setting_type").val(); 12 | var _value = $("#setting_value").val(); 13 | 14 | var data = { 15 | csrftoken: _csrftoken, 16 | action: 'add', 17 | name: _name, 18 | type: _type, 19 | value: _value, 20 | }; 21 | sendPostRequest(data, _url, _url, false); 22 | } 23 | 24 | function confirmDeleteSetting(_name) { 25 | var modal_message = 'Are you sure you want to delete the setting ' + _name + '?'; 26 | $("#confirmModalMessage").text(modal_message); 27 | $('#confirm_action').click(function () { 28 | $('#confirmModal').modal('hide'); 29 | deleteSetting(_name); 30 | }); 31 | $("#confirmModal").modal(); 32 | } 33 | 34 | function deleteSetting(_name) { 35 | var _csrftoken = $("#csrftoken").val(); 36 | 37 | var _url = window.location.pathname; 38 | 39 | var _type = $("#setting_type").val(); 40 | var _value = $("#setting_value").val(); 41 | 42 | var data = { 43 | csrftoken: _csrftoken, 44 | action: 'delete', 45 | name: _name, 46 | }; 47 | sendPostRequest(data, _url, _url, false); 48 | } 49 | 50 | function changeBooleanSetting(_name) { 51 | var _csrftoken = $("#csrftoken").val(); 52 | var _value = $("#" + _name).is(':checked'); 53 | 54 | var _url = window.location.pathname; 55 | 56 | var data = { 57 | csrftoken: _csrftoken, 58 | action: 'change', 59 | name: _name, 60 | type: 'boolean', 61 | boolean: _value, 62 | }; 63 | sendPostRequest(data, _url, '', false); 64 | } 65 | -------------------------------------------------------------------------------- /pkg/tags/utils_test.go: -------------------------------------------------------------------------------- 1 | package tags 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRandomColor(t *testing.T) { 10 | color1 := RandomColor() 11 | color2 := RandomColor() 12 | assert.NotEqual(t, false, color1 != color2) 13 | } 14 | 15 | func TestGetHex(t *testing.T) { 16 | assert.Equal(t, "00", GetHex(0)) 17 | assert.Equal(t, "0a", GetHex(10)) 18 | assert.Equal(t, "ff", GetHex(255)) 19 | } 20 | 21 | func TestTagTypeDecorator(t *testing.T) { 22 | assert.Equal(t, "env", TagTypeDecorator(0)) 23 | assert.Equal(t, "platform", TagTypeDecorator(2)) 24 | assert.Equal(t, "custom", TagTypeDecorator(4)) 25 | assert.Equal(t, "unknown", TagTypeDecorator(5)) 26 | } 27 | 28 | func TestTagTypeParser(t *testing.T) { 29 | assert.Equal(t, uint(0), TagTypeParser("env")) 30 | assert.Equal(t, uint(2), TagTypeParser("platform")) 31 | assert.Equal(t, uint(4), TagTypeParser("custom")) 32 | assert.Equal(t, uint(5), TagTypeParser("unknown")) 33 | } 34 | 35 | func TestTagCustom(t *testing.T) { 36 | assert.Equal(t, "env", SetCustomTag(0, "CUSTOM-VALUE")) 37 | assert.Equal(t, "platform", SetCustomTag(2, "CUSTOM-VALUE")) 38 | assert.Equal(t, "CUSTOM-VALUE", SetCustomTag(4, "CUSTOM-VALUE")) 39 | assert.Equal(t, "unknown", SetCustomTag(5, "CUSTOM-VALUE")) 40 | } 41 | 42 | func TestGetStrTagName(t *testing.T) { 43 | assert.Equal(t, "VALUE", GetStrTagName("custom:VALUE")) 44 | assert.Equal(t, "VALUE:EXTRA", GetStrTagName("custom:VALUE:EXTRA")) 45 | assert.Equal(t, "VALUE", GetStrTagName("VALUE")) 46 | } 47 | 48 | func TestValidateCustom(t *testing.T) { 49 | assert.Equal(t, "custom", ValidateCustom("custom")) 50 | assert.Equal(t, "unknown", ValidateCustom("anything")) 51 | assert.Equal(t, "unknown", ValidateCustom("")) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/admin/static/js/environments.js: -------------------------------------------------------------------------------- 1 | function createEnvironment() { 2 | $("#createEnvironmentModal").modal(); 3 | } 4 | 5 | function confirmCreateEnvironment() { 6 | var _csrftoken = $("#csrftoken").val(); 7 | 8 | var _url = window.location.pathname; 9 | 10 | var _name = $("#environment_name").val(); 11 | var _type = $("#environment_type").val(); 12 | var _hostname = $("#environment_host").val(); 13 | var _icon = $("#environment_icon").val(); 14 | 15 | var data = { 16 | csrftoken: _csrftoken, 17 | action: 'create', 18 | name: _name, 19 | type: _type, 20 | hostname: _hostname, 21 | icon: _icon, 22 | }; 23 | sendPostRequest(data, _url, _url, false); 24 | } 25 | 26 | function confirmDeleteEnvironment(_env) { 27 | var modal_message = 'Are you sure you want to delete the environment ' + _env + '?'; 28 | $("#confirmModalMessage").text(modal_message); 29 | $('#confirm_action').click(function () { 30 | $('#confirmModal').modal('hide'); 31 | deleteEnvironment(_env); 32 | }); 33 | $("#confirmModal").modal(); 34 | } 35 | 36 | function deleteEnvironment(_env) { 37 | var _csrftoken = $("#csrftoken").val(); 38 | 39 | var _url = window.location.pathname; 40 | 41 | var data = { 42 | csrftoken: _csrftoken, 43 | action: 'delete', 44 | name: _env, 45 | }; 46 | sendPostRequest(data, _url, _url, false); 47 | } 48 | 49 | function changeDebugHTTP(_env) { 50 | var _csrftoken = $("#csrftoken").val(); 51 | var _value = $("#" + _env + "_debug_check").is(':checked'); 52 | 53 | var _url = window.location.pathname; 54 | 55 | var data = { 56 | csrftoken: _csrftoken, 57 | action: 'debug', 58 | debughttp: _value, 59 | name: _env, 60 | }; 61 | sendPostRequest(data, _url, '', false); 62 | } 63 | -------------------------------------------------------------------------------- /cmd/tls/settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jmpsec/osctrl/pkg/config" 7 | "github.com/jmpsec/osctrl/pkg/settings" 8 | ) 9 | 10 | // Function to load all settings for the service 11 | func loadingSettings(mgr *settings.Settings, cfg *config.ServiceParameters) error { 12 | // Check if service settings for accelerated seconds is ready 13 | if !mgr.IsValue(config.ServiceTLS, settings.AcceleratedSeconds, settings.NoEnvironmentID) { 14 | if err := mgr.NewIntegerValue(config.ServiceTLS, settings.AcceleratedSeconds, int64(defaultAccelerate), settings.NoEnvironmentID); err != nil { 15 | return fmt.Errorf("failed to add %s to configuration: %w", settings.AcceleratedSeconds, err) 16 | } 17 | } 18 | // Check if service settings for environments refresh is ready 19 | if !mgr.IsValue(config.ServiceTLS, settings.RefreshEnvs, settings.NoEnvironmentID) { 20 | if err := mgr.NewIntegerValue(config.ServiceTLS, settings.RefreshEnvs, int64(defaultRefresh), settings.NoEnvironmentID); err != nil { 21 | return fmt.Errorf("failed to add %s to configuration: %w", settings.RefreshEnvs, err) 22 | } 23 | } 24 | // Check if service settings for enroll/remove oneliner links is ready 25 | if !mgr.IsValue(config.ServiceTLS, settings.OnelinerExpiration, settings.NoEnvironmentID) { 26 | if err := mgr.NewBooleanValue(config.ServiceTLS, settings.OnelinerExpiration, defaultOnelinerExpiration, settings.NoEnvironmentID); err != nil { 27 | return fmt.Errorf("failed to add %s to configuration: %w", settings.OnelinerExpiration, err) 28 | } 29 | } 30 | // Write JSON config to settings 31 | if err := mgr.SetTLSJSON(cfg, settings.NoEnvironmentID); err != nil { 32 | return fmt.Errorf("failed to add JSON values to configuration: %w", err) 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /cmd/api/settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jmpsec/osctrl/pkg/config" 7 | "github.com/jmpsec/osctrl/pkg/settings" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // Function to load all settings for the service 12 | func loadingSettings(mgr *settings.Settings, cfg *config.ServiceParameters) error { 13 | log.Debug().Msg("Initializing settings") 14 | // Check if service settings for metrics is ready, initialize if so 15 | if !mgr.IsValue(config.ServiceAPI, settings.ServiceMetrics, settings.NoEnvironmentID) { 16 | if err := mgr.NewBooleanValue(config.ServiceAPI, settings.ServiceMetrics, false, settings.NoEnvironmentID); err != nil { 17 | return fmt.Errorf("failed to add %s to settings: %w", settings.ServiceMetrics, err) 18 | } 19 | } 20 | // Check if service settings for environments refresh is ready 21 | if !mgr.IsValue(config.ServiceAPI, settings.RefreshEnvs, settings.NoEnvironmentID) { 22 | if err := mgr.NewIntegerValue(config.ServiceAPI, settings.RefreshEnvs, int64(defaultRefresh), settings.NoEnvironmentID); err != nil { 23 | return fmt.Errorf("failed to add %s to settings: %w", settings.RefreshEnvs, err) 24 | } 25 | } 26 | // Check if service settings for settings refresh is ready 27 | if !mgr.IsValue(config.ServiceAPI, settings.RefreshSettings, settings.NoEnvironmentID) { 28 | if err := mgr.NewIntegerValue(config.ServiceAPI, settings.RefreshSettings, int64(defaultRefresh), settings.NoEnvironmentID); err != nil { 29 | return fmt.Errorf("failed to add %s to settings: %w", settings.RefreshSettings, err) 30 | } 31 | } 32 | // Write JSON config to settings 33 | if err := mgr.SetAPIJSON(cfg, settings.NoEnvironmentID); err != nil { 34 | return fmt.Errorf("failed to add JSON values to configuration: %w", err) 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /cmd/admin/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/jmpsec/osctrl/pkg/config" 10 | "github.com/jmpsec/osctrl/pkg/types" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // Function to load the JSON data for osquery tables 15 | func loadOsqueryTables(file string) ([]types.OsqueryTable, error) { 16 | var tables []types.OsqueryTable 17 | jsonFile, err := os.Open(file) 18 | if err != nil { 19 | return tables, err 20 | } 21 | defer func() { 22 | if err := jsonFile.Close(); err != nil { 23 | log.Fatal().Msgf("Failed to close tables file %v", err) 24 | } 25 | }() 26 | byteValue, _ := io.ReadAll(jsonFile) 27 | if err := json.Unmarshal(byteValue, &tables); err != nil { 28 | return tables, err 29 | } 30 | // Add a string for platforms to be used as filter 31 | for i, t := range tables { 32 | filter := "" 33 | for _, p := range t.Platforms { 34 | filter += " filter-" + p 35 | } 36 | tables[i].Filter = strings.TrimSpace(filter) 37 | } 38 | return tables, nil 39 | } 40 | 41 | // Helper to convert YAML settings loaded from file to settings 42 | func loadedYAMLToServiceParams(yml config.AdminConfiguration, loadedFile string) *config.ServiceParameters { 43 | return &config.ServiceParameters{ 44 | ConfigFlag: true, 45 | ServiceConfigFile: loadedFile, 46 | Service: &yml.Service, 47 | DB: &yml.DB, 48 | Redis: &yml.Redis, 49 | Osquery: &yml.Osquery, 50 | Osctrld: &yml.Osctrld, 51 | SAML: &yml.SAML, 52 | JWT: &yml.JWT, 53 | TLS: &yml.TLS, 54 | Logger: &yml.Logger, 55 | Carver: &yml.Carver, 56 | Admin: &yml.Admin, 57 | Debug: &yml.Debug, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "fmt" 8 | ) 9 | 10 | const bytesUnit = 1024 11 | 12 | // BytesReceivedConversion - Helper to format bytes received into KB, MB, TB... Binary format 13 | func BytesReceivedConversion(b int) string { 14 | if b < bytesUnit { 15 | return fmt.Sprintf("%d bytes", b) 16 | } 17 | div, exp := int64(bytesUnit), 0 18 | for n := b / bytesUnit; n >= bytesUnit; n /= bytesUnit { 19 | div *= bytesUnit 20 | exp++ 21 | } 22 | return fmt.Sprintf("%.1f %cB", 23 | float64(b)/float64(div), "KMGTPE"[exp]) 24 | } 25 | 26 | // Helper to generate a random MD5 to be used with queries/carves 27 | func RandomForNames() string { 28 | b := make([]byte, 32) 29 | _, _ = rand.Read(b) 30 | hasher := md5.New() 31 | _, _ = hasher.Write([]byte(fmt.Sprintf("%x", b))) 32 | return hex.EncodeToString(hasher.Sum(nil)) 33 | } 34 | 35 | // Intersect returns the intersection of two slices of uints 36 | func Intersect(slice1, slice2 []uint) []uint { 37 | if len(slice1) == 0 { 38 | return slice2 39 | } 40 | // If slice2 is empty, return slice1 41 | if len(slice2) == 0 { 42 | return slice1 43 | } 44 | set := make(map[uint]struct{}) 45 | for _, item := range slice1 { 46 | set[item] = struct{}{} // Add items from slice1 to the set 47 | } 48 | intersection := []uint{} 49 | for _, item := range slice2 { 50 | if _, exists := set[item]; exists { 51 | intersection = append(intersection, item) 52 | delete(set, item) // Ensure uniqueness in the result 53 | } 54 | } 55 | return intersection 56 | } 57 | 58 | // Contains checks if string is in the slice 59 | func Contains(all []string, target string) bool { 60 | for _, s := range all { 61 | if s == target { 62 | return true 63 | } 64 | } 65 | return false 66 | } 67 | -------------------------------------------------------------------------------- /deploy/nginx/ssl.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen PUBLIC_PORT ssl default deferred; 3 | 4 | ssl_certificate CER_FILE; 5 | ssl_certificate_key KEY_FILE; 6 | 7 | # Improve HTTPS performance with session resumption 8 | ssl_session_cache shared:SSL:10m; 9 | ssl_session_timeout 5m; 10 | 11 | # Enable server-side protection against BEAST attacks 12 | ssl_prefer_server_ciphers on; 13 | ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5; 14 | 15 | # Disable SSLv3 16 | ssl_protocols TLSv1.2 TLSv1.3; 17 | 18 | # Diffie-Hellman parameter for DHE ciphersuites 19 | # $ sudo openssl dhparam -out /etc/ssl/certs/dhparam.pem 4096 20 | ssl_dhparam DHPARAM_FILE; 21 | 22 | # Enable OCSP stapling (http://blog.mozilla.org/security/2013/07/29/ocsp-stapling-in-firefox) 23 | ssl_stapling on; 24 | ssl_stapling_verify on; 25 | ssl_trusted_certificate CER_FILE; 26 | resolver 1.1.1.1 8.8.8.8 valid=300s; 27 | resolver_timeout 5s; 28 | 29 | # Enable HSTS (https://developer.mozilla.org/en-US/docs/Security/HTTP_Strict_Transport_Security) 30 | add_header Strict-Transport-Security "max-age=63072000; includeSubdomains"; 31 | add_header X-Frame-Options DENY; 32 | 33 | add_header Cache-Control "no-cache, no-store"; 34 | add_header Pragma "no-cache"; 35 | expires -1; 36 | 37 | location / { 38 | proxy_set_header Host $host; 39 | proxy_set_header X-Real-IP $remote_addr; 40 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 41 | proxy_set_header X-Forwarded-Proto $scheme; 42 | 43 | # Fix the “It appears that your reverse proxy set up is broken" error. 44 | proxy_pass http://PRIVATE_HOST:PRIVATE_PORT; 45 | proxy_read_timeout 90; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/logging/stdout.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "github.com/jmpsec/osctrl/pkg/settings" 5 | "github.com/jmpsec/osctrl/pkg/types" 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | // LoggerStdout will be used to log data using stdout 10 | type LoggerStdout struct { 11 | Enabled bool 12 | } 13 | 14 | // CreateLoggerStdout to initialize the logger 15 | func CreateLoggerStdout() (*LoggerStdout, error) { 16 | return &LoggerStdout{ 17 | Enabled: true, 18 | }, nil 19 | } 20 | 21 | // Settings - Function to prepare settings for the logger 22 | func (logStdout *LoggerStdout) Settings(mgr *settings.Settings) { 23 | log.Info().Msg("No stdout logging settings") 24 | } 25 | 26 | // Log - Function that sends JSON result/status/query logs to stdout 27 | func (logStdout *LoggerStdout) Log(logType string, data []byte, environment, uuid string, debug bool) { 28 | switch logType { 29 | case types.StatusLog: 30 | logStdout.Status(data, environment, uuid, debug) 31 | case types.ResultLog: 32 | logStdout.Result(data, environment, uuid, debug) 33 | } 34 | } 35 | 36 | // Status - Function that sends JSON status logs to stdout 37 | func (logStdout *LoggerStdout) Status(data []byte, environment, uuid string, debug bool) { 38 | log.Info().Msgf("Status: %s:%s - %d bytes [%s]", environment, uuid, len(data), string(data)) 39 | } 40 | 41 | // Result - Function that sends JSON result logs to stdout 42 | func (logStdout *LoggerStdout) Result(data []byte, environment, uuid string, debug bool) { 43 | log.Info().Msgf("Result: %s:%s - %d bytes [%s]", environment, uuid, len(data), string(data)) 44 | } 45 | 46 | // Query - Function that sends JSON query logs to stdout 47 | func (logStdout *LoggerStdout) Query(data []byte, environment, uuid, name string, status int, debug bool) { 48 | log.Info().Msgf("Query: %s:%d - %s:%s - %d bytes [%s]", name, status, environment, uuid, len(data), string(data)) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/cache/metrics.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | // Metric names and help text 8 | const ( 9 | cacheHitsName = "osctrl_cache_hits_total" 10 | cacheHitsHelp = "Total number of cache hits" 11 | cacheMissesName = "osctrl_cache_misses_total" 12 | cacheMissesHelp = "Total number of cache misses" 13 | cacheEvictionsName = "osctrl_cache_evictions_total" 14 | cacheEvictionsHelp = "Total number of cache evictions" 15 | cacheItemsName = "osctrl_cache_items" 16 | cacheItemsHelp = "Current number of items in cache" 17 | ) 18 | 19 | var ( 20 | // CacheHits tracks the number of cache hits 21 | CacheHits = prometheus.NewCounterVec( 22 | prometheus.CounterOpts{ 23 | Name: cacheHitsName, 24 | Help: cacheHitsHelp, 25 | }, 26 | []string{"cache_name"}, 27 | ) 28 | 29 | // CacheMisses tracks the number of cache misses 30 | CacheMisses = prometheus.NewCounterVec( 31 | prometheus.CounterOpts{ 32 | Name: cacheMissesName, 33 | Help: cacheMissesHelp, 34 | }, 35 | []string{"cache_name"}, 36 | ) 37 | 38 | // CacheEvictions tracks the number of cache evictions 39 | CacheEvictions = prometheus.NewCounterVec( 40 | prometheus.CounterOpts{ 41 | Name: cacheEvictionsName, 42 | Help: cacheEvictionsHelp, 43 | }, 44 | []string{"cache_name"}, 45 | ) 46 | 47 | // CacheItems tracks the current number of items in the cache 48 | CacheItems = prometheus.NewGaugeVec( 49 | prometheus.GaugeOpts{ 50 | Name: cacheItemsName, 51 | Help: cacheItemsHelp, 52 | }, 53 | []string{"cache_name"}, 54 | ) 55 | ) 56 | 57 | // RegisterMetrics registers all cache metrics with the provided registerer 58 | func RegisterMetrics(reg prometheus.Registerer) { 59 | reg.MustRegister(CacheHits) 60 | reg.MustRegister(CacheMisses) 61 | reg.MustRegister(CacheEvictions) 62 | reg.MustRegister(CacheItems) 63 | } 64 | -------------------------------------------------------------------------------- /cmd/admin/templates/components/page-header.html: -------------------------------------------------------------------------------- 1 | {{ define "page-header" }} 2 | 3 | {{ with .Metadata }} 4 | 5 | 43 | 44 | {{ end }} 45 | 46 | {{ end }} 47 | -------------------------------------------------------------------------------- /pkg/logging/none.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "github.com/jmpsec/osctrl/pkg/settings" 5 | "github.com/jmpsec/osctrl/pkg/types" 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | // LoggerNone will be used to not log any data 10 | type LoggerNone struct { 11 | Enabled bool 12 | } 13 | 14 | // CreateLoggerNone to initialize the logger 15 | func CreateLoggerNone() (*LoggerNone, error) { 16 | return &LoggerNone{Enabled: true}, nil 17 | } 18 | 19 | // Settings - Function to prepare settings for the logger 20 | func (logNone *LoggerNone) Settings(mgr *settings.Settings) { 21 | log.Info().Msg("No none logging settings") 22 | } 23 | 24 | // Log - Function that sends JSON result/status/query logs to stdout 25 | func (logNone *LoggerNone) Log(logType string, data []byte, environment, uuid string, debug bool) { 26 | if debug { 27 | log.Debug().Msgf("Sending %d bytes to none for %s - %s", len(data), environment, uuid) 28 | } 29 | switch logType { 30 | case types.StatusLog: 31 | logNone.Status(data, environment, uuid, debug) 32 | case types.ResultLog: 33 | logNone.Result(data, environment, uuid, debug) 34 | } 35 | } 36 | 37 | // Status - Function that sends JSON status logs to stdout 38 | func (logNone *LoggerNone) Status(data []byte, environment, uuid string, debug bool) { 39 | log.Info().Msgf("Skipping to log %d bytes of status from %s/%s", len(data), environment, uuid) 40 | } 41 | 42 | // Result - Function that sends JSON result logs to stdout 43 | func (logNone *LoggerNone) Result(data []byte, environment, uuid string, debug bool) { 44 | log.Info().Msgf("Skipping to log %d bytes of result from %s/%s", len(data), environment, uuid) 45 | } 46 | 47 | // Query - Function that sends JSON query logs to stdout 48 | func (logNone *LoggerNone) Query(data []byte, environment, uuid, name string, status int, debug bool) { 49 | log.Info().Msgf("Skipping to log %d bytes of query from %s/%s for query %s and status %d", len(data), environment, uuid, name, status) 50 | } 51 | -------------------------------------------------------------------------------- /deploy/docker/conf/cli/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ENV_NAME="${ENV_NAME:=dev}" 4 | CERT_FILE="${CERT_FILE:=/opt/osctrl/config/osctrl.crt}" 5 | HOST="${HOST:=nginx}" 6 | OSCTRL_USER="${OSCTRL_USER:=admin}" 7 | OSCTRL_PASS="${OSCTRL_PASS:=admin}" 8 | WAIT="${WAIT:=5}" 9 | 10 | ######################################### OSCTRL_PASS ######################################### 11 | if [[ -n "$OSCTRL_PASS_FILE" ]]; then 12 | OSCTRL_PASS=$(cat ${OSCTRL_PASS_FILE}) 13 | fi 14 | 15 | ######################################### Wait until DB is up ######################################### 16 | until /opt/osctrl/bin/osctrl-cli check-db 17 | do 18 | echo "DB is not ready" 19 | sleep $WAIT 20 | done 21 | 22 | ######################################### Create environment ######################################### 23 | /opt/osctrl/bin/osctrl-cli --db env add \ 24 | --name "${ENV_NAME}" \ 25 | --hostname "${HOST}" \ 26 | --certificate "${CERT_FILE}" 27 | if [ $? -eq 0 ]; then 28 | echo "Created environment ${ENV_NAME}" 29 | else 30 | echo "Environment ${ENV_NAME} exists" 31 | fi 32 | 33 | ######################################### Create admin user ######################################### 34 | /opt/osctrl/bin/osctrl-cli --db user add \ 35 | --admin \ 36 | --username "${OSCTRL_USER}" \ 37 | --password "${OSCTRL_PASS}" \ 38 | --environment "${ENV_NAME}" \ 39 | --fullname "${OSCTRL_USER}" 40 | 41 | if [ $? -eq 0 ]; then 42 | echo "Created ${OSCTRL_USER} user" 43 | else 44 | echo "The user ${OSCTRL_USER} exists" 45 | fi 46 | 47 | echo "The environment ${ENV_NAME} is ready" 48 | 49 | echo " 50 | ############################################################################## 51 | # Successfully created an osctrl user and env 52 | # 53 | # osctrl admin user: ${OSCTRL_USER} 54 | # osctrl env name: ${ENV_NAME} 55 | ############################################################################## 56 | " 57 | 58 | # Start a shell to avoid re-running this script 59 | /bin/bash 60 | -------------------------------------------------------------------------------- /cmd/admin/templates/components/page-head-offline.html: -------------------------------------------------------------------------------- 1 | {{ define "page-head" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{ .Title }} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {{ end }} 53 | -------------------------------------------------------------------------------- /deploy/docker/dockerfiles/Dockerfile-dev-cli: -------------------------------------------------------------------------------- 1 | #################################################### osctrl-cli-dev #################################################### 2 | ARG GOLANG_VERSION=${GOLANG_VERSION:-1.25.4} 3 | FROM golang:${GOLANG_VERSION} AS osctrl-cli-dev 4 | 5 | WORKDIR /usr/src/app 6 | 7 | ENV GO111MODULE="on" 8 | ENV GOOS="linux" 9 | ENV CGO_ENABLED=0 10 | 11 | # Hot reloading mod 12 | RUN go install github.com/cosmtrek/air@v1.41.0 13 | RUN go install github.com/go-delve/delve/cmd/dlv@v1.20.1 14 | 15 | # Copy code 16 | COPY . /usr/src/app 17 | 18 | # Download deps 19 | RUN go mod download 20 | RUN go mod verify 21 | 22 | ### Copy osctrl-api bin and configs ### 23 | RUN mkdir -p /opt/osctrl/bin 24 | RUN mkdir -p /opt/osctrl/config 25 | RUN go build -ldflags "-s -w -X main.buildCommit=$(git rev-parse HEAD) -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o /opt/osctrl/bin/osctrl-cli cmd/cli/*.go 26 | 27 | #### User and env init script #### 28 | COPY deploy/docker/conf/cli/entrypoint.sh /opt/osctrl/script/init.sh 29 | RUN chmod +x /opt/osctrl/script/init.sh 30 | 31 | ENTRYPOINT ["air", "-c", "deploy/docker/conf/dev/air/.air-osctrl-cli.toml"] 32 | CMD ["/opt/osctrl/script/init.sh"] 33 | 34 | #################################################### osctrl-ubuntu-osquery #################################################### 35 | FROM osctrl-cli-dev AS osctrl-ubuntu-osquery 36 | 37 | ARG OSQUERY_VERSION 38 | 39 | USER root 40 | 41 | # Install Osquery 42 | RUN apt-get update -y -qq && apt-get install -y curl host 43 | RUN ubuntuArch="$(dpkg --print-architecture)"; \ 44 | curl -L https://pkg.osquery.io/deb/osquery_${OSQUERY_VERSION}-1.linux_${ubuntuArch}.deb \ 45 | --output /tmp/osquery_${OSQUERY_VERSION}-1.linux_${ubuntuArch}.deb 46 | RUN ubuntuArch="$(dpkg --print-architecture)"; \ 47 | dpkg -i /tmp/osquery_${OSQUERY_VERSION}-1.linux_${ubuntuArch}.deb 48 | 49 | # Entrypoint 50 | COPY deploy/docker/conf/osquery/entrypoint.sh /entrypoint.sh 51 | RUN chmod 755 /entrypoint.sh 52 | ENTRYPOINT [ "/entrypoint.sh" ] 53 | -------------------------------------------------------------------------------- /cmd/admin/templates/components/page-js-offline.html: -------------------------------------------------------------------------------- 1 | {{ define "page-js" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {{ end }} 50 | -------------------------------------------------------------------------------- /pkg/nodes/models.go: -------------------------------------------------------------------------------- 1 | package nodes 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // OsqueryNode as abstraction of a node 10 | type OsqueryNode struct { 11 | gorm.Model 12 | NodeKey string `gorm:"index"` 13 | UUID string `gorm:"index"` 14 | Platform string 15 | PlatformVersion string 16 | OsqueryVersion string 17 | Hostname string 18 | Localname string 19 | IPAddress string 20 | Username string 21 | OsqueryUser string 22 | Environment string 23 | CPU string 24 | Memory string 25 | HardwareSerial string 26 | DaemonHash string 27 | ConfigHash string 28 | BytesReceived int 29 | RawEnrollment string 30 | LastSeen time.Time 31 | UserID uint 32 | EnvironmentID uint 33 | ExtraData string 34 | } 35 | 36 | // ArchiveOsqueryNode as abstraction of an archived node 37 | type ArchiveOsqueryNode struct { 38 | gorm.Model 39 | NodeKey string `gorm:"index"` 40 | UUID string `gorm:"index"` 41 | Trigger string 42 | Platform string 43 | PlatformVersion string 44 | OsqueryVersion string 45 | Hostname string 46 | Localname string 47 | IPAddress string 48 | Username string 49 | OsqueryUser string 50 | Environment string 51 | CPU string 52 | Memory string 53 | HardwareSerial string 54 | ConfigHash string 55 | DaemonHash string 56 | BytesReceived int 57 | RawEnrollment string 58 | LastSeen time.Time 59 | UserID uint 60 | EnvironmentID uint 61 | ExtraData string 62 | } 63 | 64 | // NodeMetadata to hold metadata for a node 65 | type NodeMetadata struct { 66 | IPAddress string 67 | Username string 68 | OsqueryUser string 69 | Hostname string 70 | Localname string 71 | ConfigHash string 72 | DaemonHash string 73 | OsqueryVersion string 74 | Platform string 75 | PlatformVersion string 76 | BytesReceived int 77 | } 78 | -------------------------------------------------------------------------------- /cmd/api/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/jmpsec/osctrl/cmd/api/handlers" 9 | "github.com/jmpsec/osctrl/pkg/config" 10 | "github.com/jmpsec/osctrl/pkg/utils" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | const ( 15 | // Key to identify request context 16 | contextAPI string = "osctrl-api-context" 17 | ) 18 | 19 | // Helper to extract token from header 20 | func extractHeaderToken(r *http.Request) string { 21 | reqToken := r.Header.Get("Authorization") 22 | splitToken := strings.Split(reqToken, "Bearer") 23 | if len(splitToken) != 2 { 24 | return "" 25 | } 26 | return strings.TrimSpace(splitToken[1]) 27 | } 28 | 29 | // Handler to check access to a resource based on the authentication enabled 30 | func handlerAuthCheck(h http.Handler, auth, jwtSecret string) http.Handler { 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | switch auth { 33 | case config.AuthNone: 34 | // Set middleware values 35 | s := make(handlers.ContextValue) 36 | s["user"] = "admin" 37 | ctx := context.WithValue(r.Context(), handlers.ContextKey(contextAPI), s) 38 | // Access granted 39 | h.ServeHTTP(w, r.WithContext(ctx)) 40 | case config.AuthJWT: 41 | // Set middleware values 42 | token := extractHeaderToken(r) 43 | if token == "" { 44 | http.Redirect(w, r, forbiddenPath, http.StatusForbidden) 45 | return 46 | } 47 | claims, valid := apiUsers.CheckToken(jwtSecret, token) 48 | if !valid { 49 | http.Redirect(w, r, forbiddenPath, http.StatusForbidden) 50 | return 51 | } 52 | // Update metadata for the user 53 | if err := apiUsers.UpdateTokenIPAddress(utils.GetIP(r), claims.Username); err != nil { 54 | log.Err(err).Msgf("error updating token for user %s", claims.Username) 55 | } 56 | // Set middleware values 57 | s := make(handlers.ContextValue) 58 | s["user"] = claims.Username 59 | ctx := context.WithValue(r.Context(), handlers.ContextKey(contextAPI), s) 60 | // Access granted 61 | h.ServeHTTP(w, r.WithContext(ctx)) 62 | } 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/admin/static/js/functions.js: -------------------------------------------------------------------------------- 1 | function sendGetRequest(req_url, _modal, _callback) { 2 | $.ajax({ 3 | url: req_url, 4 | dataType: 'json', 5 | type: 'GET', 6 | contentType: 'application/json', 7 | success: function (data, textStatus, jQxhr) { 8 | console.log('OK'); 9 | console.log(data); 10 | if (_modal) { 11 | $("#successModalMessage").text(data.message); 12 | $("#successModal").modal(); 13 | } 14 | if (_callback) { 15 | _callback(data); 16 | } 17 | }, 18 | error: function (jqXhr, textStatus, errorThrown) { 19 | var _clientmsg = "Client: " + errorThrown; 20 | var _serverJSON = $.parseJSON(jqXhr.responseText); 21 | var _servermsg = 'Server: ' + _serverJSON.message; 22 | $("#errorModalMessageClient").text(_clientmsg); 23 | console.log(_clientmsg); 24 | $("#errorModalMessageServer").text(_servermsg); 25 | $("#errorModal").modal(); 26 | } 27 | }); 28 | } 29 | 30 | function sendPostRequest(req_data, req_url, _redir, _modal, _callback) { 31 | $.ajax({ 32 | url: req_url, 33 | dataType: "json", 34 | type: "POST", 35 | contentType: "application/json", 36 | data: JSON.stringify(req_data), 37 | processData: false, 38 | success: function (data, textStatus, jQxhr) { 39 | console.log("OK"); 40 | console.log(data); 41 | if (_modal) { 42 | $("#successModalMessage").text(data.message); 43 | $("#successModal").modal(); 44 | } 45 | if (_redir !== "") { 46 | window.location.replace(_redir); 47 | } 48 | if (_callback) { 49 | _callback(data); 50 | } 51 | }, 52 | error: function (jqXhr, textStatus, errorThrown) { 53 | var _clientmsg = "Client: " + errorThrown; 54 | var _serverJSON = $.parseJSON(jqXhr.responseText); 55 | var _servermsg = "Server: " + _serverJSON.message; 56 | $("#errorModalMessageClient").text(_clientmsg); 57 | console.log(_clientmsg); 58 | $("#errorModalMessageServer").text(_servermsg); 59 | $("#errorModal").modal(); 60 | } 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /cmd/admin/handlers/json-stats.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/jmpsec/osctrl/cmd/admin/sessions" 7 | "github.com/jmpsec/osctrl/pkg/nodes" 8 | "github.com/jmpsec/osctrl/pkg/settings" 9 | "github.com/jmpsec/osctrl/pkg/users" 10 | "github.com/jmpsec/osctrl/pkg/utils" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // Define targets to be used 15 | var ( 16 | StatsTargets = map[string]bool{ 17 | "environment": true, 18 | "platform": true, 19 | } 20 | ) 21 | 22 | // JSONStatsHandler for platform/environment stats in JSON 23 | func (h *HandlersAdmin) JSONStatsHandler(w http.ResponseWriter, r *http.Request) { 24 | if h.DebugHTTPConfig.EnableHTTP { 25 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 26 | } 27 | // Get context data 28 | ctx := r.Context().Value(sessions.ContextKey(sessions.CtxSession)).(sessions.ContextValue) 29 | // Extract stats target 30 | target := r.PathValue("target") 31 | if target == "" { 32 | log.Info().Msg("error getting target") 33 | return 34 | } 35 | // Verify target 36 | if !StatsTargets[target] { 37 | log.Info().Msgf("invalid target %s", target) 38 | return 39 | } 40 | // Extract identifier 41 | identifier := r.PathValue("identifier") 42 | if identifier == "" { 43 | log.Info().Msg("error getting target identifier") 44 | return 45 | } 46 | // Get stats 47 | var stats nodes.StatsData 48 | if target == "environment" { 49 | // Verify identifier 50 | env, err := h.Envs.Get(identifier) 51 | if err != nil { 52 | log.Err(err).Msgf("error getting environment %s", identifier) 53 | return 54 | } 55 | // Check permissions 56 | if !h.Users.CheckPermissions(ctx[sessions.CtxUser], users.UserLevel, env.UUID) { 57 | log.Info().Msgf("%s has insufficient permissions", ctx[sessions.CtxUser]) 58 | return 59 | } 60 | stats, err = h.Nodes.GetStatsByEnv(env.Name, h.Settings.InactiveHours(settings.NoEnvironmentID)) 61 | if err != nil { 62 | log.Err(err).Msg("error getting stats") 63 | return 64 | } 65 | } 66 | // Serve JSON 67 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, stats) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/environments/env-cache.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/jmpsec/osctrl/pkg/cache" 8 | ) 9 | 10 | const ( 11 | cacheName = "environments" 12 | ) 13 | 14 | // EnvCache provides cached access to TLS environments 15 | type EnvCache struct { 16 | // The cache itself, storing Environment objects 17 | cache *cache.MemoryCache[TLSEnvironment] 18 | 19 | // Reference to the environment manager for cache misses 20 | envs EnvManager 21 | } 22 | 23 | // NewEnvCache creates a new environment cache 24 | func NewEnvCache(envs EnvManager) *EnvCache { 25 | // Create a new cache with a 10-minute cleanup interval 26 | envCache := cache.NewMemoryCache( 27 | cache.WithCleanupInterval[TLSEnvironment](2*time.Hour), 28 | cache.WithName[TLSEnvironment](cacheName), 29 | ) 30 | 31 | return &EnvCache{ 32 | cache: envCache, 33 | envs: envs, 34 | } 35 | } 36 | 37 | // GetByUUID retrieves an environment by UUID, using cache when available 38 | func (ec *EnvCache) GetByUUID(ctx context.Context, uuid string) (TLSEnvironment, error) { 39 | // Try to get from cache first 40 | if env, found := ec.cache.Get(ctx, uuid); found { 41 | return env, nil 42 | } 43 | 44 | // Not in cache, fetch from database 45 | env, err := ec.envs.GetByUUID(uuid) 46 | if err != nil { 47 | return TLSEnvironment{}, err 48 | } 49 | 50 | ec.cache.Set(ctx, uuid, env, 2*time.Hour) 51 | 52 | return env, nil 53 | } 54 | 55 | // InvalidateEnv removes a specific environment from the cache 56 | func (ec *EnvCache) InvalidateEnv(ctx context.Context, uuid string) { 57 | ec.cache.Delete(ctx, uuid) 58 | } 59 | 60 | // InvalidateAll clears the entire cache 61 | func (ec *EnvCache) InvalidateAll(ctx context.Context) { 62 | ec.cache.Clear(ctx) 63 | } 64 | 65 | // UpdateEnvInCache updates an environment in the cache 66 | func (ec *EnvCache) UpdateEnvInCache(ctx context.Context, env TLSEnvironment) { 67 | ec.cache.Set(ctx, env.UUID, env, 2*time.Hour) 68 | } 69 | 70 | // Close stops the cleanup goroutine and releases resources 71 | func (ec *EnvCache) Close() { 72 | if ec.cache != nil { 73 | ec.cache.Stop() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tools/fake_logging.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf-8 3 | # 4 | # Script to simulate HTTP logging services (Graylog, Splunk...) for osctrl 5 | # 6 | # Usage: python fake_logging.py port 7 | # 8 | 9 | import http.server 10 | import socketserver 11 | import sys 12 | import time 13 | import json 14 | 15 | _NAME = "FakeServerLogging" 16 | _BIND = "0.0.0.0" 17 | _PARAMS = 2 18 | 19 | _UTF = "utf-8" 20 | 21 | 22 | class FakeServer(http.server.SimpleHTTPRequestHandler): 23 | def _set_headers(self): 24 | self.send_response(200) 25 | self.send_header("Content-type", "application/json") 26 | self.end_headers() 27 | 28 | def do_GET(self): 29 | self._set_headers() 30 | self.wfile.write(bytes("{'text':'Success','code':0}", _UTF)) 31 | 32 | def do_POST(self): 33 | content_length = int(self.headers["Content-Length"]) 34 | post_data = self.rfile.read(content_length) 35 | self._set_headers() 36 | self.wfile.write(bytes("{'text':'Success','code':0}", _UTF)) 37 | print( 38 | "-----------------------------------Headers-----------------------------------------" 39 | ) 40 | print(str(self.headers)) 41 | print( 42 | "------------------------------------Body-------------------------------------------" 43 | ) 44 | print(json.dumps(json.loads(post_data.decode(_UTF)), indent=4)) 45 | print( 46 | "-----------------------------------------------------------------------------------" 47 | ) 48 | 49 | 50 | if __name__ == "__main__": 51 | if len(sys.argv) < _PARAMS: 52 | print() 53 | print("Usage: " + sys.argv[0] + " port") 54 | exit(1) # pylint: disable=consider-using-sys-exit 55 | 56 | _port = int(sys.argv[1]) 57 | 58 | httpd = socketserver.TCPServer((_BIND, _port), FakeServer) 59 | print(f"{time.asctime()},{_NAME} UP - {_BIND}:{_port}") 60 | 61 | try: 62 | httpd.serve_forever() 63 | except KeyboardInterrupt: 64 | pass 65 | httpd.server_close() 66 | print(f"{time.asctime()},{ _NAME} DOWN - {_BIND}:{_port}") 67 | -------------------------------------------------------------------------------- /cmd/tls/handlers/metrics.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "github.com/prometheus/client_golang/prometheus" 4 | 5 | const ( 6 | RequestPath = "path" 7 | RequestMethod = "method" 8 | StatusCode = "status_code" 9 | Environment = "osctrl_env" 10 | RequestType = "type" 11 | LogType = "log_type" 12 | ) 13 | 14 | var ( 15 | requestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 16 | Name: "osctrl_tls_request_duration_seconds", 17 | Help: "The duration of requests", 18 | Buckets: []float64{0.0005, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.5, 1, 5}, 19 | }, []string{RequestMethod, RequestPath, StatusCode}) 20 | requestSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 21 | Name: "osctrl_tls_request_size_bytes", 22 | Help: "The size of requests", 23 | Buckets: []float64{100, 1000, 10000, 100000, 1000000}, 24 | }, []string{Environment, RequestType}) 25 | logProcessDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 26 | Name: "osctrl_tls_log_process_duration_seconds", 27 | Help: "The duration of log/scheduled query processing", 28 | Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10}, 29 | }, []string{Environment, LogType}) 30 | distributedQueryProcessingDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 31 | Name: "osctrl_tls_distributed_query_process_duration_seconds", 32 | Help: "The duration of distributed query result processing", 33 | Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10}, 34 | }, []string{Environment}) 35 | batchFlushDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 36 | Name: "osctrl_tls_batch_flush_duration_seconds", 37 | Help: "The duration of batch data flushing to backend", 38 | Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5}, 39 | }, []string{"operation"}) 40 | ) 41 | 42 | func RegisterMetrics(reg prometheus.Registerer) { 43 | reg.MustRegister(requestDuration) 44 | reg.MustRegister(requestSize) 45 | reg.MustRegister(logProcessDuration) 46 | reg.MustRegister(distributedQueryProcessingDuration) 47 | reg.MustRegister(batchFlushDuration) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/environments/flags_test.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGenServerCertsFlag(t *testing.T) { 8 | t.Run("empty", func(t *testing.T) { 9 | flag := GenServerCertsFlag("") 10 | if flag != "" { 11 | t.Errorf("Expected empty flag, got %s", flag) 12 | } 13 | }) 14 | t.Run("not empty", func(t *testing.T) { 15 | flag := GenServerCertsFlag("certificate") 16 | if flag != "--tls_server_certs=certificate" { 17 | t.Errorf("Expected flag --tls_server_certs=certificate, got %s", flag) 18 | } 19 | }) 20 | } 21 | 22 | func TestGenCarveBlockSizeFlag(t *testing.T) { 23 | t.Run("empty", func(t *testing.T) { 24 | flag := GenCarveBlockSizeFlag("") 25 | if flag != "" { 26 | t.Errorf("Expected empty flag, got %s", flag) 27 | } 28 | }) 29 | t.Run("not empty", func(t *testing.T) { 30 | flag := GenCarveBlockSizeFlag("blockSize") 31 | if flag != "--carver_block_size=blockSize" { 32 | t.Errorf("Expected flag --carver_block_size=blockSize, got %s", flag) 33 | } 34 | }) 35 | } 36 | 37 | func TestGenSingleFlag(t *testing.T) { 38 | t.Run("empty", func(t *testing.T) { 39 | flag := GenSingleFlag("tmplName", "flagName", "") 40 | if flag != "--flagName=" { 41 | t.Errorf("Expected --flagName=, got %s", flag) 42 | } 43 | }) 44 | t.Run("not empty", func(t *testing.T) { 45 | flag := GenSingleFlag("tmplName", "flagName", "flagValue") 46 | if flag != "--flagName=flagValue" { 47 | t.Errorf("Expected flag --flagName=flagValue, got %s", flag) 48 | } 49 | }) 50 | } 51 | 52 | func TestParseFlagTemplate(t *testing.T) { 53 | t.Run("empty data", func(t *testing.T) { 54 | flag := ParseFlagTemplate("tmplName", "flagTemplate", nil) 55 | if flag != "flagTemplate" { 56 | t.Errorf("Expected empty flag, got %s", flag) 57 | } 58 | }) 59 | t.Run("not empty data", func(t *testing.T) { 60 | flag := ParseFlagTemplate("tmplName", "--{{ .Name }}={{ .Value }}", struct { 61 | Name string 62 | Value string 63 | }{ 64 | Name: "flagName", 65 | Value: "flagValue", 66 | }) 67 | if flag != "--flagName=flagValue" { 68 | t.Errorf("Expected flag --flagName=flagValue, got %s", flag) 69 | } 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /deploy/osquery/packs/osctrl-windows-application-security.conf: -------------------------------------------------------------------------------- 1 | { 2 | "platform": "windows", 3 | "queries": { 4 | "bitlocker_autoencrypt_settings_registry": { 5 | "query": "SELECT * FROM registry WHERE key LIKE 'HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Bitlocker\\%%';", 6 | "interval": 3600, 7 | "description": "Controls Bitlocker full-disk encryption settings." 8 | }, 9 | "bitlocker_fde_settings_registry": { 10 | "query": "SELECT * FROM registry WHERE key LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\FVE\\%%';", 11 | "interval": 3600, 12 | "description": "Controls Bitlocker full-disk encryption settings." 13 | }, 14 | "chrome_extension_force_list_registry": { 15 | "query": "SELECT * FROM registry WHERE key='HKEY_LOCAL_MACHINE\\Software\\Policies\\Google\\Chrome\\ExtensionInstallForcelist';", 16 | "interval": 3600, 17 | "description": "Controls Google Chrome plugins that are forcibly installed." 18 | }, 19 | "emet_settings_registry": { 20 | "query": "SELECT * FROM registry WHERE key LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\EMET\\%%';", 21 | "interval": 3600, 22 | "description": "Controls EMET-protected applications and system settings." 23 | }, 24 | "microsoft_laps_settings_registry": { 25 | "query": "SELECT * FROM registry WHERE key='HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft Services\\AdmPwd';", 26 | "interval": 3600, 27 | "description": "Controls Local Administrative Password Solution (LAPS) settings." 28 | }, 29 | "passport_for_work_settings_registry": { 30 | "query": "SELECT * FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\PassportForWork\\%%';", 31 | "interval": 3600, 32 | "description": "Controls Windows Passport for Work (Hello) settings." 33 | }, 34 | "uac_settings_registry": { 35 | "query": "SELECT * FROM registry WHERE path='HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\EnableLUA';", 36 | "interval": 3600, 37 | "description": "Controls UAC. A setting of 0 indicates that UAC is disabled." 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cmd/admin/settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jmpsec/osctrl/pkg/config" 7 | "github.com/jmpsec/osctrl/pkg/settings" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // Function to load all settings for the service 12 | func loadingSettings(mgr *settings.Settings, cfg *config.ServiceParameters) error { 13 | // Check if service settings for debug service is ready 14 | log.Debug().Msg("Initializing settings") 15 | // Check if service settings for sessions cleanup is ready 16 | if !mgr.IsValue(config.ServiceAdmin, settings.CleanupSessions, settings.NoEnvironmentID) { 17 | if err := mgr.NewIntegerValue(config.ServiceAdmin, settings.CleanupSessions, int64(defaultRefresh), settings.NoEnvironmentID); err != nil { 18 | return fmt.Errorf("failed to add %s to configuration: %w", settings.CleanupSessions, err) 19 | } 20 | } 21 | // Check if service settings for queries/carves cleanup is ready 22 | if !mgr.IsValue(config.ServiceAdmin, settings.CleanupExpired, settings.NoEnvironmentID) { 23 | if err := mgr.NewIntegerValue(config.ServiceAdmin, settings.CleanupExpired, int64(defaultExpiration), settings.NoEnvironmentID); err != nil { 24 | return fmt.Errorf("failed to add %s to configuration: %w", settings.CleanupExpired, err) 25 | } 26 | } 27 | // Check if service settings for node inactive hours is ready 28 | if !mgr.IsValue(config.ServiceAdmin, settings.InactiveHours, settings.NoEnvironmentID) { 29 | if err := mgr.NewIntegerValue(config.ServiceAdmin, settings.InactiveHours, int64(defaultInactive), settings.NoEnvironmentID); err != nil { 30 | return fmt.Errorf("failed to add %s to configuration: %w", settings.InactiveHours, err) 31 | } 32 | } 33 | // Check if service settings for display dashboard is ready 34 | if !mgr.IsValue(config.ServiceAdmin, settings.NodeDashboard, settings.NoEnvironmentID) { 35 | if err := mgr.NewBooleanValue(config.ServiceAdmin, settings.NodeDashboard, false, settings.NoEnvironmentID); err != nil { 36 | return fmt.Errorf("failed to add %s to settings: %w", settings.NodeDashboard, err) 37 | } 38 | } 39 | // Write JSON config to settings 40 | if err := mgr.SetAdminJSON(cfg, settings.NoEnvironmentID); err != nil { 41 | return fmt.Errorf("failed to add JSON values to configuration: %w", err) 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/nodes/node-cache.go: -------------------------------------------------------------------------------- 1 | package nodes 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/jmpsec/osctrl/pkg/cache" 8 | ) 9 | 10 | const ( 11 | cacheName = "nodes" 12 | // Default time-to-live for cached nodes 13 | defaultTTL = 60 * time.Minute 14 | // Default cleanup interval for the cache 15 | defaultCleanupInterval = 30 * time.Minute 16 | ) 17 | 18 | // NodeCache provides cached access to OsqueryNode objects 19 | type NodeCache struct { 20 | // The cache itself, storing OsqueryNode objects 21 | cache *cache.MemoryCache[OsqueryNode] 22 | 23 | // Reference to the node manager for cache misses 24 | nodes *NodeManager 25 | } 26 | 27 | // NewNodeCache creates a new node cache 28 | func NewNodeCache(nodes *NodeManager) *NodeCache { 29 | // Create a new cache with appropriate cleanup interval 30 | nodeCache := cache.NewMemoryCache( 31 | cache.WithCleanupInterval[OsqueryNode](defaultCleanupInterval), 32 | cache.WithName[OsqueryNode](cacheName), 33 | ) 34 | 35 | return &NodeCache{ 36 | cache: nodeCache, 37 | nodes: nodes, 38 | } 39 | } 40 | 41 | // GetByKey retrieves a node by node_key, using cache when available 42 | func (nc *NodeCache) GetByKey(ctx context.Context, nodeKey string) (OsqueryNode, error) { 43 | // Try to get from cache first 44 | if node, found := nc.cache.Get(ctx, nodeKey); found { 45 | return node, nil 46 | } 47 | 48 | // Not in cache, fetch from database 49 | node, err := nc.nodes.getByKeyFromDB(nodeKey) 50 | if err != nil { 51 | return OsqueryNode{}, err 52 | } 53 | 54 | // Store in cache for future requests 55 | nc.cache.Set(ctx, nodeKey, node, defaultTTL) 56 | 57 | return node, nil 58 | } 59 | 60 | // InvalidateNode removes a specific node from the cache 61 | func (nc *NodeCache) InvalidateNode(ctx context.Context, nodeKey string) { 62 | nc.cache.Delete(ctx, nodeKey) 63 | } 64 | 65 | // InvalidateAll clears the entire cache 66 | func (nc *NodeCache) InvalidateAll(ctx context.Context) { 67 | nc.cache.Clear(ctx) 68 | } 69 | 70 | // UpdateNodeInCache updates a node in the cache 71 | func (nc *NodeCache) UpdateNodeInCache(ctx context.Context, node OsqueryNode) { 72 | nc.cache.Set(ctx, node.NodeKey, node, defaultTTL) 73 | } 74 | 75 | // Close stops the cleanup goroutine and releases resources 76 | func (nc *NodeCache) Close() { 77 | if nc.cache != nil { 78 | nc.cache.Stop() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /cmd/api/handlers/login.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/jmpsec/osctrl/pkg/types" 9 | "github.com/jmpsec/osctrl/pkg/users" 10 | "github.com/jmpsec/osctrl/pkg/utils" 11 | ) 12 | 13 | // LoginHandler - POST Handler for API login request 14 | func (h *HandlersApi) LoginHandler(w http.ResponseWriter, r *http.Request) { 15 | // Debug HTTP if enabled, never log the body for login 16 | if h.DebugHTTPConfig.EnableHTTP { 17 | utils.DebugHTTPDump(h.DebugHTTP, r, false) 18 | } 19 | // Extract environment 20 | envVar := r.PathValue("env") 21 | if envVar == "" { 22 | apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) 23 | return 24 | } 25 | // Get environment by UUID 26 | env, err := h.Envs.GetByUUID(envVar) 27 | if err != nil { 28 | apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) 29 | return 30 | } 31 | var l types.ApiLoginRequest 32 | // Parse request JSON body 33 | if err := json.NewDecoder(r.Body).Decode(&l); err != nil { 34 | apiErrorResponse(w, "error parsing POST body", http.StatusInternalServerError, err) 35 | return 36 | } 37 | // Check credentials 38 | access, user := h.Users.CheckLoginCredentials(l.Username, l.Password) 39 | if !access { 40 | apiErrorResponse(w, "invalid credentials", http.StatusForbidden, err) 41 | return 42 | } 43 | // Check if user has access to this environment 44 | if !h.Users.CheckPermissions(l.Username, users.AdminLevel, env.UUID) { 45 | apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use %s by user %s", h.ServiceName, l.Username)) 46 | return 47 | } 48 | // Do we have a token already? 49 | if user.APIToken == "" { 50 | token, exp, err := h.Users.CreateToken(l.Username, h.ServiceName, l.ExpHours) 51 | if err != nil { 52 | apiErrorResponse(w, "error creating token", http.StatusInternalServerError, err) 53 | return 54 | } 55 | if err = h.Users.UpdateToken(l.Username, token, exp); err != nil { 56 | apiErrorResponse(w, "error updating token", http.StatusInternalServerError, err) 57 | return 58 | } 59 | user.APIToken = token 60 | } 61 | h.AuditLog.NewLogin(l.Username, r.RemoteAddr) 62 | // Serialize and serve JSON 63 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiLoginResponse{Token: user.APIToken}) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/queries/saved.go: -------------------------------------------------------------------------------- 1 | package queries 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // SavedQuery as abstraction of a saved query to be used in distributed, schedule or packs 10 | type SavedQuery struct { 11 | gorm.Model 12 | Name string 13 | Creator string 14 | Query string 15 | EnvironmentID uint 16 | ExtraData string 17 | } 18 | 19 | // GetSavedByCreator to get a saved query by creator 20 | func (q *Queries) GetSavedByCreator(creator string, envid uint) ([]SavedQuery, error) { 21 | var saved []SavedQuery 22 | if err := q.DB.Where("creator = ? AND environment_id = ?", creator, envid).Find(&saved).Error; err != nil { 23 | return saved, err 24 | } 25 | return saved, nil 26 | } 27 | 28 | // GetSaved to get a saved query by creator 29 | func (q *Queries) GetSaved(name, creator string, envid uint) (SavedQuery, error) { 30 | var saved SavedQuery 31 | if err := q.DB.Where("creator = ? AND name = ? AND environment_id = ?", creator, name, envid).Find(&saved).Error; err != nil { 32 | return saved, err 33 | } 34 | return saved, nil 35 | } 36 | 37 | // CreateSaved to create new saved query 38 | func (q *Queries) CreateSaved(name, query, creator string, envid uint) error { 39 | saved := SavedQuery{ 40 | Name: name, 41 | Query: query, 42 | Creator: creator, 43 | EnvironmentID: envid, 44 | } 45 | if err := q.DB.Create(&saved).Error; err != nil { 46 | return err 47 | } 48 | return nil 49 | } 50 | 51 | // UpdateSaved to update an existing saved query 52 | func (q *Queries) UpdateSaved(name, query, creator string, envid uint) error { 53 | saved, err := q.GetSaved(name, creator, envid) 54 | if err != nil { 55 | return fmt.Errorf("error getting saved query %w", err) 56 | } 57 | data := SavedQuery{ 58 | Name: name, 59 | Query: query, 60 | EnvironmentID: envid, 61 | } 62 | if err := q.DB.Model(&saved).Updates(data).Error; err != nil { 63 | return fmt.Errorf("in Updates %w", err) 64 | } 65 | return nil 66 | } 67 | 68 | // DeleteSaved to delete an existing saved query 69 | func (q *Queries) DeleteSaved(name, creator string, envid uint) error { 70 | saved, err := q.GetSaved(name, creator, envid) 71 | if err != nil { 72 | return fmt.Errorf("error getting saved query %w", err) 73 | } 74 | if err := q.DB.Unscoped().Delete(&saved).Error; err != nil { 75 | return fmt.Errorf("in DeleteSaved %w", err) 76 | } 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /deploy/docker/conf/nginx/osctrl.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl default deferred; 3 | 4 | ssl_certificate /etc/ssl/certs/osctrl.crt; 5 | ssl_certificate_key /etc/ssl/private/osctrl.key; 6 | 7 | # Improve HTTPS performance with session resumption 8 | ssl_session_cache shared:SSL:10m; 9 | ssl_session_timeout 5m; 10 | 11 | # Enable server-side protection against BEAST attacks 12 | ssl_prefer_server_ciphers on; 13 | ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5; 14 | 15 | # Disable SSLv3 16 | ssl_protocols TLSv1.2 TLSv1.3; 17 | 18 | # Enable OCSP stapling (http://blog.mozilla.org/security/2013/07/29/ocsp-stapling-in-firefox) 19 | ssl_stapling on; 20 | ssl_stapling_verify on; 21 | ssl_trusted_certificate /etc/ssl/certs/osctrl.crt; 22 | resolver 1.1.1.1 8.8.8.8 valid=300s; 23 | resolver_timeout 5s; 24 | 25 | # Enable HSTS (https://developer.mozilla.org/en-US/docs/Security/HTTP_Strict_Transport_Security) 26 | add_header Strict-Transport-Security "max-age=63072000; includeSubdomains"; 27 | add_header X-Frame-Options DENY; 28 | 29 | add_header Cache-Control "no-cache, no-store"; 30 | add_header Pragma "no-cache"; 31 | expires -1; 32 | 33 | ################################## osctrl-admin webgui ################################## 34 | location / { 35 | proxy_set_header Host $host; 36 | proxy_set_header X-Real-IP $remote_addr; 37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 38 | proxy_set_header X-Forwarded-Proto $scheme; 39 | 40 | # Fix the “It appears that your reverse proxy set up is broken" error. 41 | proxy_pass http://osctrl-admin:9001; 42 | proxy_read_timeout 90; 43 | } 44 | 45 | ################################## osctrl-tls osquery server ################################## 46 | location ~* .(enroll|config|log|init|block|read|write|enroll.sh|enroll.ps1|remove.sh|remove.ps1)$ { 47 | proxy_set_header Host $host; 48 | proxy_set_header X-Real-IP $remote_addr; 49 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 50 | proxy_set_header X-Forwarded-Proto $scheme; 51 | 52 | # Fix the “It appears that your reverse proxy set up is broken" error. 53 | proxy_pass http://osctrl-tls:9000; 54 | proxy_read_timeout 90; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /deploy/cicd/deb/pre-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ################### Create osctrl user and group ################### 6 | id -u osctrl &>/dev/null || adduser --system --no-create-home --group osctrl 7 | 8 | ################### Create osctrl directory ################### 9 | if [ ! -d "/opt/osctrl" ] 10 | then 11 | echo "Directory /opt/osctrl DOES NOT exists." 12 | mkdir /opt/osctrl 13 | chown root:root -R /opt/osctrl 14 | fi 15 | 16 | ################### Create osctrl log directory ################### 17 | if [ ! -d "/var/log/osctrl-{{ OSCTRL_COMPONENT }}" ] 18 | then 19 | echo "Directory /var/log/osctrl-{{ OSCTRL_COMPONENT }} DOES NOT exists." 20 | mkdir /var/log/osctrl-{{ OSCTRL_COMPONENT }} 21 | chown osctrl:adm -R /var/log/osctrl-{{ OSCTRL_COMPONENT }} 22 | fi 23 | 24 | 25 | ################### Set perms on config directory ################### 26 | if [ -d "/opt/osctrl/config" ] 27 | then 28 | chown root:osctrl -R /opt/osctrl/config 29 | fi 30 | 31 | ################### Copy needed configs ################### 32 | if [ ! -f /opt/osctrl/config/db.json ] 33 | then 34 | cp /tmp/osctrl-{{ OSCTRL_COMPONENT }}/db.json.example /opt/osctrl/config/db.json 35 | chown root:root /opt/osctrl/config/db.json.example 36 | fi 37 | 38 | if [ ! -f /opt/osctrl/config/redis.json ] 39 | then 40 | cp /tmp/osctrl-{{ OSCTRL_COMPONENT }}/redis.json.example /opt/osctrl/config/redis.json 41 | chown root:root /opt/osctrl/config/redis.json.example 42 | fi 43 | rm -rd /tmp/osctrl-{{ OSCTRL_COMPONENT }} 44 | 45 | ################### osctrl-admin web assets ################### 46 | if [ -d "/opt/osctrl/tmpl_admin" ] 47 | then 48 | # set user as the owner 49 | chown root -R /opt/osctrl/tmpl_admin/ 50 | 51 | # set osctrl as the group owner 52 | chgrp -R osctrl /opt/osctrl/tmpl_admin/ 53 | 54 | # 750 permissions for everything 55 | chmod -R 750 /opt/osctrl/tmpl_admin/ 56 | 57 | # new files and folders inherit group ownership from the parent folder 58 | chmod g+s /opt/osctrl/tmpl_admin/ 59 | fi 60 | 61 | if [ -d "/opt/osctrl/static" ] 62 | then 63 | # set user as the owner 64 | chown root -R /opt/osctrl/static/ 65 | 66 | # set osctrl as the group owner 67 | chgrp -R osctrl /opt/osctrl/static/ 68 | 69 | # 750 permissions for everything 70 | chmod -R 750 /opt/osctrl/static/ 71 | 72 | # new files and folders inherit group ownership from the parent folder 73 | chmod g+s /opt/osctrl/static/ 74 | fi 75 | -------------------------------------------------------------------------------- /.github/actions/build/binaries/action.yml: -------------------------------------------------------------------------------- 1 | name: "Build Osctrl binaries" 2 | description: "Build Osctrl components with Golang" 3 | inputs: 4 | go_os: 5 | required: true 6 | description: Define the OS to compile binary for - https://pkg.go.dev/internal/goos 7 | go_arch: 8 | required: true 9 | description: Define the architecture to compile binary for - https://pkg.go.dev/internal/goarch 10 | osctrl_component: 11 | required: true 12 | description: Define the osctrl component to compile 13 | commit_sha: 14 | required: true 15 | description: Define the SHA1 git commit hash 16 | commit_branch: 17 | required: true 18 | description: Define the git branch 19 | golang_version: 20 | required: false 21 | description: Define the version of golang to compile with 22 | default: 1.25.4 23 | 24 | runs: 25 | using: "composite" 26 | steps: 27 | ########################### Checkout code ########################### 28 | - name: Checkout code 29 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 30 | with: 31 | fetch-depth: 2 32 | 33 | ########################### Install go to env ########################### 34 | - name: Set up Go 35 | uses: actions/setup-go@8e57b58e57be52ac95949151e2777ffda8501267 # v5.5.0 36 | with: 37 | go-version: ${{ inputs.golang_version }} 38 | - run: go version 39 | shell: bash 40 | 41 | ########################### Get GO deps ############################# 42 | - name: Get GO deps 43 | run: go mod download 44 | shell: bash 45 | 46 | ########################### Build osctrl inputs.osctrl_component ########################### 47 | - name: Build osctrl component 48 | run: | 49 | GOOS=${{ inputs.go_os }} GOARCH=${{ inputs.go_arch }} \ 50 | go build -o osctrl-${{ inputs.osctrl_component }}-${{ inputs.commit_sha }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin \ 51 | ./cmd/${{ inputs.osctrl_component }} 52 | shell: bash 53 | 54 | ########################### Upload artifacts ########################### 55 | - name: Upload osctrl binaries 56 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 57 | with: 58 | name: osctrl-${{ inputs.osctrl_component }}-${{ inputs.commit_sha }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin 59 | path: osctrl-${{ inputs.osctrl_component }}-${{ inputs.commit_sha }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin 60 | retention-days: 10 61 | -------------------------------------------------------------------------------- /pkg/environments/oneliners_test.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPrepareOneLiner(t *testing.T) { 10 | envTest := TLSEnvironment{ 11 | Certificate: "certificate", 12 | Hostname: "hostname", 13 | UUID: "name", 14 | RemoveSecretPath: "rPath", 15 | EnrollSecretPath: "ePath", 16 | } 17 | tmpl := "oneliner {{ .InsecureTLS }} 1 {{ .TLSHost }} 2 {{ .Environment }} 3 {{ .SecretPath }}" 18 | t.Run("empty", func(t *testing.T) { 19 | oneliner, _ := PrepareOneLiner("", true, TLSEnvironment{}, "") 20 | assert.Equal(t, oneliner, "") 21 | }) 22 | t.Run("not empty insecure enroll.sh", func(t *testing.T) { 23 | oneliner, _ := PrepareOneLiner(tmpl, true, envTest, "enroll.sh") 24 | assert.Equal(t, oneliner, "oneliner k 1 hostname 2 name 3 ePath") 25 | }) 26 | t.Run("not empty insecure remove.sh", func(t *testing.T) { 27 | oneliner, _ := PrepareOneLiner(tmpl, true, envTest, "remove.sh") 28 | assert.Equal(t, oneliner, "oneliner k 1 hostname 2 name 3 rPath") 29 | }) 30 | t.Run("not empty insecure enroll.ps1", func(t *testing.T) { 31 | oneliner, _ := PrepareOneLiner(tmpl, true, envTest, "enroll.ps1") 32 | assert.Equal(t, oneliner, "oneliner [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}; 1 hostname 2 name 3 ePath") 33 | }) 34 | t.Run("not empty insecure remove.ps1", func(t *testing.T) { 35 | oneliner, _ := PrepareOneLiner(tmpl, true, envTest, "remove.ps1") 36 | assert.Equal(t, oneliner, "oneliner [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}; 1 hostname 2 name 3 rPath") 37 | }) 38 | // Empty certificate means secure TLS 39 | envTest.Certificate = "" 40 | t.Run("not empty secure enroll.sh", func(t *testing.T) { 41 | oneliner, _ := PrepareOneLiner(tmpl, false, envTest, "enroll.sh") 42 | assert.Equal(t, oneliner, "oneliner 1 hostname 2 name 3 ePath") 43 | }) 44 | t.Run("not empty secure remove.sh", func(t *testing.T) { 45 | oneliner, _ := PrepareOneLiner(tmpl, false, envTest, "remove.sh") 46 | assert.Equal(t, oneliner, "oneliner 1 hostname 2 name 3 rPath") 47 | }) 48 | t.Run("not empty secure enroll.ps1", func(t *testing.T) { 49 | oneliner, _ := PrepareOneLiner(tmpl, false, envTest, "enroll.ps1") 50 | assert.Equal(t, oneliner, "oneliner 1 hostname 2 name 3 ePath") 51 | }) 52 | t.Run("not empty secure remove.ps1", func(t *testing.T) { 53 | oneliner, _ := PrepareOneLiner(tmpl, false, envTest, "remove.ps1") 54 | assert.Equal(t, oneliner, "oneliner 1 hostname 2 name 3 rPath") 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/environments/util.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // ReadExternalFile to read an external file and return contents 11 | func ReadExternalFile(path string) string { 12 | content, err := os.ReadFile(path) 13 | if err != nil { 14 | return "" 15 | } 16 | return string(content) 17 | } 18 | 19 | // IsItExpired to determine if a time has expired, which makes it in the past 20 | func IsItExpired(t time.Time) bool { 21 | if t.IsZero() { 22 | return false 23 | } 24 | now := time.Now() 25 | return (int(t.Sub(now).Seconds()) <= 0) 26 | } 27 | 28 | // IsPlatformQuery to know if a plaform is going to trigger a query 29 | func IsPlatformQuery(pQuery, pCheck string) bool { 30 | // Empty plaform means all platforms 31 | if pQuery == "" || pQuery == "all" || pQuery == "any" { 32 | return true 33 | } 34 | // Check if platform is posix (darwin, freebsd, linux) 35 | if pQuery == "posix" && (pCheck == "darwin" || pCheck == "freebsd" || pCheck == "linux" || IsPlatformLinux(strings.ToLower(pCheck))) { 36 | return true 37 | } 38 | // Last check is platform itself 39 | return (pQuery == pCheck) 40 | } 41 | 42 | // IsPlatformLinux to know if a linux is going to trigger a query 43 | func IsPlatformLinux(pCheck string) bool { 44 | return (pCheck == "ubuntu" || pCheck == "centos" || pCheck == "rhel" || pCheck == "fedora" || pCheck == "debian" || pCheck == "opensuse" || pCheck == "arch" || pCheck == "amzn") 45 | } 46 | 47 | // PackageDownloadURL to get the download URL for a package 48 | func PackageDownloadURL(env TLSEnvironment, pkg string) string { 49 | if pkg == "" { 50 | return "" 51 | } 52 | if strings.HasPrefix(pkg, "https://") { 53 | return pkg 54 | } 55 | return fmt.Sprintf("https://%s/%s/%s/package/%s", env.Hostname, env.UUID, env.Secret, pkg) 56 | } 57 | 58 | // EnvironmentFinderID to find the environment and return its name based on the environment ID 59 | func EnvironmentFinderID(envID uint, envs []TLSEnvironment, uuid bool) string { 60 | if envID == 0 { 61 | return "None" 62 | } 63 | for _, env := range envs { 64 | if env.ID == envID { 65 | if uuid { 66 | return env.UUID 67 | } 68 | return env.Name 69 | } 70 | } 71 | return "Unknown" 72 | } 73 | 74 | // EnvironmentFinderUUID to find the environment and return its name based on the environment UUID 75 | func EnvironmentFinderUUID(envIdentifier string, envs []TLSEnvironment) string { 76 | for _, env := range envs { 77 | if env.UUID == envIdentifier || env.Name == envIdentifier { 78 | return env.Name 79 | } 80 | } 81 | return "Unknown" 82 | } 83 | -------------------------------------------------------------------------------- /pkg/logging/s3.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | osctrl_config "github.com/jmpsec/osctrl/pkg/config" 11 | "github.com/jmpsec/osctrl/pkg/settings" 12 | "github.com/rs/zerolog/log" 13 | 14 | "github.com/aws/aws-sdk-go-v2/aws" 15 | "github.com/aws/aws-sdk-go-v2/config" 16 | "github.com/aws/aws-sdk-go-v2/credentials" 17 | "github.com/aws/aws-sdk-go-v2/feature/s3/manager" 18 | "github.com/aws/aws-sdk-go-v2/service/s3" 19 | ) 20 | 21 | // LoggerS3 will be used to log data using S3 22 | type LoggerS3 struct { 23 | S3Config osctrl_config.S3Logger 24 | AWSConfig aws.Config 25 | Client *s3.Client 26 | Uploader *manager.Uploader 27 | Enabled bool 28 | Debug bool 29 | } 30 | 31 | // CreateLoggerS3 to initialize the logger 32 | func CreateLoggerS3(s3Config *osctrl_config.S3Logger) (*LoggerS3, error) { 33 | ctx := context.Background() 34 | creds := credentials.NewStaticCredentialsProvider(s3Config.AccessKey, s3Config.SecretAccessKey, "") 35 | cfg, err := config.LoadDefaultConfig( 36 | ctx, 37 | config.WithCredentialsProvider(creds), config.WithRegion(s3Config.Region), 38 | ) 39 | if err != nil { 40 | return nil, err 41 | } 42 | client := s3.NewFromConfig(cfg) 43 | uploader := manager.NewUploader(client) 44 | l := &LoggerS3{ 45 | S3Config: *s3Config, 46 | AWSConfig: cfg, 47 | Client: client, 48 | Uploader: uploader, 49 | Enabled: true, 50 | Debug: false, 51 | } 52 | return l, nil 53 | } 54 | 55 | // Settings - Function to prepare settings for the logger 56 | func (logS3 *LoggerS3) Settings(mgr *settings.Settings) { 57 | log.Info().Msg("No s3 logging settings") 58 | } 59 | 60 | // Send - Function that sends JSON logs to S3 61 | func (logS3 *LoggerS3) Send(logType string, data []byte, environment, uuid string, debug bool) { 62 | ctx := context.Background() 63 | if debug { 64 | log.Debug().Msgf("Sending %d bytes to S3 for %s - %s", len(data), environment, uuid) 65 | } 66 | ptrContentLength := int64(len(data)) 67 | result, err := logS3.Uploader.Upload(ctx, &s3.PutObjectInput{ 68 | Bucket: aws.String(logS3.S3Config.Bucket), 69 | Key: aws.String(environment + "/" + logType + "/" + uuid + ":" + strconv.FormatInt(time.Now().UnixMilli(), 10) + ".json"), 70 | Body: bytes.NewBuffer(data), 71 | ContentLength: &ptrContentLength, 72 | ContentType: aws.String(http.DetectContentType(data)), 73 | }) 74 | if err != nil { 75 | log.Err(err).Msg("Error sending data to s3") 76 | } 77 | if debug { 78 | log.Debug().Msgf("S3 Upload %+v", result) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osctrl 2 | 3 |

4 | osctrl 5 |

6 | Fast and efficient osquery management. 7 |

8 |

9 | 10 | Software License 11 | 12 | 13 | Build Status 14 | 15 | 16 | Go Report Card 17 | 18 |

19 |

20 | 21 | ## What is osctrl? 22 | 23 | **osctrl** is a fast and efficient [osquery](https://osquery.io) management solution, implementing its [remote API](https://osquery.readthedocs.io/en/stable/deployment/remote/) as TLS endpoint. 24 | 25 | With **osctrl** you can monitor all your systems running osquery, distribute its configuration fast, collect all the status and result logs and allow you to run on-demand queries. 26 | 27 | > [!WARNING] 28 | > **osctrl** is a fast evolving project, and while it is already being used in production environments, it is still under active development. Please make sure to read the documentation and understand its current state before deploying it in a critical environment. 29 | 30 | ## Running osctrl with docker for development 31 | 32 | You can use docker to run **osctrl** and all the components are defined in the `docker-compose-dev.yml` that ties all the components together, to serve a functional deployment. 33 | 34 | Ultimately you can just execute `make docker_dev` and it will automagically build and run **osctrl** locally in docker, for development purposes. 35 | 36 | ## Documentation 37 | 38 | You can find the documentation of the project in [https://osctrl.net](https://osctrl.net) 39 | 40 | ## Slack 41 | 42 | Find us in the #osctrl channel in the official osquery Slack community ([Request an auto-invite!](https://join.slack.com/t/osquery/shared_invite/zt-1wipcuc04-DBXmo51zYJKBu3_EP3xZPA)) 43 | 44 | ## License 45 | 46 | **osctrl** is licensed under the [MIT License](https://github.com/jmpsec/osctrl/blob/master/LICENSE). 47 | 48 | ## Contributing 49 | 50 | Feel free to fork the repository and submit pull requests. For major changes, please open an issue first to discuss what you would like to change. 51 | -------------------------------------------------------------------------------- /pkg/nodes/utils.go: -------------------------------------------------------------------------------- 1 | package nodes 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // For testing - allows us to mock time.Now() 10 | var timeNow = time.Now 11 | 12 | // IsActive determines if a node is active based on when it was last seen. 13 | // The inactive parameter specifies the number of hours a node can be without 14 | // checking in before it's considered inactive. This number is expected positive. 15 | // Returns true if the node has checked in within the specified timeframe. 16 | func IsActive(n OsqueryNode, inactive int64) bool { 17 | // If LastSeen is zero (never seen), node is not active 18 | if n.LastSeen.IsZero() { 19 | return false 20 | } 21 | // A node is active if it was seen more recently than the inactive threshold 22 | cutoffTime := ActiveTimeCutoff(inactive) 23 | return n.LastSeen.After(cutoffTime) 24 | } 25 | 26 | // ActiveTimeCutoff returns the cutoff time for active nodes 27 | // based on the specified number of hours 28 | func ActiveTimeCutoff(hours int64) time.Time { 29 | return timeNow().Add(-time.Duration(hours) * time.Hour) 30 | } 31 | 32 | // ApplyNodeTarget adds the appropriate query constraints for the target node status 33 | // (active, inactive, all) to the provided gorm query. Default is all nodes. 34 | func ApplyNodeTarget(query *gorm.DB, target string, hours int64) *gorm.DB { 35 | switch target { 36 | case AllNodes: 37 | return query 38 | case ActiveNodes: 39 | cutoff := ActiveTimeCutoff(hours) 40 | return query.Where("last_seen > ?", cutoff) 41 | case InactiveNodes: 42 | cutoff := ActiveTimeCutoff(hours) 43 | return query.Where("last_seen <= ?", cutoff) 44 | default: 45 | return query 46 | } 47 | } 48 | 49 | // GetStats retrieves node statistics (total, active, inactive) for the given filter condition 50 | func GetStats(db *gorm.DB, column, value string, hours int64) (StatsData, error) { 51 | var stats StatsData 52 | 53 | // Base query with the filter condition 54 | baseQuery := db.Model(&OsqueryNode{}).Where(column+" = ?", value) 55 | 56 | // Get total count 57 | if err := baseQuery.Count(&stats.Total).Error; err != nil { 58 | return stats, err 59 | } 60 | 61 | // Get active count - nodes seen after the cutoff time 62 | cutoff := ActiveTimeCutoff(hours) 63 | activeQuery := db.Model(&OsqueryNode{}).Where(column+" = ?", value).Where("last_seen > ?", cutoff) 64 | if err := activeQuery.Count(&stats.Active).Error; err != nil { 65 | return stats, err 66 | } 67 | 68 | // Get inactive count 69 | // Calculate inactive count as total - active to be consistent 70 | stats.Inactive = stats.Total - stats.Active 71 | 72 | return stats, nil 73 | } 74 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test_pr.yml: -------------------------------------------------------------------------------- 1 | name: Build and test PRs pushed to osctrl 2 | permissions: 3 | contents: read 4 | 5 | on: [push, pull_request] 6 | 7 | env: 8 | GOLANG_VERSION: 1.25.4 9 | OSQUERY_VERSION: 5.20.0 10 | 11 | jobs: 12 | build_and_test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | components: ["tls", "admin", "api", "cli"] 17 | goos: ["linux", "darwin", "windows"] 18 | goarch: ["amd64", "arm64"] 19 | steps: 20 | ########################### Checkout code ########################### 21 | - name: Checkout code 22 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 23 | with: 24 | fetch-depth: 2 25 | 26 | ########################### Generate SHA1 commit hash ########################### 27 | # https://newbedev.com/getting-current-branch-and-commit-hash-in-github-action 28 | - name: Declare GIT hash and branch 29 | id: vars 30 | shell: bash 31 | run: | 32 | echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 33 | echo "branch=$(echo ${GITHUB_REF#refs/heads/})" >> $GITHUB_OUTPUT 34 | echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 35 | 36 | ########################### Build osctrl ########################### 37 | - name: Build osctrl binaries 38 | # Build all osctrl components for linux for all archs 39 | # Build all osctrl components for darwin for all archs 40 | # Build osctrl cli for windows for all archs 41 | if: matrix.goos == 'linux' || matrix.goos == 'darwin' || (matrix.goos == 'windows' && matrix.components == 'cli') 42 | uses: ./.github/actions/build/binaries 43 | with: 44 | go_os: "${{ matrix.goos }}" 45 | go_arch: "${{ matrix.goarch }}" 46 | osctrl_component: "${{ matrix.components }}" 47 | commit_sha: "${{ steps.vars.outputs.sha_short }}" 48 | commit_branch: "${{ steps.vars.outputs.branch }}" 49 | golang_version: "${{ env.GOLANG_VERSION }}" 50 | 51 | ########################### Test binaries ########################### 52 | # - name: Run tests 53 | # id: bin_tests 54 | # uses: ./.github/actions/test/binaries 55 | # with: 56 | # go_os: "${{ matrix.goos }}" 57 | # go_arch: "${{ matrix.goarch }}" 58 | # osctrl_component: "${{ matrix.components }}" 59 | # commit_sha: "${{ steps.vars.outputs.sha_short }}" 60 | # commit_branch: "${{ steps.vars.outputs.branch }}" 61 | # golang_version: "${{ env.GOLANG_VERSION }}" 62 | -------------------------------------------------------------------------------- /.github/actions/test/binaries/action.yml: -------------------------------------------------------------------------------- 1 | name: "Test Osctrl binaries" 2 | description: "Run Osctrl tests" 3 | inputs: 4 | go_os: 5 | required: true 6 | description: Define the OS to compile binary for - https://pkg.go.dev/internal/goos 7 | go_arch: 8 | required: true 9 | description: Define the architecture to compile binary for - https://pkg.go.dev/internal/goarch 10 | osctrl_component: 11 | required: true 12 | description: Define the osctrl component to compile 13 | commit_sha: 14 | required: true 15 | description: Define the SHA1 git commit hash 16 | commit_branch: 17 | required: true 18 | description: Define the git branch 19 | golang_version: 20 | required: false 21 | description: Define the version of golang to compile with 22 | default: 1.25.4 23 | 24 | runs: 25 | using: "composite" 26 | steps: 27 | ########################### Install go to env ########################### 28 | - name: Set up Go 29 | uses: actions/setup-go@8e57b58e57be52ac95949151e2777ffda8501267 # v5.5.0 30 | with: 31 | go-version: ${{ inputs.golang_version }} 32 | - run: go version 33 | shell: bash 34 | 35 | ########################### Checkout code ########################### 36 | - name: Checkout code 37 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 38 | 39 | ########################### Lint golang code ########################### 40 | - name: golangci-lint 41 | uses: golangci/golangci-lint-action@e60da84bfae8c7920a47be973d75e15710aa8bd7 # v8.0.0 42 | with: 43 | version: v1.29 44 | 45 | ########################### Get GO deps ############################# 46 | - name: Get GO deps 47 | run: go mod download 48 | shell: bash 49 | 50 | ########################### Download artifacts ########################### 51 | - name: Download a osctrl binaries 52 | uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 53 | with: 54 | name: osctrl-${{ inputs.osctrl_component }}-${{ inputs.commit_branch }}-${{ inputs.commit_sha }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin 55 | 56 | ########################### Run tests ########################### 57 | - name: Run tests - go clean 58 | run: go clean -testcache ./... 59 | shell: bash 60 | 61 | - name: Run tests - go clean 62 | run: go test ./utils -v 63 | shell: bash 64 | 65 | - name: Run tests - go clean 66 | run: go test ./tls/handlers -v 67 | shell: bash 68 | 69 | - name: Run tests - go clean 70 | run: go clean -testcache ./... 71 | shell: bash 72 | -------------------------------------------------------------------------------- /cmd/tls/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jmpsec/osctrl/pkg/config" 5 | "github.com/jmpsec/osctrl/pkg/environments" 6 | "github.com/jmpsec/osctrl/pkg/settings" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | // Helper to determine if an IPv4 is public, based on the following: 11 | // Class Starting IPAddress Ending IPAddress 12 | // A 10.0.0.0 10.255.255.255 13 | // B 172.16.0.0 172.31.255.255 14 | // C 192.168.0.0 192.168.255.255 15 | // Link-local 169.254.0.0 169.254.255.255 16 | // Local 127.0.0.0 127.255.255.255 17 | /* 18 | func isPublicIP(ip net.IP) bool { 19 | // Use native functions 20 | if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() { 21 | return false 22 | } 23 | // Check each octet 24 | if ip4 := ip.To4(); ip4 != nil { 25 | switch { 26 | case ip4[0] == 10: 27 | return false 28 | case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31: 29 | return false 30 | case ip4[0] == 192 && ip4[1] == 168: 31 | return false 32 | default: 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | */ 39 | 40 | // Helper to refresh the environments map until cache/Redis support is implemented 41 | func refreshEnvironments() environments.MapEnvironments { 42 | log.Debug().Msg("Refreshing environments...") 43 | _envsmap, err := envs.GetMap() 44 | if err != nil { 45 | log.Err(err).Msg("error refreshing environments") 46 | return environments.MapEnvironments{} 47 | } 48 | return _envsmap 49 | } 50 | 51 | // Helper to refresh the settings until cache/Redis support is implemented 52 | func refreshSettings() settings.MapSettings { 53 | log.Debug().Msg("Refreshing settings...") 54 | _settingsmap, err := settingsmgr.GetMap(config.ServiceTLS, settings.NoEnvironmentID) 55 | if err != nil { 56 | log.Err(err).Msg("error refreshing settings") 57 | return settings.MapSettings{} 58 | } 59 | return _settingsmap 60 | } 61 | 62 | // Helper to convert YAML settings loaded from file to settings 63 | func loadedYAMLToServiceParams(yml config.TLSConfiguration, loadedFile string) *config.ServiceParameters { 64 | return &config.ServiceParameters{ 65 | ConfigFlag: true, 66 | ServiceConfigFile: loadedFile, 67 | Service: &yml.Service, 68 | DB: &yml.DB, 69 | BatchWriter: &yml.BatchWriter, 70 | Redis: &yml.Redis, 71 | Osquery: &yml.Osquery, 72 | Osctrld: &yml.Osctrld, 73 | Metrics: &yml.Metrics, 74 | TLS: &yml.TLS, 75 | Logger: &yml.Logger, 76 | Carver: &yml.Carver, 77 | Debug: &yml.Debug, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cmd/admin/static/js/enrolls.js: -------------------------------------------------------------------------------- 1 | function genericLinkAction(_type, _action) { 2 | var _csrftoken = $("#csrftoken").val(); 3 | var _url = "/expiration/" + window.location.pathname.split("/").pop(); 4 | var data = { 5 | csrftoken: _csrftoken, 6 | type: _type, 7 | action: _action, 8 | }; 9 | sendPostRequest(data, _url, window.location.pathname, false); 10 | } 11 | 12 | function extendEnrollLink() { 13 | genericLinkAction("enroll", "extend"); 14 | } 15 | 16 | function expireEnrollLink() { 17 | genericLinkAction("enroll", "expire"); 18 | } 19 | 20 | function rotateEnrollLink() { 21 | genericLinkAction("enroll", "rotate"); 22 | } 23 | 24 | function notexpireEnrollLink() { 25 | genericLinkAction("enroll", "notexpire"); 26 | } 27 | 28 | function extendRemoveLink() { 29 | genericLinkAction("remove", "extend"); 30 | } 31 | 32 | function expireRemoveLink() { 33 | genericLinkAction("remove", "expire"); 34 | } 35 | 36 | function rotateRemoveLink() { 37 | genericLinkAction("remove", "rotate"); 38 | } 39 | 40 | function notexpireRemoveLink() { 41 | genericLinkAction("remove", "notexpire"); 42 | } 43 | 44 | function confirmUploadCertificate() { 45 | $("#certificate_action").click(function () { 46 | $("#certificateModal").modal("hide"); 47 | uploadCertificate(); 48 | }); 49 | $("#certificateModal").modal(); 50 | } 51 | 52 | function uploadCertificate() { 53 | var _csrftoken = $("#csrftoken").val(); 54 | var _blob = $("#certificate").data("CodeMirrorInstance"); 55 | var _certificate = _blob.getValue(); 56 | 57 | var _url = window.location.pathname; 58 | 59 | var data = { 60 | csrftoken: _csrftoken, 61 | action: "enroll_certificate", 62 | certificate: btoa(_certificate), 63 | }; 64 | sendPostRequest(data, _url, window.location.pathname, false); 65 | } 66 | 67 | function saveDebPackage() { 68 | var _package = $("#deb-package-value").val(); 69 | savePackage(_package, "package_deb"); 70 | } 71 | 72 | function saveRpmPackage() { 73 | var _package = $("#rpm-package-value").val(); 74 | savePackage(_package, "package_rpm"); 75 | } 76 | 77 | function savePkgPackage() { 78 | var _package = $("#pkg-package-value").val(); 79 | savePackage(_package, "package_pkg"); 80 | } 81 | 82 | function saveMsiPackage() { 83 | var _package = $("#msi-package-value").val(); 84 | savePackage(_package, "package_msi"); 85 | } 86 | 87 | function savePackage(_package, _action) { 88 | var _csrftoken = $("#csrftoken").val(); 89 | 90 | var _url = window.location.pathname; 91 | 92 | var data = { 93 | csrftoken: _csrftoken, 94 | action: _action, 95 | packageurl: _package, 96 | }; 97 | sendPostRequest(data, _url, window.location.pathname, false); 98 | } 99 | -------------------------------------------------------------------------------- /deploy/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | # User to run nginx as. It changes based on the host. 2 | # Ubuntu: www-data 3 | # CentOS: nginx 4 | # Docker: nginx 5 | user SERVER_USER; 6 | 7 | worker_processes auto; 8 | pid /run/nginx.pid; 9 | 10 | # Load dynamic modules. See /usr/share/nginx/README.dynamic. 11 | # Ubuntu: /etc/nginx/modules-enabled/*.conf; 12 | # CentOS: /usr/share/nginx/modules/*.conf; 13 | include MODULES_CONF; 14 | 15 | events { 16 | worker_connections 1024; 17 | } 18 | 19 | http { 20 | ## 21 | # Logging Settings 22 | ## 23 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 24 | '$status $body_bytes_sent "$http_referer" ' 25 | '"$http_user_agent" "$http_x_forwarded_for"'; 26 | 27 | access_log /var/log/nginx/access.log; 28 | error_log /var/log/nginx/error.log; 29 | 30 | ## 31 | # Performance Settings 32 | ## 33 | 34 | # copies data between one FD and other from within the kernel 35 | # faster than read() + write() 36 | sendfile on; 37 | 38 | # send headers in one piece, it is better than sending them one by one 39 | tcp_nopush on; 40 | 41 | # don't buffer data sent, good for small data bursts in real time 42 | tcp_nodelay on; 43 | 44 | # allow the server to close connection on non responding client, this will free up memory 45 | reset_timedout_connection on; 46 | 47 | # request timed out -- default 60 48 | client_body_timeout 10; 49 | 50 | # if client stop responding, free up memory -- default 60 51 | send_timeout 2; 52 | 53 | # server will close connection after this time -- default 75 54 | keepalive_timeout 30; 55 | 56 | ## 57 | # Random Settings 58 | ## 59 | include /etc/nginx/mime.types; 60 | default_type application/octet-stream; 61 | 62 | # Limit size of request body 63 | client_max_body_size 20M; 64 | 65 | # Do not send nginx version number in error pages or server header 66 | server_tokens off; 67 | server_name_in_redirect off; 68 | 69 | # Passive protections 70 | add_header X-XSS-Protection "1; mode=block"; 71 | add_header X-Content-Type-Options nosniff; 72 | 73 | # CSP headers 74 | add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self'; frame-src 'self'; object-src 'none'"; 75 | 76 | # Listen to 80, redirect to HTTPS 77 | server { 78 | listen 80; 79 | rewrite ^ https://$host$request_uri? permanent; 80 | } 81 | 82 | ## 83 | # http://nginx.org/en/docs/ngx_core_module.html#include 84 | ## 85 | include /etc/nginx/conf.d/*.conf; 86 | include /etc/nginx/sites-enabled/*; 87 | } 88 | -------------------------------------------------------------------------------- /pkg/logging/file.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "github.com/jmpsec/osctrl/pkg/config" 5 | "github.com/jmpsec/osctrl/pkg/settings" 6 | "github.com/jmpsec/osctrl/pkg/types" 7 | "github.com/rs/zerolog" 8 | "github.com/rs/zerolog/log" 9 | lumberjack "gopkg.in/natefinch/lumberjack.v2" 10 | ) 11 | 12 | // LoggerFile will be used to log data using external file 13 | type LoggerFile struct { 14 | Enabled bool 15 | Filename string 16 | Logger *zerolog.Logger 17 | } 18 | 19 | // CreateLoggerFile to initialize the logger 20 | func CreateLoggerFile(cfg *config.LocalLogger) (*LoggerFile, error) { 21 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 22 | z := zerolog.New(&lumberjack.Logger{ 23 | Filename: cfg.FilePath, 24 | MaxSize: cfg.MaxSize, 25 | MaxBackups: cfg.MaxBackups, 26 | MaxAge: cfg.MaxAge, 27 | Compress: cfg.Compress, 28 | }) 29 | logger := z.With().Caller().Timestamp().Logger() 30 | return &LoggerFile{ 31 | Enabled: true, 32 | Filename: cfg.FilePath, 33 | Logger: &logger, 34 | }, nil 35 | } 36 | 37 | // Settings - Function to prepare settings for the logger 38 | func (logFile *LoggerFile) Settings(mgr *settings.Settings) { 39 | log.Info().Msg("No file logging settings") 40 | } 41 | 42 | // Log - Function that sends JSON result/status/query logs to stdout 43 | func (logFile *LoggerFile) Log(logType string, data []byte, environment, uuid string, debug bool) { 44 | if debug { 45 | log.Debug().Msgf("Sending %d bytes to stdout for %s - %s", len(data), environment, uuid) 46 | } 47 | switch logType { 48 | case types.StatusLog: 49 | logFile.Status(data, environment, uuid, debug) 50 | case types.ResultLog: 51 | logFile.Result(data, environment, uuid, debug) 52 | } 53 | } 54 | 55 | // Status - Function that sends JSON status logs to stdout 56 | func (logFile *LoggerFile) Status(data []byte, environment, uuid string, debug bool) { 57 | logFile.Logger.Info().Str( 58 | "type", types.StatusLog).Str( 59 | "environment", environment).Str( 60 | "uuid", uuid).RawJSON("data", data) 61 | } 62 | 63 | // Result - Function that sends JSON result logs to stdout 64 | func (logFile *LoggerFile) Result(data []byte, environment, uuid string, debug bool) { 65 | logFile.Logger.Info().Str( 66 | "type", types.ResultLog).Str( 67 | "environment", environment).Str( 68 | "uuid", uuid).RawJSON("data", data) 69 | } 70 | 71 | // Query - Function that sends JSON query logs to stdout 72 | func (logFile *LoggerFile) Query(data []byte, environment, uuid, name string, status int, debug bool) { 73 | logFile.Logger.Info().Str( 74 | "type", types.QueryLog).Str( 75 | "environment", environment).Str( 76 | "name", name).Int( 77 | "status", status).Str( 78 | "uuid", uuid).RawJSON("data", data) 79 | } 80 | -------------------------------------------------------------------------------- /deploy/docker/conf/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | # User to run nginx as. It changes based on the host. 2 | # Ubuntu: www-data 3 | # CentOS: nginx 4 | # Docker: nginx 5 | user nginx; 6 | 7 | worker_processes auto; 8 | pid /run/nginx.pid; 9 | 10 | # Load dynamic modules. See /usr/share/nginx/README.dynamic. 11 | # Ubuntu: /etc/nginx/modules-enabled/*.conf; 12 | # CentOS: /usr/share/nginx/modules/*.conf; 13 | include /usr/share/nginx/modules/*.conf; 14 | 15 | events { 16 | worker_connections 1024; 17 | } 18 | 19 | http { 20 | ## 21 | # Logging Settings 22 | ## 23 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 24 | '$status $body_bytes_sent "$http_referer" ' 25 | '"$http_user_agent" "$http_x_forwarded_for"'; 26 | 27 | access_log /var/log/nginx/access.log; 28 | error_log /var/log/nginx/error.log; 29 | 30 | ## 31 | # Performance Settings 32 | ## 33 | 34 | # copies data between one FD and other from within the kernel 35 | # faster than read() + write() 36 | sendfile on; 37 | 38 | # send headers in one piece, it is better than sending them one by one 39 | tcp_nopush on; 40 | 41 | # don't buffer data sent, good for small data bursts in real time 42 | tcp_nodelay on; 43 | 44 | # allow the server to close connection on non responding client, this will free up memory 45 | reset_timedout_connection on; 46 | 47 | # request timed out -- default 60 48 | client_body_timeout 10; 49 | 50 | # if client stop responding, free up memory -- default 60 51 | send_timeout 2; 52 | 53 | # server will close connection after this time -- default 75 54 | keepalive_timeout 30; 55 | 56 | ## 57 | # Random Settings 58 | ## 59 | include /etc/nginx/mime.types; 60 | default_type application/octet-stream; 61 | 62 | # Limit size of request body 63 | client_max_body_size 20M; 64 | 65 | # Do not send nginx version number in error pages or server header 66 | server_tokens off; 67 | server_name_in_redirect off; 68 | 69 | # Passive protections 70 | add_header X-XSS-Protection "1; mode=block"; 71 | add_header X-Content-Type-Options nosniff; 72 | 73 | # CSP headers 74 | add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self'; frame-src 'self'; object-src 'none'"; 75 | 76 | # Listen to 80, redirect to HTTPS 77 | server { 78 | listen 80; 79 | rewrite ^ https://$host$request_uri? permanent; 80 | } 81 | 82 | ## 83 | # http://nginx.org/en/docs/ngx_core_module.html#include 84 | ## 85 | include /etc/nginx/conf.d/*.conf; 86 | include /etc/nginx/sites-enabled/*; 87 | } 88 | -------------------------------------------------------------------------------- /cmd/admin/handlers/json-audit.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/jmpsec/osctrl/cmd/admin/sessions" 8 | "github.com/jmpsec/osctrl/pkg/environments" 9 | "github.com/jmpsec/osctrl/pkg/users" 10 | "github.com/jmpsec/osctrl/pkg/utils" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // AuditLogJSON to be used to populate JSON data for audit logs 15 | type AuditLogJSON struct { 16 | Service string `json:"service"` 17 | Username string `json:"username"` 18 | Line string `json:"line"` 19 | SourceIP string `json:"sourceip"` 20 | LogType string `json:"logtype"` 21 | Severity string `json:"severity"` 22 | Env string `json:"environment"` 23 | When CreationTimes `json:"when"` 24 | } 25 | 26 | // ReturnedAudit to return a JSON with audit logs 27 | type ReturnedAudit struct { 28 | Data []AuditLogJSON `json:"data"` 29 | } 30 | 31 | // JSONAuditLogHandler for audit logs in JSON 32 | func (h *HandlersAdmin) JSONAuditLogHandler(w http.ResponseWriter, r *http.Request) { 33 | if h.DebugHTTPConfig.EnableHTTP { 34 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 35 | } 36 | // Get context data 37 | ctx := r.Context().Value(sessions.ContextKey(sessions.CtxSession)).(sessions.ContextValue) 38 | // Check permissions 39 | if !h.Users.CheckPermissions(ctx[sessions.CtxUser], users.AdminLevel, users.NoEnvironment) { 40 | adminErrorResponse(w, fmt.Sprintf("%s has insufficient permissions", ctx[sessions.CtxUser]), http.StatusForbidden, nil) 41 | return 42 | } 43 | // Get all environments 44 | envs, err := h.Envs.All() 45 | if err != nil { 46 | log.Err(err).Msg("error getting environments") 47 | return 48 | } 49 | // Get audit logs 50 | auditLogs, err := h.AuditLog.GetAll() 51 | if err != nil { 52 | log.Err(err).Msg("error getting audit logs") 53 | return 54 | } 55 | // Prepare data to be returned 56 | var auditLogsJSON []AuditLogJSON 57 | for _, logEntry := range auditLogs { 58 | auditLogsJSON = append(auditLogsJSON, AuditLogJSON{ 59 | Service: logEntry.Service, 60 | Username: logEntry.Username, 61 | Line: logEntry.Line, 62 | SourceIP: logEntry.SourceIP, 63 | LogType: h.AuditLog.LogTypeToString(logEntry.LogType), 64 | Severity: h.AuditLog.SeverityToString(logEntry.Severity), 65 | Env: environments.EnvironmentFinderID(logEntry.EnvironmentID, envs, false), 66 | When: CreationTimes{ 67 | Display: utils.PastFutureTimes(logEntry.CreatedAt), 68 | // Use Unix timestamp in seconds 69 | Timestamp: utils.TimeTimestamp(logEntry.CreatedAt), 70 | }, 71 | }) 72 | } 73 | returned := ReturnedAudit{ 74 | Data: auditLogsJSON, 75 | } 76 | // Serve JSON 77 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, returned) 78 | } 79 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build a release with GoReleaser 2 | 3 | on: 4 | push: 5 | # Publish semver tags as releases. 6 | tags: ["v*.*.*"] 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | env: 13 | GOLANG_VERSION: 1.25.4 14 | 15 | jobs: 16 | goreleaser: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@8e57b58e57be52ac95949151e2777ffda8501267 # v5.5.0 26 | with: 27 | go-version: ${{ env.GOLANG_VERSION }} 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@af1b253b8dc984466d22633f04ef341c1520ed2f # v3.11.1 31 | 32 | - name: Log in to Docker Hub 33 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 34 | with: 35 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 36 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 37 | 38 | - name: Run GoReleaser 39 | uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 40 | with: 41 | distribution: goreleaser 42 | version: latest 43 | args: release --snapshot --skip-publish --clean 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | DOCKER_HUB_ORG: ${{ secrets.DOCKER_HUB_ORG }} 47 | 48 | # Optional: Sign Docker images with cosign 49 | sign: 50 | needs: goreleaser 51 | runs-on: ubuntu-latest 52 | if: startsWith(github.ref, 'refs/tags/') 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 56 | 57 | - name: Install cosign 58 | uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 59 | 60 | - name: Log in to Docker Hub 61 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 62 | with: 63 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 64 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 65 | 66 | - name: Sign Docker images 67 | run: | 68 | for component in tls admin api cli; do 69 | cosign sign --yes docker.io/${{ secrets.DOCKER_HUB_ORG }}/osctrl-$component:${{ github.ref_name }} 70 | cosign verify \ 71 | --certificate-identity-regexp="https://github.com/${{ github.repository }}/.github/workflows/.*" \ 72 | --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ 73 | docker.io/${{ secrets.DOCKER_HUB_ORG }}/osctrl-$component:${{ github.ref_name }} 74 | done 75 | -------------------------------------------------------------------------------- /cmd/api/handlers/get.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/jmpsec/osctrl/pkg/users" 8 | "github.com/jmpsec/osctrl/pkg/utils" 9 | ) 10 | 11 | // HealthHandler - Handle health requests 12 | func (h *HandlersApi) HealthHandler(w http.ResponseWriter, r *http.Request) { 13 | // Debug HTTP if enabled 14 | if h.DebugHTTPConfig.EnableHTTP { 15 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 16 | } 17 | // Send response 18 | utils.HTTPResponse(w, "", http.StatusOK, []byte(okContent)) 19 | } 20 | 21 | // CheckHandlerNoAuth - Handle unauthenticated check requests 22 | func (h *HandlersApi) CheckHandlerNoAuth(w http.ResponseWriter, r *http.Request) { 23 | // Debug HTTP if enabled 24 | if h.DebugHTTPConfig.EnableHTTP { 25 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 26 | } 27 | // Send response 28 | utils.HTTPResponse(w, "Checked", http.StatusOK, []byte(okContent)) 29 | } 30 | 31 | // CheckHandlerAuth - Handle authenticated check requests 32 | func (h *HandlersApi) CheckHandlerAuth(w http.ResponseWriter, r *http.Request) { 33 | // Debug HTTP if enabled 34 | if h.DebugHTTPConfig.EnableHTTP { 35 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 36 | } 37 | // Get context data and check access 38 | ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) 39 | if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, users.NoEnvironment) { 40 | apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) 41 | return 42 | } 43 | // Send response 44 | utils.HTTPResponse(w, "Checked", http.StatusOK, []byte(okContent)) 45 | } 46 | 47 | // RootHandler - Handle root requests 48 | func (h *HandlersApi) RootHandler(w http.ResponseWriter, r *http.Request) { 49 | // Debug HTTP if enabled 50 | if h.DebugHTTPConfig.EnableHTTP { 51 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 52 | } 53 | // Send response 54 | utils.HTTPResponse(w, "", http.StatusOK, []byte(okContent)) 55 | } 56 | 57 | // ErrorHandler - Handle error requests 58 | func (h *HandlersApi) ErrorHandler(w http.ResponseWriter, r *http.Request) { 59 | // Debug HTTP if enabled 60 | if h.DebugHTTPConfig.EnableHTTP { 61 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 62 | } 63 | // Send response 64 | utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte(errorContent)) 65 | } 66 | 67 | // ForbiddenHandler - Handle forbidden error requests 68 | func (h *HandlersApi) ForbiddenHandler(w http.ResponseWriter, r *http.Request) { 69 | // Debug HTTP if enabled 70 | if h.DebugHTTPConfig.EnableHTTP { 71 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 72 | } 73 | // Send response 74 | utils.HTTPResponse(w, "", http.StatusForbidden, []byte(errorContent)) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/admin/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ template "page-head" . }} 5 | 6 | 7 |
8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 |

Login

16 |

get access to {{ .Project }}

17 |
18 |
19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 |
38 | 39 |
40 |
41 | 42 | 63 | 64 | 65 |
66 | 67 | {{ template "page-js" . }} 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /.github/actions/tagged_release/docker/codesign/action.yml: -------------------------------------------------------------------------------- 1 | name: "Sign osctrl Docker images" 2 | description: "Sign osctrl Docker images" 3 | inputs: 4 | osctrl_component: 5 | required: true 6 | description: Define the osctrl component to compile 7 | docker_tags: 8 | required: true 9 | description: Define the Docker tag 10 | docker_image_digests: 11 | required: true 12 | description: Dockerhub image digest 13 | docker_hub_org: 14 | required: true 15 | description: Pass DockerHub org to action 16 | docker_hub_username: 17 | required: true 18 | description: Pass DockerHub username to action 19 | docker_hub_access_token: 20 | required: true 21 | description: Pass DockerHub access token to action 22 | 23 | runs: 24 | using: "composite" 25 | steps: 26 | ########################### Checkout code ########################### 27 | - name: Checkout code 28 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 29 | with: 30 | fetch-depth: 2 31 | 32 | ########################### Install cosign ########################### 33 | # https://github.com/sigstore/cosign-installer 34 | - name: Install cosign 35 | uses: sigstore/cosign-installer@v3.9.2 36 | 37 | ########################### Log into Dockerhub ########################### 38 | - name: Login to Docker Hub 39 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 40 | with: 41 | username: ${{ inputs.docker_hub_username }} 42 | password: ${{ inputs.docker_hub_access_token }} 43 | 44 | ########################### Sign built Docker image using keyless cosign ########################### 45 | - name: Sign the published Docker image 46 | shell: bash 47 | run: | 48 | # Parse space-separated digests 49 | IFS=' ' read -ra DIGESTS <<< "${{ inputs.docker_image_digests }}" 50 | 51 | for digest in "${DIGESTS[@]}" 52 | do 53 | IMAGE_NAME="${{ inputs.docker_hub_org }}/osctrl-${{ inputs.osctrl_component }}" 54 | cosign sign --yes docker.io/$IMAGE_NAME@sha256:$digest 55 | done 56 | 57 | ########################### Verify signed image using keyless cosign ########################### 58 | - name: Verify the signed published Docker image 59 | shell: bash 60 | run: | 61 | # Parse space-separated digests 62 | IFS=' ' read -ra DIGESTS <<< "${{ inputs.docker_image_digests }}" 63 | 64 | for digest in "${DIGESTS[@]}" 65 | do 66 | IMAGE_NAME="${{ inputs.docker_hub_org }}/osctrl-${{ inputs.osctrl_component }}" 67 | cosign verify \ 68 | --certificate-identity-regexp="https://github.com/${{ github.repository }}/.github/workflows/.*" \ 69 | --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ 70 | docker.io/$IMAGE_NAME@sha256:$digest 71 | done 72 | -------------------------------------------------------------------------------- /cmd/admin/static/js/profile.js: -------------------------------------------------------------------------------- 1 | function profileChangePassword(_username) { 2 | $("#new_password").val(''); 3 | $("#confirm_password").val(''); 4 | $("#change_password_username").val(_username); 5 | $("#change_password_header").text('Change Password for ' + _username); 6 | $("#changePasswordModal").modal(); 7 | } 8 | 9 | function profileConfirmChangePassword() { 10 | var _csrftoken = $("#csrftoken").val(); 11 | 12 | var _url = window.location.pathname; 13 | 14 | var _username = $("#change_password_username").val(); 15 | var _newpassword = $("#new_password").val(); 16 | var _oldpassword = $("#old_password").val(); 17 | 18 | var data = { 19 | csrftoken: _csrftoken, 20 | action: 'change_password', 21 | username: _username, 22 | new_password: _newpassword, 23 | old_password: _oldpassword, 24 | }; 25 | sendPostRequest(data, _url, _url, false); 26 | } 27 | 28 | function profileEditSave() { 29 | var _csrftoken = $("#csrftoken").val(); 30 | 31 | var _url = window.location.pathname; 32 | 33 | var _username = $("#profile_username").val(); 34 | var _email = $("#profile_email").val(); 35 | var _fullname = $("#profile_fullname").val(); 36 | 37 | var data = { 38 | csrftoken: _csrftoken, 39 | action: 'edit', 40 | username: _username, 41 | email: _email, 42 | fullname: _fullname, 43 | }; 44 | sendPostRequest(data, _url, '', true); 45 | } 46 | 47 | function toggleAPIToken() { 48 | var tokenField = document.getElementById("profile_api_token"); 49 | var tokenValue = document.getElementById("profile_api_token_value").value; 50 | if (tokenField.type === "password") { 51 | tokenField.type = "text"; 52 | tokenField.value = tokenValue; 53 | $("#button-eye").html(''); 54 | } else { 55 | tokenField.type = "password"; 56 | tokenField.value = "••••••••••••••••••••••••••••••"; 57 | $("#button-eye").html(''); 58 | } 59 | } 60 | 61 | function refreshUserToken() { 62 | $("#refreshTokenButton").prop("disabled", true); 63 | $("#refreshTokenButton").html( 64 | '' 65 | ); 66 | var _csrftoken = $("#csrftoken").val(); 67 | var _username = $("#profile_token_username").val(); 68 | var _exp_hours = parseInt($("#profile_exp_hours").val()); 69 | var data = { 70 | csrftoken: _csrftoken, 71 | username: _username, 72 | exp_hours: _exp_hours, 73 | }; 74 | sendPostRequest( 75 | data, 76 | "/tokens/" + _username + "/refresh", 77 | "", 78 | false, 79 | function (data) { 80 | console.log(data); 81 | $("#profile_api_token_value").val(data.token); 82 | var expDiv = document.getElementById('profile_token_exp'); 83 | expDiv.innerText = data.expiration; 84 | $("#refreshTokenButton").prop("disabled", false); 85 | $("#refreshTokenButton").html(''); 86 | } 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /pkg/logging/kinesis.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jmpsec/osctrl/pkg/config" 8 | "github.com/jmpsec/osctrl/pkg/settings" 9 | "github.com/rs/zerolog/log" 10 | 11 | "github.com/aws/aws-sdk-go-v2/aws" 12 | awsconfig "github.com/aws/aws-sdk-go-v2/config" 13 | "github.com/aws/aws-sdk-go-v2/credentials" 14 | "github.com/aws/aws-sdk-go-v2/service/kinesis" 15 | ) 16 | 17 | // LoggerKinesis will be used to log data using Kinesis 18 | type LoggerKinesis struct { 19 | Configuration config.KinesisLogger 20 | KinesisClient *kinesis.Client 21 | Enabled bool 22 | } 23 | 24 | // CreateLoggerKinesis to initialize the logger 25 | func CreateLoggerKinesis(cfg *config.KinesisLogger) (*LoggerKinesis, error) { 26 | loadOpts := []func(*awsconfig.LoadOptions) error{ 27 | awsconfig.WithRegion(cfg.Region), 28 | } 29 | if cfg.AccessKeyID != "" || cfg.SecretAccessKey != "" || cfg.SessionToken != "" { 30 | loadOpts = append(loadOpts, 31 | awsconfig.WithCredentialsProvider( 32 | credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, cfg.SessionToken), 33 | )) 34 | } 35 | 36 | awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), loadOpts...) 37 | if err != nil { 38 | return nil, fmt.Errorf("load AWS config: %w", err) 39 | } 40 | 41 | var kinesisOpts []func(*kinesis.Options) 42 | if cfg.Endpoint != "" { 43 | endpoint := cfg.Endpoint 44 | kinesisOpts = append(kinesisOpts, func(o *kinesis.Options) { 45 | o.BaseEndpoint = aws.String(endpoint) 46 | }) 47 | } 48 | 49 | kc := kinesis.NewFromConfig(awsCfg, kinesisOpts...) 50 | 51 | if _, err := kc.DescribeStream(context.Background(), &kinesis.DescribeStreamInput{ 52 | StreamName: aws.String(cfg.Stream), 53 | }); err != nil { 54 | return nil, fmt.Errorf("DescribeStream: %w", err) 55 | } 56 | 57 | return &LoggerKinesis{ 58 | Configuration: *cfg, 59 | KinesisClient: kc, 60 | Enabled: true, 61 | }, nil 62 | } 63 | 64 | // Settings - Function to prepare settings for the logger 65 | func (logSK *LoggerKinesis) Settings(mgr *settings.Settings) { 66 | log.Info().Msg("No kinesis logging settings") 67 | } 68 | 69 | // Send - Function that sends JSON logs to Splunk HTTP Event Collector 70 | func (logSK *LoggerKinesis) Send(logType string, data []byte, environment, uuid string, debug bool) { 71 | if debug { 72 | log.Debug().Msgf("Sending %d bytes to Kinesis for %s - %s", len(data), environment, uuid) 73 | } 74 | streamName := aws.String(logSK.Configuration.Stream) 75 | putOutput, err := logSK.KinesisClient.PutRecord(context.Background(), &kinesis.PutRecordInput{ 76 | Data: data, 77 | StreamName: streamName, 78 | PartitionKey: aws.String(logType + ":" + environment + ":" + uuid), 79 | }) 80 | if err != nil { 81 | log.Err(err).Msg("Error sending kinesis stream") 82 | return 83 | } 84 | if debug { 85 | log.Debug().Msgf("PutRecordOutput %+v", putOutput) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cmd/api/handlers/platforms.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/jmpsec/osctrl/pkg/auditlog" 9 | "github.com/jmpsec/osctrl/pkg/users" 10 | "github.com/jmpsec/osctrl/pkg/utils" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // PlatformsHandler - GET Handler for multiple JSON platforms 15 | func (h *HandlersApi) PlatformsHandler(w http.ResponseWriter, r *http.Request) { 16 | // Debug HTTP if enabled 17 | if h.DebugHTTPConfig.EnableHTTP { 18 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 19 | } 20 | // Get context data and check access 21 | ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) 22 | if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { 23 | apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) 24 | return 25 | } 26 | // Get platforms 27 | platforms, err := h.Nodes.GetAllPlatforms() 28 | if err != nil { 29 | apiErrorResponse(w, "error getting platforms", http.StatusInternalServerError, err) 30 | return 31 | } 32 | // Serialize and serve JSON 33 | log.Debug().Msg("Returned platforms") 34 | h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) 35 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, platforms) 36 | } 37 | 38 | // PlatformsEnvHandler - GET Handler to return platforms for one environment as JSON 39 | func (h *HandlersApi) PlatformsEnvHandler(w http.ResponseWriter, r *http.Request) { 40 | // Debug HTTP if enabled 41 | if h.DebugHTTPConfig.EnableHTTP { 42 | utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) 43 | } 44 | // Extract environment 45 | envVar := r.PathValue("env") 46 | if envVar == "" { 47 | apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) 48 | return 49 | } 50 | // Get environment by name 51 | env, err := h.Envs.GetByUUID(envVar) 52 | if err != nil { 53 | if err.Error() == "record not found" { 54 | apiErrorResponse(w, "environment not found", http.StatusNotFound, err) 55 | } else { 56 | apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) 57 | } 58 | return 59 | } 60 | // Get context data and check access 61 | ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) 62 | if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { 63 | apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) 64 | return 65 | } 66 | // Get platforms 67 | platforms, err := h.Nodes.GetEnvPlatforms(env.UUID) 68 | if err != nil { 69 | apiErrorResponse(w, "error getting platforms", http.StatusInternalServerError, err) 70 | return 71 | } 72 | // Serialize and serve JSON 73 | log.Debug().Msg("Returned platforms") 74 | h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) 75 | utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, platforms) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/utils/time-utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | var ( 9 | // OneMinute - 60 seconds 10 | OneMinute = 60 * time.Second 11 | // OneHour - 3600 seconds 12 | OneHour = 60 * time.Minute 13 | // SixHours - 21600 seconds 14 | SixHours = 6 * time.Hour 15 | // OneDay - 86400 seconds 16 | OneDay = 24 * time.Hour 17 | // FifteenDays - 1296000 seconds 18 | FifteenDays = 15 * OneDay 19 | ) 20 | 21 | // StringifyTime - Helper to get a string based on the difference of two times 22 | func StringifyTime(seconds int) string { 23 | var timeStr string 24 | w := make(map[int]string) 25 | w[DurationSeconds(OneDay)] = "day" 26 | w[DurationSeconds(OneHour)] = "hour" 27 | w[DurationSeconds(OneMinute)] = "minute" 28 | // Ordering the values will prevent bad values 29 | ww := [3]int{DurationSeconds(OneDay), DurationSeconds(OneHour), DurationSeconds(OneMinute)} 30 | for _, v := range ww { 31 | if seconds >= v { 32 | d := seconds / v 33 | dStr := strconv.Itoa(d) 34 | timeStr = dStr + " " + w[v] 35 | if d > 1 { 36 | timeStr += "s" 37 | } 38 | break 39 | } 40 | } 41 | return timeStr 42 | } 43 | 44 | // DurationSeconds - Helper to get the seconds value fom a Duration 45 | func DurationSeconds(duration time.Duration) int { 46 | return int(duration.Seconds()) 47 | } 48 | 49 | // TimeTimestamp - Helper to format times in timestamp format 50 | func TimeTimestamp(t time.Time) string { 51 | return strconv.FormatInt(t.Unix(), 10) 52 | } 53 | 54 | // PastFutureTimes - Helper to format past or future times 55 | func PastFutureTimes(t time.Time) string { 56 | if t.Before(time.Now()) { 57 | return PastTimeAgo(t) 58 | } 59 | return InFutureTime(t) 60 | } 61 | 62 | // PastFutureTimesEpoch - Helper to format past or future times 63 | func PastFutureTimesEpoch(ts int64) string { 64 | return PastFutureTimes(time.Unix(ts, 0)) 65 | } 66 | 67 | // PastTimeAgo - Helper to format past times only returning one value (minute, hour, day) 68 | func PastTimeAgo(t time.Time) string { 69 | if t.IsZero() { 70 | return "Never" 71 | } 72 | now := time.Now() 73 | seconds := DurationSeconds(now.Sub(t)) 74 | if seconds < 2 { 75 | return "Just Now" 76 | } 77 | if seconds < DurationSeconds(OneMinute) { 78 | return strconv.Itoa(seconds) + " seconds ago" 79 | } 80 | if seconds > DurationSeconds(FifteenDays) { 81 | return "Since " + t.Format("Mon Jan 02 15:04:05 MST 2006") 82 | } 83 | return StringifyTime(seconds) + " ago" 84 | } 85 | 86 | // InFutureTime - Helper to format future times only returning one value (minute, hour, day) 87 | func InFutureTime(t time.Time) string { 88 | if t.IsZero() { 89 | return "Never Expires" 90 | } 91 | now := time.Now() 92 | seconds := int(t.Sub(now).Seconds()) 93 | if seconds <= 2 { 94 | return "Expired" 95 | } 96 | if seconds < DurationSeconds(OneMinute) { 97 | return "Expires in " + strconv.Itoa(seconds) + " seconds" 98 | } 99 | return "Expires in " + StringifyTime(seconds) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/carves/utils.go: -------------------------------------------------------------------------------- 1 | package carves 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/jmpsec/osctrl/pkg/utils" 10 | ) 11 | 12 | const ( 13 | // S3proto to be used as s3 URL 14 | S3proto = "s3://" 15 | // S3URL to format the s3 URL 16 | S3URL = S3proto + "%s/%s" 17 | // S3Key to format the s3 key for a block 18 | S3Key = "%s:%s:%s:%d" 19 | // S3File to format the s3 key for a reconstructed file 20 | S3File = "%s:%s:%s:%s" + TarFileExtension 21 | // LocalFile to format the local file name 22 | LocalFile = "%s_%s_%s" + TarFileExtension 23 | ) 24 | 25 | // Function to generate a carve block filename for s3 26 | func GenerateS3Data(bucket, env, uuid, sessionid string, blockid int) string { 27 | return fmt.Sprintf(S3URL, bucket, GenerateS3Key(env, uuid, sessionid, blockid)) 28 | } 29 | 30 | // Function to generate a carve archived filename for s3 31 | func GenerateS3Archive(bucket, env, uuid, sessionid, path string) string { 32 | return fmt.Sprintf(S3URL, bucket, GenerateS3File(env, uuid, sessionid, path)) 33 | } 34 | 35 | // Function to generate the s3 key for a carve block 36 | func GenerateS3Key(env, uuid, sessionid string, blockid int) string { 37 | return fmt.Sprintf(S3Key, env, uuid, sessionid, blockid) 38 | } 39 | 40 | // Function to generate the s3 file reconstructed from blocks 41 | func GenerateS3File(env, uuid, sessionid, path string) string { 42 | return fmt.Sprintf(S3File, env, uuid, sessionid, path) 43 | } 44 | 45 | // Function to translate from a s3:// URL to just the key 46 | func S3URLtoKey(s3url, bucket string) string { 47 | return strings.TrimPrefix(s3url, fmt.Sprintf(S3proto+"%s/", bucket)) 48 | } 49 | 50 | // Function to generate a local file for carve archives 51 | func GenerateArchiveName(carve CarvedFile) string { 52 | cPath := strings.ReplaceAll(strings.ReplaceAll(carve.Path, "/", "-"), "\\", "-") 53 | return fmt.Sprintf(LocalFile, carve.UUID, carve.SessionID, cPath) 54 | } 55 | 56 | // Function to check if data is compressed using zstd 57 | // https://github.com/facebook/zstd 58 | func CheckCompressionRaw(data []byte) bool { 59 | return bytes.Equal(data[:4], CompressionHeader) 60 | } 61 | 62 | // Function to check if a block data is compressed using zstd 63 | // https://github.com/facebook/zstd 64 | func CheckCompressionBlock(block CarvedBlock) (bool, error) { 65 | // Make sure this is the block 0 66 | if block.BlockID != 0 { 67 | return false, fmt.Errorf("block_id is not 0 (%d)", block.BlockID) 68 | } 69 | compressionCheck, err := base64.StdEncoding.DecodeString(block.Data) 70 | if err != nil { 71 | return false, fmt.Errorf("error decoding block %w", err) 72 | } 73 | return CheckCompressionRaw(compressionCheck), nil 74 | } 75 | 76 | // Helper to generate a random carve name 77 | func GenCarveName() string { 78 | return "carve_" + utils.RandomForNames() 79 | } 80 | 81 | // Helper to generate the carve query 82 | func GenCarveQuery(file string, glob bool) string { 83 | if glob { 84 | return "SELECT * FROM carves WHERE carve=1 AND path LIKE '" + file + "';" 85 | } 86 | return "SELECT * FROM carves WHERE carve=1 AND path = '" + file + "';" 87 | } 88 | -------------------------------------------------------------------------------- /.github/actions/tagged_release/github/action.yml: -------------------------------------------------------------------------------- 1 | name: "Release binaries and packages" 2 | description: "Release binaries and packages" 3 | inputs: 4 | go_os: 5 | required: true 6 | description: Define the OS to compile binary for - https://pkg.go.dev/internal/goos 7 | go_arch: 8 | required: true 9 | description: Define the architecture to compile binary for - https://pkg.go.dev/internal/goarch 10 | commit_sha: 11 | required: true 12 | description: Define the SHA1 git commit hash 13 | release_version_tag: 14 | required: true 15 | description: Define the release version 16 | osctrl_component: 17 | required: true 18 | description: Define the osctrl component to compile 19 | 20 | runs: 21 | using: "composite" 22 | steps: 23 | ########################### Download osctrl binary ########################### 24 | - name: Download osctrl binaries 25 | uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 26 | with: 27 | name: osctrl-${{ inputs.osctrl_component }}-${{ inputs.commit_sha }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin 28 | 29 | - name: PWD 30 | shell: bash 31 | run: "pwd" 32 | 33 | - name: LS 34 | shell: bash 35 | run: "ls -la" 36 | 37 | - name: Copy linux or darwin binary 38 | if: ${{ inputs.go_os }} == 'linux' || ${{ inputs.go_os }} == 'darwin' 39 | shell: bash 40 | run: | 41 | cp \ 42 | osctrl-${{ inputs.osctrl_component }}-${{ inputs.commit_sha }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin \ 43 | osctrl-${{ inputs.osctrl_component }}-${{ inputs.release_version_tag }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin 44 | 45 | - name: Copy windows binary 46 | if: ${{ inputs.go_os }} == 'windows' 47 | shell: bash 48 | run: | 49 | cp \ 50 | osctrl-${{ inputs.osctrl_component }}-${{ inputs.commit_sha }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.bin \ 51 | osctrl-${{ inputs.osctrl_component }}-${{ inputs.release_version_tag }}-${{ inputs.go_os }}-${{ inputs.go_arch }}.exe 52 | 53 | ########################### Download osctrl DEB package ########################### 54 | - name: List available artifacts 55 | if: ${{ inputs.go_os }} == 'linux' 56 | run: | 57 | echo "Looking for DEB package: osctrl-${{ inputs.osctrl_component }}_${{ inputs.release_version_tag }}_${{ inputs.go_arch }}.deb" 58 | echo "Release version tag: ${{ inputs.release_version_tag }}" 59 | echo "Component: ${{ inputs.osctrl_component }}" 60 | echo "Arch: ${{ inputs.go_arch }}" 61 | shell: bash 62 | 63 | - name: Download osctrl DEB package 64 | if: ${{ inputs.go_os }} == 'linux' 65 | uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 66 | with: 67 | name: osctrl-${{ inputs.osctrl_component }}_${{ inputs.release_version_tag }}_${{ inputs.go_arch }}.deb 68 | 69 | ########################### Release ########################### 70 | - name: Release 71 | uses: softprops/action-gh-release@f82d31e53e61a962573dd0c5fcd6b446ca78871f # v2.3.2 72 | if: startsWith(github.ref, 'refs/tags/') 73 | with: 74 | files: | 75 | osctrl-*.bin 76 | osctrl-*.exe 77 | osctrl-*.deb 78 | --------------------------------------------------------------------------------