From fe9414ac9c1f8cb3fe45b991d14b3e33fc8496b1 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Fri, 14 Jul 2023 16:29:04 -0400 Subject: [PATCH 01/84] additional login option --- html/js/app.js | 2 +- html/js/i18n.js | 1 + html/js/routes/login.js | 7 ++++++- html/login/index.html | 12 ++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/html/js/app.js b/html/js/app.js index 6defed11d..209540bbc 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -709,7 +709,7 @@ $(document).ready(function() { const redirectCookie = this.getCookie('AUTH_REDIRECT'); if ((response.headers && response.headers['content-type'] == "text/html") || (response.status == 401) || - (redirectCookie != null && redirectCookie.length > 0)) { + (response.request.responseURL.indexOf("/login/") == -1 && redirectCookie != null && redirectCookie.length > 0)) { this.deleteCookie('AUTH_REDIRECT'); this.showLogin(); return null diff --git a/html/js/i18n.js b/html/js/i18n.js index 187189d88..60e6d9fe4 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -428,6 +428,7 @@ const i18n = { loginEnabled: 'Unlocked', loginExpired: 'The login session has expired. Refresh, or wait for the page to refresh automatically, and then try again.', loginInvalid: 'The provided credentials are invalid. Please try again.', + loginOidc: 'Continue with', loginTitle: 'Login to Security Onion', logout: 'Logout', logoutFailure: 'Unable to initiate logout. Ensure server is accessible.', diff --git a/html/js/routes/login.js b/html/js/routes/login.js index 7310eda06..e4bed47dd 100644 --- a/html/js/routes/login.js +++ b/html/js/routes/login.js @@ -27,6 +27,7 @@ routes.push({ path: '*', name: 'login', component: { script: null, email: null, }, + oidc: [], totpCodeLength: 6, rules: { required: value => !!value || this.$root.i18n.required, @@ -110,6 +111,7 @@ routes.push({ path: '*', name: 'login', component: { this.form.method = flow.data.ui.nodes.find(item => item.attributes && item.attributes.name == 'method' && item.attributes.value == 'password') ? 'password' : 'totp'; this.extractWebauthnData(flow); + this.extractOidcData(flow); this.$nextTick(function () { // Wait for next Vue tick to set focus, since at the time of this function call (or even mounted() hook), this element won't be // loaded, due to v-if's that have yet to process. @@ -165,6 +167,9 @@ routes.push({ path: '*', name: 'login', component: { }, runWebauthn() { eval(this.webauthnForm.onclick); - } + }, + extractOidcData(response) { + this.oidc = response.data.ui.nodes.filter(item => item.group == "oidc" && item.type == "input" ).map(item => item.attributes.value); + }, }, }}); diff --git a/html/login/index.html b/html/login/index.html index 18643c1a9..d8b538e4f 100644 --- a/html/login/index.html +++ b/html/login/index.html @@ -56,6 +56,18 @@ +
+ + + + + fa-brands fa-microsoft + fa-brands fa-google + fa-arrow-right-to-bracket + {{ i18n.loginOidc + ' ' + provider }} + + +
From 52e87562259a1c16f908f891d3b51a3268c06c0e Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Mon, 17 Jul 2023 11:01:34 -0400 Subject: [PATCH 02/84] improve test cleanup --- licensing/license_manager.go | 20 +++++----- licensing/license_manager_test.go | 62 ++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/licensing/license_manager.go b/licensing/license_manager.go index 215b1f397..dfc5d83c8 100644 --- a/licensing/license_manager.go +++ b/licensing/license_manager.go @@ -43,8 +43,6 @@ const FEAT_TIMETRACKING = "timetracking" const FEAT_FIPS = "fips" const FEAT_STIG = "stig" -const DEFAULT_PILLAR_FILENAME = "/opt/so/saltstack/local/pillar/soc/license.sls" - const PUBLIC_KEY = ` -----BEGIN PUBLIC KEY----- MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA4w/cDz7rv6QotLWR7mn9 @@ -59,6 +57,9 @@ rdA93ynlX+ihg6jL0iS4uFEV9YveqajjOyi3DYyUFCjFAgMBAAE= -----END PUBLIC KEY----- ` +var runMode = true +var pillarFilename = "/opt/so/saltstack/local/pillar/soc/license.sls" + type licenseManager struct { status string available []string @@ -68,7 +69,6 @@ type licenseManager struct { pillarTimer *time.Timer running bool licenseKey *LicenseKey - pillarFilename string } type LicenseKey struct { @@ -94,9 +94,8 @@ var manager *licenseManager func newLicenseManager() *licenseManager { return &licenseManager{ - available: make([]string, 0, 0), - limits: make(map[string]bool), - pillarFilename: DEFAULT_PILLAR_FILENAME, + available: make([]string, 0, 0), + limits: make(map[string]bool), } } @@ -194,6 +193,7 @@ func Init(key string) { } func Test(feat string, users int, nodes int, socUrl string, dataUrl string) { + runMode = false available := make([]string, 0, 0) available = append(available, feat) licenseKey := &LicenseKey{} @@ -223,13 +223,13 @@ func createManager(status string, available []string, licenseKey *LicenseKey, st manager.status = status manager.available = available manager.licenseKey = licenseKey + manager.running = runMode if (status == LICENSE_STATUS_ACTIVE || status == LICENSE_STATUS_PENDING) && startMonitors { - manager.running = true go startExpirationMonitor() go startEffectiveMonitor() - go startPillarMonitor() } + go startPillarMonitor() log.WithFields(log.Fields{ "status": manager.status, @@ -322,9 +322,9 @@ func startPillarMonitor() { } else { contents += "features: []\n" } - err := os.WriteFile(manager.pillarFilename, []byte(contents), 0644) + err := os.WriteFile(pillarFilename, []byte(contents), 0644) if err != nil { - log.WithError(err).WithField("filename", manager.pillarFilename).Error("Failed to update features") + log.WithError(err).WithField("filename", pillarFilename).Error("Failed to update features") manager.status = LICENSE_STATUS_INVALID } diff --git a/licensing/license_manager_test.go b/licensing/license_manager_test.go index 4c7cc3b6b..f72b0a4df 100644 --- a/licensing/license_manager_test.go +++ b/licensing/license_manager_test.go @@ -23,7 +23,22 @@ import ( const EXPIRED_KEY = ` H4sIAIvZnGMAA22QR4+bUBSF9/kVFlvPBExz2ZlqijHVNo6yeMaPYsOjPWMgyn8PMyNFihTpLm75zjnS/UXAOIYRzjpIbAiaohfv1Ef5FLX5rAvxRsC+yhqAsxJ9MfR/GASKDwcftnhmZhFELZy9z6yDP1MO7izw5JlmzWz3IAWirx2sSZHdJj4GD/hOUYtpzr9UHy7KtP3rYsBhusYQ4GcDW2Lz4+cb8WxhM7WLKbe8wa+uLaOgySd1inHVbkiyLQv4SmEDv2eoA/mU90bcAAb/UgCVeIK+VzmI4ES0WYI+oyYm8VBxAEZUFbKlp2ugz6t9n15kiX0uligc+es1jf3A7HldUAoctnhtxZ6f7R3+Rc5tOdqqcM5J/F0UyZJh4raOdcNTa6EEyoq+CXR0PloieilIUFlTgwa748r0chJZj2TdmAq3owZi3iFFk4vzx9mUGV41U1N3yLA/GEnCN+tdLa3uAR1zwn6MEh+EmDxefX9VulSjqi6F08hfcQLfYGNXnWxMFpwkKY3h6bJp8bs2z/zRfvCZEu7z3VDh8HRzoOMPa/51S8ho6LrBw7KZgu3qkq7KVGeHu+1Q9LZXkzJDJwaLnnwKpJAbnfkQstcyAlq0ho++q5YLDw11nES0xj+3NmfgvLhYC7rXKFGO927oPHg7oql6MNvDqxMWYxPuBkg/K+Uum/bxnGT90mwjrvZD4+yJmly1Llo+rsGZdZugo307jKf/FbdR950Vr1BHnRrlZMwsACQml/XKq8J5iV5HxgF7sub6Xueyvk7Gfo2Km1AuVZYsWNOv0FEtB4H1WJW/ayfh1S0ZZiB+f/sDb9bxLiEDAAA= ` +func teardown() { + if manager != nil { + manager.running = false + } + os.Remove(pillarFilename) +} + +func setup() func() { + pillarFilename = "/tmp/soc_test_pillar_monitor.sls" + runMode = false + return teardown +} + func TestInit_Missing(tester *testing.T) { + defer setup()() + // None Init("") assert.Equal(tester, LICENSE_STATUS_UNPROVISIONED, GetStatus()) @@ -42,6 +57,8 @@ func TestInit_Missing(tester *testing.T) { } func TestExpirationMonitor(tester *testing.T) { + defer setup()() + Init("") assert.Equal(tester, LICENSE_STATUS_UNPROVISIONED, GetStatus()) manager.licenseKey.Expiration = time.Now().Add(time.Second * 1) @@ -50,6 +67,8 @@ func TestExpirationMonitor(tester *testing.T) { } func TestEffectiveMonitor_Unprovisioned(tester *testing.T) { + defer setup()() + Init("") assert.Equal(tester, LICENSE_STATUS_UNPROVISIONED, GetStatus()) manager.licenseKey.Effective = time.Now().Add(time.Second * 1) @@ -58,6 +77,8 @@ func TestEffectiveMonitor_Unprovisioned(tester *testing.T) { } func TestEffectiveMonitor_Pending(tester *testing.T) { + defer setup()() + Init("") manager.status = LICENSE_STATUS_PENDING manager.licenseKey.Effective = time.Now().Add(time.Second * 1) @@ -66,6 +87,8 @@ func TestEffectiveMonitor_Pending(tester *testing.T) { } func TestEffectiveMonitor_Exceeded(tester *testing.T) { + defer setup()() + Init("") manager.status = LICENSE_STATUS_PENDING manager.limits["foo"] = true @@ -75,6 +98,8 @@ func TestEffectiveMonitor_Exceeded(tester *testing.T) { } func TestIsEnabled(tester *testing.T) { + defer setup()() + Init("") assert.False(tester, IsEnabled("something")) @@ -88,6 +113,8 @@ func TestIsEnabled(tester *testing.T) { } func TestListAvailableFeatures(tester *testing.T) { + defer setup()() + Init("") assert.Len(tester, ListAvailableFeatures(), 0) @@ -100,6 +127,8 @@ func TestListAvailableFeatures(tester *testing.T) { } func TestListEnabledFeatures(tester *testing.T) { + defer setup()() + Init("") assert.Len(tester, ListEnabledFeatures(), 3) @@ -118,6 +147,8 @@ func TestListEnabledFeatures(tester *testing.T) { } func TestGetLicenseKey(tester *testing.T) { + defer setup()() + Init(EXPIRED_KEY) key := GetLicenseKey() assert.Equal(tester, key.Users, 1) @@ -135,6 +166,8 @@ func TestGetLicenseKey(tester *testing.T) { } func TestGetStatus(tester *testing.T) { + defer setup()() + Init("") assert.Equal(tester, LICENSE_STATUS_UNPROVISIONED, GetStatus()) @@ -143,6 +176,8 @@ func TestGetStatus(tester *testing.T) { } func TestGetId(tester *testing.T) { + defer setup()() + Init("") assert.Equal(tester, "", GetId()) @@ -151,6 +186,8 @@ func TestGetId(tester *testing.T) { } func TestGetLicensee(tester *testing.T) { + defer setup()() + Init("") assert.Equal(tester, "", GetLicensee()) @@ -159,6 +196,8 @@ func TestGetLicensee(tester *testing.T) { } func TestGetExpiration(tester *testing.T) { + defer setup()() + Init("") assert.Equal(tester, time.Time{}, GetExpiration()) @@ -168,6 +207,8 @@ func TestGetExpiration(tester *testing.T) { } func TestGetName(tester *testing.T) { + defer setup()() + Init("") assert.Equal(tester, "", GetName()) @@ -176,6 +217,8 @@ func TestGetName(tester *testing.T) { } func TestValidateUserCount(tester *testing.T) { + defer setup()() + Init("") manager.licenseKey.Users = 2 assert.True(tester, ValidateUserCount(0)) @@ -185,6 +228,8 @@ func TestValidateUserCount(tester *testing.T) { } func TestValidateNodeCount(tester *testing.T) { + defer setup()() + Init("") manager.licenseKey.Nodes = 2 assert.True(tester, ValidateNodeCount(0)) @@ -194,6 +239,8 @@ func TestValidateNodeCount(tester *testing.T) { } func TestValidateSocUrl(tester *testing.T) { + defer setup()() + Init("") manager.licenseKey.SocUrl = "foo" assert.True(tester, ValidateSocUrl("Foo")) @@ -203,6 +250,8 @@ func TestValidateSocUrl(tester *testing.T) { } func TestValidateDataUrl(tester *testing.T) { + defer setup()() + Init("") manager.licenseKey.DataUrl = "foo" assert.True(tester, ValidateDataUrl("Foo")) @@ -212,14 +261,13 @@ func TestValidateDataUrl(tester *testing.T) { } func TestPillarMonitor(tester *testing.T) { - Test("stig", 0, 0, "", "") + defer setup()() - manager.pillarFilename = "/tmp/soc_test_pillar_monitor.sls" + Test("stig", 0, 0, "", "") - os.Remove(manager.pillarFilename) startPillarMonitor() assert.Equal(tester, manager.status, LICENSE_STATUS_ACTIVE) - contents, _ := os.ReadFile(manager.pillarFilename) + contents, _ := os.ReadFile(pillarFilename) expected := ` # Copyright Jason Ertel (github.com/jertel). @@ -243,9 +291,11 @@ features: } func TestPillarMonitor_Fail(tester *testing.T) { - Init("") + defer setup()() + + pillarFilename = "/tmp/does/not/exist" - manager.pillarFilename = "/tmp/does/not/exist" + Init("") assert.Equal(tester, manager.status, LICENSE_STATUS_UNPROVISIONED) startPillarMonitor() From 73b64e74c393987e094379805a3c52bea4e24f4e Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 18 Jul 2023 16:06:03 -0400 Subject: [PATCH 03/84] improve tests --- licensing/license_manager.go | 27 ++++++++++++++++++++------- licensing/license_manager_test.go | 14 ++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/licensing/license_manager.go b/licensing/license_manager.go index dfc5d83c8..1fed59576 100644 --- a/licensing/license_manager.go +++ b/licensing/license_manager.go @@ -57,8 +57,10 @@ rdA93ynlX+ihg6jL0iS4uFEV9YveqajjOyi3DYyUFCjFAgMBAAE= -----END PUBLIC KEY----- ` -var runMode = true -var pillarFilename = "/opt/so/saltstack/local/pillar/soc/license.sls" +const LICENSE_PILLAR_FILENAME = "/opt/so/saltstack/local/pillar/soc/license.sls" + +var pillarFilename = LICENSE_PILLAR_FILENAME +var pillarMonitorCount = 0 type licenseManager struct { status string @@ -67,7 +69,6 @@ type licenseManager struct { expirationTimer *time.Timer effectiveTimer *time.Timer pillarTimer *time.Timer - running bool licenseKey *LicenseKey } @@ -193,7 +194,7 @@ func Init(key string) { } func Test(feat string, users int, nodes int, socUrl string, dataUrl string) { - runMode = false + pillarFilename = "/tmp/soc_test_pillar_monitor.sls" available := make([]string, 0, 0) available = append(available, feat) licenseKey := &LicenseKey{} @@ -223,12 +224,12 @@ func createManager(status string, available []string, licenseKey *LicenseKey, st manager.status = status manager.available = available manager.licenseKey = licenseKey - manager.running = runMode if (status == LICENSE_STATUS_ACTIVE || status == LICENSE_STATUS_PENDING) && startMonitors { go startExpirationMonitor() go startEffectiveMonitor() } + pillarMonitorCount = 0 go startPillarMonitor() log.WithFields(log.Fields{ @@ -327,19 +328,30 @@ func startPillarMonitor() { log.WithError(err).WithField("filename", pillarFilename).Error("Failed to update features") manager.status = LICENSE_STATUS_INVALID } + pillarMonitorCount = pillarMonitorCount + 1 - if manager.running { + if Usable() { duration := time.Duration(rand.Intn(3600000)+1) * time.Millisecond manager.pillarTimer = time.NewTimer(duration) <-manager.pillarTimer.C go startPillarMonitor() + } else { + log.WithField("pillarFilename", pillarFilename).Info("Exiting pillar monitor") + go func() { + // Leave enough time for rest of unit tests to finish + time.Sleep(5 * time.Minute) + stopMonitor() + }() } } +func Usable() bool { + return pillarFilename == LICENSE_PILLAR_FILENAME +} + func stopMonitor() { if manager != nil { - manager.running = false if manager.expirationTimer != nil { manager.expirationTimer.Stop() } @@ -347,6 +359,7 @@ func stopMonitor() { manager.effectiveTimer.Stop() } if manager.pillarTimer != nil { + pillarFilename = "" manager.pillarTimer.Stop() } diff --git a/licensing/license_manager_test.go b/licensing/license_manager_test.go index f72b0a4df..fd5f305b3 100644 --- a/licensing/license_manager_test.go +++ b/licensing/license_manager_test.go @@ -24,15 +24,11 @@ import ( const EXPIRED_KEY = ` H4sIAIvZnGMAA22QR4+bUBSF9/kVFlvPBExz2ZlqijHVNo6yeMaPYsOjPWMgyn8PMyNFihTpLm75zjnS/UXAOIYRzjpIbAiaohfv1Ef5FLX5rAvxRsC+yhqAsxJ9MfR/GASKDwcftnhmZhFELZy9z6yDP1MO7izw5JlmzWz3IAWirx2sSZHdJj4GD/hOUYtpzr9UHy7KtP3rYsBhusYQ4GcDW2Lz4+cb8WxhM7WLKbe8wa+uLaOgySd1inHVbkiyLQv4SmEDv2eoA/mU90bcAAb/UgCVeIK+VzmI4ES0WYI+oyYm8VBxAEZUFbKlp2ugz6t9n15kiX0uligc+es1jf3A7HldUAoctnhtxZ6f7R3+Rc5tOdqqcM5J/F0UyZJh4raOdcNTa6EEyoq+CXR0PloieilIUFlTgwa748r0chJZj2TdmAq3owZi3iFFk4vzx9mUGV41U1N3yLA/GEnCN+tdLa3uAR1zwn6MEh+EmDxefX9VulSjqi6F08hfcQLfYGNXnWxMFpwkKY3h6bJp8bs2z/zRfvCZEu7z3VDh8HRzoOMPa/51S8ho6LrBw7KZgu3qkq7KVGeHu+1Q9LZXkzJDJwaLnnwKpJAbnfkQstcyAlq0ho++q5YLDw11nES0xj+3NmfgvLhYC7rXKFGO927oPHg7oql6MNvDqxMWYxPuBkg/K+Uum/bxnGT90mwjrvZD4+yJmly1Llo+rsGZdZugo307jKf/FbdR950Vr1BHnRrlZMwsACQml/XKq8J5iV5HxgF7sub6Xueyvk7Gfo2Km1AuVZYsWNOv0FEtB4H1WJW/ayfh1S0ZZiB+f/sDb9bxLiEDAAA= ` func teardown() { - if manager != nil { - manager.running = false - } os.Remove(pillarFilename) } func setup() func() { pillarFilename = "/tmp/soc_test_pillar_monitor.sls" - runMode = false return teardown } @@ -260,12 +256,18 @@ func TestValidateDataUrl(tester *testing.T) { assert.False(tester, ValidateDataUrl("bar")) } +func awaitPillarMonitor() { + for pillarMonitorCount == 0 { + time.Sleep(50 * time.Millisecond) + } +} + func TestPillarMonitor(tester *testing.T) { defer setup()() Test("stig", 0, 0, "", "") - startPillarMonitor() + awaitPillarMonitor() assert.Equal(tester, manager.status, LICENSE_STATUS_ACTIVE) contents, _ := os.ReadFile(pillarFilename) @@ -298,6 +300,6 @@ func TestPillarMonitor_Fail(tester *testing.T) { Init("") assert.Equal(tester, manager.status, LICENSE_STATUS_UNPROVISIONED) - startPillarMonitor() + awaitPillarMonitor() assert.Equal(tester, manager.status, LICENSE_STATUS_INVALID) } From 02b639e47d236b5c147c3610b1031d0e030ee366 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Tue, 18 Jul 2023 14:58:13 -0600 Subject: [PATCH 04/84] Resolving hostnames in Hunt page Lookup hostnames for anything that looks like an ip (v4 or v6). The lookups are cached in the JS until they reload the page. The cache should survive from page to page as long as the $root component stays loaded. Collects groups of IPs to look them up in batches. The Group Metrics table resolves, the Events table resolves, and the Events table's details section showing all the fields will resolve. Hunt, dashboards, and alerts will now resolve. Introduces a new Config value: Dns. Should contain the IP and port of a local DNS instance. Leaving the option blank tells Go to fall back to "defaults" (i.e. /etc/resolv.conf). Leaving off a port defaults to :53. This server is used during resolution. To do: Cases, PCAP --- .gitignore | 2 +- config/serverconfig.go | 1 + html/index.html | 6 +-- html/js/app.js | 52 ++++++++++++++++++++++ html/js/app.test.js | 67 ++++++++++++++++++++++++++++- html/js/routes/hunt.js | 12 ++++++ server/server.go | 1 + server/utilhandler.go | 97 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 server/utilhandler.go diff --git a/.gitignore b/.gitignore index 3a848029b..07cacdab1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ __pycache__ .coverage *.pyc -__debug_bin \ No newline at end of file +__debug_bin* \ No newline at end of file diff --git a/config/serverconfig.go b/config/serverconfig.go index 5272c94ed..c1b06b230 100644 --- a/config/serverconfig.go +++ b/config/serverconfig.go @@ -27,6 +27,7 @@ type ServerConfig struct { BindAddress string `json:"bindAddress"` BaseUrl string `json:"baseUrl"` DeveloperEnabled bool `json:"developerEnabled"` + Dns string `json:"dns"` HtmlDir string `json:"htmlDir"` ImportUploadDir string `json:"importUploadDir"` MaxPacketCount int `json:"maxPacketCount"` diff --git a/html/index.html b/html/index.html index dd89a17a0..b31431979 100644 --- a/html/index.html +++ b/html/index.html @@ -584,7 +584,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

-
+
{{ item[field.value] }} - {{ $root.pickHostname(item[field.value]) }}
@@ -653,7 +653,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

- + {{ item[field.value] }}
- {{ $root.pickHostname(item[field.value]) }}
@@ -674,7 +674,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

-
+
{{item.value}} - {{ $root.pickHostname(item.value) }}
diff --git a/html/js/app.js b/html/js/app.js index 6defed11d..ef096baa7 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -101,6 +101,7 @@ $(document).ready(function() { maximizedCancelFn: null, licenseKey: null, licenseStatus: null, + ip2host: {}, }, watch: { '$vuetify.theme.dark': 'saveLocalSettings', @@ -938,6 +939,57 @@ $(document).ready(function() { } event.cancel(); }, + batchLookup(ips, comp) { + ips = ips.filter(ip => (this.isIPv4(ip) || this.isIPv6(ip)) && !this.ip2host[ip]); + if (ips.length) { + ips.forEach(ip => this.ip2host[ip] = []); + const route = this; + this.papi.put('util/reverse-lookup', ips).then(response => { + for (let entry in response.data) { + let existing = this.ip2host[entry]; + if (!existing) { + existing = []; + } + + if (response.data && response.data[entry] && response.data[entry].length) { + let arr = this.ip2host[entry]; + if (!arr || !arr.length) { + arr = []; + } + + arr.push(...response.data[entry]); + this.ip2host[entry] = arr; + } + } + comp.$forceUpdate(); + }); + } + }, + isIPv4(str) { + if (typeof str === 'string') { + return !!str.match(/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/); + } + + return false; + }, + isIPv6(str) { + if (typeof str === 'string') { + return !!str.match(/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/i); + } + + return false; + }, + pickHostname(ip) { + const arr = this.ip2host[ip]; + if (arr && arr.length) { + const names = this.ip2host[ip].filter(host => host != ip); + if (names.length) { + return names[0]; + } + } + + return ''; + }, }, created() { this.log("Initializing application components"); diff --git a/html/js/app.test.js b/html/js/app.test.js index 8dbae94f4..ec29ccab0 100644 --- a/html/js/app.test.js +++ b/html/js/app.test.js @@ -246,7 +246,7 @@ test('setFavicon', () => { png_icon.href = "https://somehost.com/so.png"; var mock = jest.fn(); - mock.mockImplementation((path) => { + mock.mockImplementation((path) => { if (path.indexOf("png") != -1) { return png_icon; } @@ -343,4 +343,67 @@ test('colorLicenseStatus', () => { expect(app.colorLicenseStatus("expired")).toBe('warning'); expect(app.colorLicenseStatus("invalid")).toBe('error'); expect(app.colorLicenseStatus("pending")).toBe('warning'); -}); \ No newline at end of file +}); + +test('isIPv4', () => { + expect(app.isIPv4('')).toBe(false); + expect(app.isIPv4(null)).toBe(false); + expect(app.isIPv4('foo')).toBe(false); + expect(app.isIPv4(10)).toBe(false); + expect(app.isIPv4('1.2.3')).toBe(false); + expect(app.isIPv4('1.2.3.4.5')).toBe(false); + expect(app.isIPv4('256.256.256.256')).toBe(false); + expect(app.isIPv4('¹.¹.¹.¹')).toBe(false); + expect(app.isIPv4('١.١.١.١')).toBe(false); + expect(app.isIPv4('𝟣.𝟣.𝟣.𝟣')).toBe(false); // punycode + expect(app.isIPv4('①.①.①.①')).toBe(false); + expect(app.isIPv4('1:2:3:4:5:6:7:8')).toBe(false); + expect(app.isIPv4('1::')).toBe(false); + + expect(app.isIPv4('0.0.0.0')).toBe(true); + expect(app.isIPv4('127.0.0.1')).toBe(true); + expect(app.isIPv4('255.255.255.255')).toBe(true); +}); + +test('isIPv6', () => { + expect(app.isIPv6('')).toBe(false); + expect(app.isIPv6(null)).toBe(false); + expect(app.isIPv6('foo')).toBe(false); + expect(app.isIPv6(10)).toBe(false); + expect(app.isIPv6('1.2.3')).toBe(false); + expect(app.isIPv6('1.2.3.4.5')).toBe(false); + expect(app.isIPv6('256.256.256.256')).toBe(false); + expect(app.isIPv6('¹.¹.¹.¹')).toBe(false); + expect(app.isIPv6('١.١.١.١')).toBe(false); + expect(app.isIPv6('𝟣.𝟣.𝟣.𝟣')).toBe(false); // punycode + expect(app.isIPv6('①.①.①.①')).toBe(false); + expect(app.isIPv6('0.0.0.0')).toBe(false); + expect(app.isIPv6('127.0.0.1')).toBe(false); + expect(app.isIPv6('255.255.255.255')).toBe(false); + + expect(app.isIPv6('1:2:3:4:5:6:7:8')).toBe(true); + expect(app.isIPv6('1::')).toBe(true); + expect(app.isIPv6('1:2:3:4:5:6:7::')).toBe(true); + expect(app.isIPv6('1::8')).toBe(true); + expect(app.isIPv6('1:2:3:4:5:6::8')).toBe(true); + expect(app.isIPv6('1:2:3:4:5:6::8')).toBe(true); + expect(app.isIPv6('1::7:8')).toBe(true); + expect(app.isIPv6('1:2:3:4:5::7:8')).toBe(true); + expect(app.isIPv6('1:2:3:4:5::8')).toBe(true); + expect(app.isIPv6('1::6:7:8')).toBe(true); + expect(app.isIPv6('1:2:3:4::6:7:8')).toBe(true); + expect(app.isIPv6('1:2:3:4::8')).toBe(true); + expect(app.isIPv6('1::5:6:7:8')).toBe(true); + expect(app.isIPv6('1:2:3::5:6:7:8')).toBe(true); + expect(app.isIPv6('1:2:3::8')).toBe(true); + expect(app.isIPv6('1::4:5:6:7:8')).toBe(true); + expect(app.isIPv6('1:2::4:5:6:7:8')).toBe(true); + expect(app.isIPv6('1:2::8')).toBe(true); + expect(app.isIPv6('1::3:4:5:6:7:8')).toBe(true); + expect(app.isIPv6('1::3:4:5:6:7:8')).toBe(true); + expect(app.isIPv6('1::8')).toBe(true); + expect(app.isIPv6('::2:3:4:5:6:7:8')).toBe(true); + expect(app.isIPv6('::2:3:4:5:6:7:8')).toBe(true); + expect(app.isIPv6('::8')).toBe(true); + expect(app.isIPv6('::')).toBe(true); +}); diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index 0681caeb5..47a5c2017 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -1076,16 +1076,19 @@ const huntComponent = { constructGroupByRows(fields, data) { const records = []; const route = this; + let batch = []; data.forEach(function(row, index) { var record = { count: row.value, }; fields.forEach(function(field, index) { record[field] = route.localizeValue(row.keys[index]); + batch.push(record[field]); }); route.lookupSocIds(record); records.push(record); }); + this.$root.batchLookup(batch, this); return records; }, constructChartMetrics(data) { @@ -1197,6 +1200,8 @@ const huntComponent = { var eventDataset; var route = this; if (events != null && events.length > 0) { + let batch = []; + events.forEach(function(event, index) { var record = event.payload; record.soc_id = event.id; @@ -1207,6 +1212,10 @@ const huntComponent = { route.lookupSocIds(record); records.push(record); + for (const key in record) { + batch.push(record[key]); + } + var currentModule = record["event.module"]; var currentDataset = record["event.dataset"]; if (eventModule == null && currentModule) { @@ -1219,6 +1228,9 @@ const huntComponent = { inconsistentEvents = true; } }); + + route.$root.batchLookup(batch, route); + for (const key in records[0]) { fields.push(key); } diff --git a/server/server.go b/server/server.go index 2fc93bc26..53073b633 100644 --- a/server/server.go +++ b/server/server.go @@ -87,6 +87,7 @@ func (server *Server) Start() { RegisterConfigRoutes(server, r, "/api/config") RegisterGridMemberRoutes(server, r, "/api/gridmembers") RegisterRolesRoutes(server, r, "/api/roles") + RegisterUtilRoutes(server, r, "/api/util") server.Host.RegisterRouter("/api/", r) diff --git a/server/utilhandler.go b/server/utilhandler.go new file mode 100644 index 000000000..de3d47d93 --- /dev/null +++ b/server/utilhandler.go @@ -0,0 +1,97 @@ +package server + +import ( + "context" + "encoding/json" + "net" + "net/http" + "sync" + "time" + + "github.com/go-chi/chi" + lop "github.com/samber/lo/parallel" + "github.com/security-onion-solutions/securityonion-soc/web" +) + +type UtilHandler struct { + server *Server +} + +func RegisterUtilRoutes(srv *Server, r chi.Router, prefix string) { + h := &UtilHandler{ + server: srv, + } + + r.Route(prefix, func(r chi.Router) { + r.Put("/reverse-lookup", h.getReverseLookup) + }) +} + +func (h *UtilHandler) getReverseLookup(w http.ResponseWriter, r *http.Request) { + var body []string + results := map[string][]string{} + + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + web.Respond(w, r, http.StatusBadRequest, err) + return + } + + dedup := map[string]struct{}{} + for _, ip := range body { + dedup[ip] = struct{}{} + } + + ips := make([]string, 0, len(dedup)) + for ip := range dedup { + ips = append(ips, ip) + } + + var resolver *net.Resolver + + if h.server.Config.Dns != "" { + dnsServer := h.server.Config.Dns + + _, _, err = net.SplitHostPort(dnsServer) + if err != nil && err.Error() == "missing port in address" { + dnsServer = net.JoinHostPort(dnsServer, "53") + err = nil + } + + if err == nil { + resolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{ + Timeout: time.Millisecond * time.Duration(3000), + } + return d.DialContext(ctx, network, dnsServer) + }, + } + } + } + + if resolver == nil { + resolver = net.DefaultResolver + } + + mapLock := sync.Mutex{} + lop.ForEach(ips, func(ip string, _ int) { + addrs, _ := resolver.LookupAddr(context.Background(), ip) + if addrs == nil { + addrs = []string{} + } + + mapLock.Lock() + results[ip] = addrs + mapLock.Unlock() + }) + + for k, v := range results { + if len(v) == 0 { + results[k] = []string{k} + } + } + + web.Respond(w, r, http.StatusOK, results) +} From 70d3c5b69bd63bbfba00c408f3d09161114be36a Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Wed, 19 Jul 2023 16:11:44 -0600 Subject: [PATCH 05/84] More pages, style Alerts and PCAP added. Added a class to each of the new spans for themeing, including light mode support. The lookup endpoint now logs errors even if it doesn't respond to them. Log all except one error: unknown service. The endpoint has been temporarily rewritten to make debugging easier. TODO: Remove this before merging. --- html/css/app.css | 8 +++++ html/index.html | 22 ++++++------ html/js/routes/case.js | 7 ++++ html/js/routes/job.js | 8 +++++ server/utilhandler.go | 82 ++++++++++++++++++++++++------------------ 5 files changed, 82 insertions(+), 45 deletions(-) diff --git a/html/css/app.css b/html/css/app.css index feac3c8a4..16610762d 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -477,3 +477,11 @@ td { .inline { display: inline-block; } + +.resolved-host { + color: rgba(255, 255, 255, 0.5); +} + +.theme--light .resolved-host { + color: rgba(0, 0, 0, 0.5); +} diff --git a/html/index.html b/html/index.html index b31431979..dbaf8e9d4 100644 --- a/html/index.html +++ b/html/index.html @@ -584,7 +584,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

-
{{ item[field.value] }} - {{ $root.pickHostname(item[field.value]) }}
+
{{ item[field.value] }} - {{ $root.pickHostname(item[field.value]) }}
@@ -653,7 +653,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

- {{ item[field.value] }}
- {{ $root.pickHostname(item[field.value]) }}
+ {{ item[field.value] }}
- {{ $root.pickHostname(item[field.value]) }}
@@ -674,7 +674,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

-
{{item.value}} - {{ $root.pickHostname(item.value) }}
+
{{item.value}} - {{ $root.pickHostname(item.value) }}
@@ -1343,13 +1343,13 @@

{{ i18n.viewJob }}

fa-file-export - {{ job.filter.srcIp }}:{{ job.filter.srcPort }} + {{ job.filter.srcIp }}:{{ job.filter.srcPort }} - {{ $root.pickHostname(job.filter.srcIp) }} fa-file-import - {{ job.filter.dstIp }}:{{ job.filter.dstPort }} + {{ job.filter.dstIp }}:{{ job.filter.dstPort }} - {{ $root.pickHostname(job.filter.dstIp) }} @@ -1374,7 +1374,7 @@

{{ i18n.viewJob }}

{{ i18n.srcIp }}: - {{ job.filter.srcIp }} + {{ job.filter.srcIp }} - {{ $root.pickHostname(job.filter.srcIp) }}
{{ i18n.srcPort }}: @@ -1382,7 +1382,7 @@

{{ i18n.viewJob }}

{{ i18n.dstIp }}: - {{ job.filter.dstIp }} + {{ job.filter.dstIp }} - {{ $root.pickHostname(job.filter.dstIp) }}
{{ i18n.dstPort }}: @@ -1462,9 +1462,9 @@

{{ i18n.viewJob }}

{{ props.item.timestamp | formatTimestamp }} {{ props.item.type }} - {{ props.item.srcIp }} + {{ props.item.srcIp }} - {{ $root.pickHostname(props.item.srcIp) }} {{ props.item.srcPort }} - {{ props.item.dstIp }} + {{ props.item.dstIp }} - {{ $root.pickHostname(props.item.dstIp) }} {{ props.item.dstPort }} {{ flag }} {{ props.item.length }} @@ -2077,7 +2077,7 @@

{{ i18n.evidenceAdd }}

{{ props.item.createTime | formatDateTime }} {{ props.item.updateTime | formatDateTime }} {{ props.item.artifactType }} - {{ props.item.value }} + {{ props.item.value }}
- {{ $root.pickHostname(props.item.value) }}
diff --git a/html/js/app.js b/html/js/app.js index 9ce46557c..2a4ded818 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -874,6 +874,9 @@ $(document).ready(function() { isUserAdmin(user = null) { return this.userHasRole("superuser", user); }, + isMyUser(user) { + return user != null && this.user != null && user.id == this.user.id; + }, userHasRole(role, user = null) { if (!user) { user = this.user; diff --git a/html/js/app.test.js b/html/js/app.test.js index ec29ccab0..dcfd54a60 100644 --- a/html/js/app.test.js +++ b/html/js/app.test.js @@ -113,6 +113,16 @@ test('isUserAdmin', async () => { expect(app.isUserAdmin()).toBe(true); }); +test('isMyUser', () => { + app.user = null; + expect(app.isMyUser()).toBe(false); + var user = {id:'123',email:'hi@there.net',roles:['nope', 'peon']}; + expect(app.isMyUser(user)).toBe(false); + app.user = user; + expect(app.isMyUser(user)).toBe(true); + expect(app.isMyUser()).toBe(false); +}); + test('loadServerSettings', async () => { const fakeInfo = { srvToken: 'xyz', From 6de92c927414119bdbde333edab007ad9d4f5477 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Wed, 26 Jul 2023 18:58:29 -0400 Subject: [PATCH 13/84] oidc --- html/index.html | 35 +++++++++++++++----- html/js/i18n.js | 8 +++++ html/js/routes/login.js | 19 ++++++++--- html/js/routes/login.test.js | 42 +++++++++++++++++++++++- html/js/routes/settings.js | 16 +++++++++ html/js/routes/settings.test.js | 32 +++++++++++++++++- html/login/index.html | 30 ++++++++++++----- model/user.go | 2 ++ server/modules/kratos/kratosuser.go | 10 ++++++ server/modules/kratos/kratosuser_test.go | 4 +++ 10 files changed, 175 insertions(+), 23 deletions(-) diff --git a/html/index.html b/html/index.html index 96afa467a..19a4162b8 100644 --- a/html/index.html +++ b/html/index.html @@ -1187,10 +1187,11 @@

{{ i18n.usersEnabled }} {{ countUsersEnabled() }} / {{
- fa-user-shield - fa-user + fa-link + fa-user-shield + fa-fingerprint fa-user-slash - fa-exclamation-triangle + fa-exclamation-triangle
@@ -1229,6 +1230,10 @@

{{ i18n.usersEnabled }} {{ countUsersEnabled() }} / {{ @click:append="showPassword = !showPassword" :append-icon="showPassword ? 'fa-eye-slash' : 'fa-eye'" :rules="[rules.required, rules.maxpasslen, rules.minpasslen, rules.badpasschs]"> + + {{ i18n.oidcResetPasswordHelp }} + + @@ -3237,7 +3242,21 @@

{{ i18n.securityInstructions }} - + + {{ i18n.oidc }} + + + + +
+ + +
+
+
+
+ + {{ i18n.password }} @@ -3251,7 +3270,7 @@

- + {{ i18n.securityInstructionsTotp }} @@ -3273,7 +3292,7 @@

- + {{ i18n.totp }} @@ -3285,7 +3304,7 @@

- + {{ i18n.webauthn }} @@ -3297,7 +3316,7 @@

-
+
diff --git a/html/js/i18n.js b/html/js/i18n.js index 60e6d9fe4..73ee09a3f 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -466,6 +466,12 @@ const i18n = { notFound: 'The selected item no longer exists', number: 'Num', numericOps: 'Numeric Ops', + oidc: 'Open ID Connect (OIDC)', + oidcLinked: 'OIDC Linked', + oidcInstructions: 'Users can link to or unlink from the OIDC providers listed below. Be aware that unlinking from all OIDC providers without having a local password set may result in being unable to access this user account. If prompted to login again to verify your identity, choose a login method which is already verified. For example, if you are linking to a new OIDC provider, you cannot use that OIDC provider to confirm your identity.', + oidcLink: 'Link with ', + oidcResetPasswordHelp: 'Resetting this user password will also unlink them from all configured OIDC providers. Consider resetting their password in the provider administrator interface instead.', + oidcUnlink: 'Unlink from ', ok: 'OK', offline: 'Offline', online: 'Online', @@ -656,6 +662,7 @@ const i18n = { toolTheHiveHelp: 'Case Management', totp: 'Time-based One-Time Password (TOTP)', totpActivate: 'Activate TOTP', + totpActive: 'TOTP Active', totpCodeHelp: 'Enter the code from your authenticator app.', totpEnabled: 'TOTP (Time-based One-Time Password) enabled', totpQrInstructions: 'TOTP is a multi-factor authentication (MFA) using an authenticator app, such as Google Authenticator. Using the app on your mobile device, scan the QR code shown below.', @@ -701,6 +708,7 @@ const i18n = { viewCase: 'Case Details', viewResults: 'View Results', webauthn: 'Security Keys (WebAuthn / PassKey)', + webauthnActive: 'Webauthn / Security Keys Active', webauthnAddKey: 'Add New Security Key', webauthnContinueHelp: 'Prepare your security key (webauthn) device, and press Login when ready.', webauthnExistingKeys: 'Existing Keys:', diff --git a/html/js/routes/login.js b/html/js/routes/login.js index e4bed47dd..18a56860f 100644 --- a/html/js/routes/login.js +++ b/html/js/routes/login.js @@ -27,6 +27,8 @@ routes.push({ path: '*', name: 'login', component: { script: null, email: null, }, + passwordEnabled: false, + totpEnabled: false, oidc: [], totpCodeLength: 6, rules: { @@ -107,15 +109,14 @@ routes.push({ path: '*', name: 'login', component: { this.csrfToken = flow.data.ui.nodes.find(item => item.attributes && item.attributes.name == 'csrf_token').attributes.value; - // method could be password or totp depending on which phase of login we're in. May be ignored if webauthn is in progress. - this.form.method = flow.data.ui.nodes.find(item => item.attributes && item.attributes.name == 'method' && item.attributes.value == 'password') ? 'password' : 'totp'; - + this.extractPasswordData(flow); + this.extractTotpData(flow); this.extractWebauthnData(flow); this.extractOidcData(flow); this.$nextTick(function () { // Wait for next Vue tick to set focus, since at the time of this function call (or even mounted() hook), this element won't be // loaded, due to v-if's that have yet to process. - if (this.form.method == "totp") { + if (this.totpEnabled) { const ele = document.getElementById("totp--0"); if (ele) { ele.focus(); @@ -171,5 +172,15 @@ routes.push({ path: '*', name: 'login', component: { extractOidcData(response) { this.oidc = response.data.ui.nodes.filter(item => item.group == "oidc" && item.type == "input" ).map(item => item.attributes.value); }, + extractPasswordData(response) { + if (response.data.ui.nodes.find(item => item.group == "password")) { + this.passwordEnabled = true; + } + }, + extractTotpData(response) { + if (response.data.ui.nodes.find(item => item.group == "totp")) { + this.totpEnabled = true; + } + }, }, }}); diff --git a/html/js/routes/login.test.js b/html/js/routes/login.test.js index 56964382a..51a348116 100644 --- a/html/js/routes/login.test.js +++ b/html/js/routes/login.test.js @@ -192,4 +192,44 @@ test('shouldRunWebauthn', () => { comp.webauthnForm.onclick = 'this.foo = 123'; comp.runWebauthn(); expect(comp.foo).toBe(123); -}); \ No newline at end of file +}); + +test('shouldExtractPasswordData', () => { + const identifier = {attributes: {name: 'identifier', value: 'some_identifier'}}; + const passwordMethod = {group: 'password', attributes: {name: 'method', value: 'password'}}; + const nodes = [identifier, passwordMethod]; + const response = {data: {ui: {nodes: nodes}}}; + + expect(comp.passwordEnabled).toBe(false); + + comp.extractPasswordData(response); + + expect(comp.passwordEnabled).toBe(true); +}); + +test('shouldExtractTotpData', () => { + const identifier = {attributes: {name: 'identifier', value: 'some_identifier'}}; + const totpMethod = {group: 'totp', attributes: {name: 'method', value: 'totp'}}; + const nodes = [identifier, totpMethod]; + const response = {data: {ui: {nodes: nodes}}}; + + expect(comp.totpEnabled).toBe(false); + + comp.extractTotpData(response); + + expect(comp.totpEnabled).toBe(true); +}); + +test('shouldExtractOidcData', () => { + const identifier = {attributes: {name: 'identifier', value: 'some_identifier'}}; + const oidcMethod = {group: 'oidc', type: 'input', attributes: {value: 'SSO'}}; + const nodes = [identifier, oidcMethod]; + const response = {data: {ui: {nodes: nodes}}}; + + expect(comp.oidc.length).toBe(0); + + comp.extractOidcData(response); + + expect(comp.oidc.length).toBe(1); + expect(comp.oidc[0]).toBe('SSO'); +}); diff --git a/html/js/routes/settings.js b/html/js/routes/settings.js index 79c797267..1a5101127 100644 --- a/html/js/routes/settings.js +++ b/html/js/routes/settings.js @@ -41,6 +41,9 @@ routes.push({ path: '/settings', name: 'settings', component: { script: null, existingKeys: [], }, + passwordEnabled: false, + oidcEnabled: false, + oidcProviders: [], rules: { required: value => !!value || this.$root.i18n.required, matches: value => (!!value && value == this.passwordForm.password) || this.$root.i18n.passwordMustMatch, @@ -85,8 +88,10 @@ routes.push({ path: '/settings', name: 'settings', component: { this.profileForm.lastName = response.data.identity.traits.lastName; this.profileForm.note = response.data.identity.traits.note; } + this.extractPasswordData(response); this.extractTotpData(response); this.extractWebauthnData(response); + this.extractOidcData(response); var errorsMessage = null; if (response.data.ui.messages && response.data.ui.messages.length > 0) { @@ -147,6 +152,17 @@ routes.push({ path: '/settings', name: 'settings', component: { }, runWebauthn() { eval(this.webauthnForm.onclick); + }, + extractPasswordData(response) { + if (response.data.ui.nodes.find(item => item.group == "password")) { + this.passwordEnabled = true; + } + }, + extractOidcData(response) { + response.data.ui.nodes.filter(item => item.group == "oidc").forEach((oidc) => { + this.oidcEnabled = true; + this.oidcProviders.push({op: oidc.attributes.name, id: oidc.attributes.value}); + }); } } }}); diff --git a/html/js/routes/settings.test.js b/html/js/routes/settings.test.js index 77d64a093..f80b40e0c 100644 --- a/html/js/routes/settings.test.js +++ b/html/js/routes/settings.test.js @@ -81,4 +81,34 @@ test('shouldRunWebauthn', () => { comp.webauthnForm.onclick = 'this.foo = 123'; comp.runWebauthn(); expect(comp.foo).toBe(123); -}); \ No newline at end of file +}); + +test('shouldExtractPasswordData', () => { + const identifier = {attributes: {name: 'identifier', value: 'some_identifier'}}; + const passwordMethod = {group: 'password', attributes: {name: 'method', value: 'password'}}; + const nodes = [identifier, passwordMethod]; + const response = {data: {ui: {nodes: nodes}}}; + + expect(comp.passwordEnabled).toBe(false); + + comp.extractPasswordData(response); + + expect(comp.passwordEnabled).toBe(true); +}); + +test('shouldExtractOidcData', () => { + const identifier = {attributes: {name: 'identifier', value: 'some_identifier'}}; + const oidcMethod = {group: 'oidc', type: 'input', attributes: {name: 'link', value: 'SSO'}}; + const nodes = [identifier, oidcMethod]; + const response = {data: {ui: {nodes: nodes}}}; + + expect(comp.oidcProviders.length).toBe(0); + expect(comp.oidcEnabled).toBe(false); + + comp.extractOidcData(response); + + expect(comp.oidcEnabled).toBe(true); + expect(comp.oidcProviders.length).toBe(1); + expect(comp.oidcProviders[0].id).toBe('SSO'); + expect(comp.oidcProviders[0].op).toBe('link'); +}); diff --git a/html/login/index.html b/html/login/index.html index d8b538e4f..b0c2da362 100644 --- a/html/login/index.html +++ b/html/login/index.html @@ -63,23 +63,35 @@ fa-brands fa-microsoft fa-brands fa-google + fa-brands fa-github fa-arrow-right-to-bracket {{ i18n.loginOidc + ' ' + provider }}
- + + - - -
-
{{ i18n.totpCodeHelp }}
- - -
+ + + + +
+ + + + +
+ + + + +
{{ i18n.totpCodeHelp }}
+ + - +
diff --git a/model/user.go b/model/user.go index 6f5156825..9e47c66af 100644 --- a/model/user.go +++ b/model/user.go @@ -18,6 +18,8 @@ type User struct { FirstName string `json:"firstName"` LastName string `json:"lastName"` TotpStatus string `json:"totpStatus"` + OidcStatus string `json:"oidcStatus"` + WebauthnStatus string `json:"webauthnStatus"` Note string `json:"note"` Roles []string `json:"roles"` Status string `json:"status"` diff --git a/server/modules/kratos/kratosuser.go b/server/modules/kratos/kratosuser.go index 6ac0378e5..fe81fecc8 100644 --- a/server/modules/kratos/kratosuser.go +++ b/server/modules/kratos/kratosuser.go @@ -100,6 +100,16 @@ func (kratosUser *KratosUser) copyToUser(user *model.User) { } else { user.TotpStatus = "disabled" } + if kratosUser.Credentials["oidc"] != nil { + user.OidcStatus = "enabled" + } else { + user.OidcStatus = "disabled" + } + if kratosUser.Credentials["webauthn"] != nil { + user.WebauthnStatus = "enabled" + } else { + user.WebauthnStatus = "disabled" + } } } diff --git a/server/modules/kratos/kratosuser_test.go b/server/modules/kratos/kratosuser_test.go index f9f026177..3a3bd8449 100644 --- a/server/modules/kratos/kratosuser_test.go +++ b/server/modules/kratos/kratosuser_test.go @@ -43,6 +43,8 @@ func TestCopyToUser(tester *testing.T) { kratosUser := NewKratosUser("myEmail", "myFirst", "myLast", "note", "inactive") kratosUser.Credentials = make(map[string]*KratosCredential) kratosUser.Credentials["totp"] = &KratosCredential{Type: "totp"} + kratosUser.Credentials["webauthn"] = &KratosCredential{Type: "webauthn"} + kratosUser.Credentials["oidc"] = &KratosCredential{Type: "oidc"} kratosUser.Credentials["password"] = &KratosCredential{Type: "password"} user := model.NewUser() kratosUser.copyToUser(user) @@ -53,6 +55,8 @@ func TestCopyToUser(tester *testing.T) { assert.Equal(tester, kratosUser.Addresses[0].Value, user.Email) assert.Equal(tester, "locked", user.Status) assert.Equal(tester, "enabled", user.TotpStatus) + assert.Equal(tester, "enabled", user.OidcStatus) + assert.Equal(tester, "enabled", user.WebauthnStatus) assert.Equal(tester, false, user.PasswordChanged) kratosUser.Credentials = make(map[string]*KratosCredential) From 463a4396d8175bc08a59abe35a1d8eecf275569d Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Wed, 26 Jul 2023 20:29:09 -0400 Subject: [PATCH 14/84] oidc --- html/index.html | 10 +++++----- html/js/i18n.js | 2 +- server/modules/kratos/kratosuser.go | 2 +- server/modules/kratos/kratosuser_test.go | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/html/index.html b/html/index.html index 19a4162b8..efa4a5085 100644 --- a/html/index.html +++ b/html/index.html @@ -1189,7 +1189,7 @@

{{ i18n.usersEnabled }} {{ countUsersEnabled() }} / {{
fa-link fa-user-shield - fa-fingerprint + fa-fingerprint fa-user-slash fa-exclamation-triangle
@@ -1225,15 +1225,15 @@

{{ i18n.usersEnabled }} {{ countUsersEnabled() }} / {{ {{ i18n.access }} + + {{ i18n.oidcResetPasswordHelp }} + + - - {{ i18n.oidcResetPasswordHelp }} - - diff --git a/html/js/i18n.js b/html/js/i18n.js index 73ee09a3f..93b928534 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -470,7 +470,7 @@ const i18n = { oidcLinked: 'OIDC Linked', oidcInstructions: 'Users can link to or unlink from the OIDC providers listed below. Be aware that unlinking from all OIDC providers without having a local password set may result in being unable to access this user account. If prompted to login again to verify your identity, choose a login method which is already verified. For example, if you are linking to a new OIDC provider, you cannot use that OIDC provider to confirm your identity.', oidcLink: 'Link with ', - oidcResetPasswordHelp: 'Resetting this user password will also unlink them from all configured OIDC providers. Consider resetting their password in the provider administrator interface instead.', + oidcResetPasswordHelp: 'Administering authentication settings for OIDC users should normally be conducted in the external provider administration interface. Proceeding may cause the user to be disconnected from the OIDC provider.', oidcUnlink: 'Unlink from ', ok: 'OK', offline: 'Offline', diff --git a/server/modules/kratos/kratosuser.go b/server/modules/kratos/kratosuser.go index fe81fecc8..2ff02905d 100644 --- a/server/modules/kratos/kratosuser.go +++ b/server/modules/kratos/kratosuser.go @@ -100,7 +100,7 @@ func (kratosUser *KratosUser) copyToUser(user *model.User) { } else { user.TotpStatus = "disabled" } - if kratosUser.Credentials["oidc"] != nil { + if kratosUser.Credentials["oidc"] != nil && len(kratosUser.Credentials["oidc"].Identifiers) > 0 { user.OidcStatus = "enabled" } else { user.OidcStatus = "disabled" diff --git a/server/modules/kratos/kratosuser_test.go b/server/modules/kratos/kratosuser_test.go index 3a3bd8449..30d60bcc0 100644 --- a/server/modules/kratos/kratosuser_test.go +++ b/server/modules/kratos/kratosuser_test.go @@ -44,7 +44,9 @@ func TestCopyToUser(tester *testing.T) { kratosUser.Credentials = make(map[string]*KratosCredential) kratosUser.Credentials["totp"] = &KratosCredential{Type: "totp"} kratosUser.Credentials["webauthn"] = &KratosCredential{Type: "webauthn"} - kratosUser.Credentials["oidc"] = &KratosCredential{Type: "oidc"} + oidcIds := make([]string, 1, 1) + oidcIds[0] = "test" + kratosUser.Credentials["oidc"] = &KratosCredential{Type: "oidc", Identifiers: oidcIds} kratosUser.Credentials["password"] = &KratosCredential{Type: "password"} user := model.NewUser() kratosUser.copyToUser(user) From 583645106e1fc213be19e3fc8216a1951474ff32 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Fri, 28 Jul 2023 07:12:55 -0400 Subject: [PATCH 15/84] adjust color of icons --- html/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/html/index.html b/html/index.html index efa4a5085..75ea957ef 100644 --- a/html/index.html +++ b/html/index.html @@ -1187,9 +1187,9 @@

{{ i18n.usersEnabled }} {{ countUsersEnabled() }} / {{
- fa-link - fa-user-shield - fa-fingerprint + fa-link + fa-user-shield + fa-fingerprint fa-user-slash fa-exclamation-triangle
From 528df5109a18d030a3551586a433b753e77ef79c Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Fri, 28 Jul 2023 13:43:12 -0600 Subject: [PATCH 16/84] Pick the correct field when interacting with bar charts --- html/js/routes/hunt.js | 31 ++++++++++++++++++------------- html/js/routes/hunt.test.js | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index 4484fe560..088924764 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -1260,7 +1260,7 @@ const huntComponent = { group.chart_type = "bar"; group.chart_options = {}; group.chart_data = {}; - this.setupBarChart(group.chart_options, group.chart_data, group.title); + this.setupBarChart(group.chart_options, group.chart_data, group.title, groupIdx); this.applyLegendOption(group, groupIdx); this.populateChart(group.chart_data, group.chart_metrics); Vue.set(this.groupBys, groupIdx, group); @@ -1473,11 +1473,11 @@ const huntComponent = { this.setupTimelineChart(this.timelineChartOptions, this.timelineChartData, this.i18n.chartTitleTimeline); this.setupBarChart(this.bottomChartOptions, this.bottomChartData, this.i18n.chartTitleBottom); }, - setupBarChart(options, data, title) { + setupBarChart(options, data, title, groupIdx) { var fontColor = this.$root.getColor("#888888", -40); var dataColor = this.$root.getColor("primary"); var gridColor = this.$root.getColor("#888888", 65); - options.onClick = this.handleChartClick; + options.onClick = this.handleChartClick(groupIdx); options.responsive = true; options.maintainAspectRatio = false; options.plugins = { @@ -1621,18 +1621,23 @@ const huntComponent = { } return color; }, - async handleChartClick(e, activeElement, chart) { - if (activeElement.length > 0) { - var clickedValue = chart.data.labels[activeElement[0].index] + ""; - if (clickedValue && clickedValue.length > 0) { - if (this.canQuery(clickedValue)) { - var chartGroupByField = this.groupBys[0].fields[0]; - this.toggleQuickAction(e, {}, chartGroupByField, clickedValue); + handleChartClick(groupIdx) { + if (!groupIdx) { + groupIdx = 0; + } + return (e, activeElement, chart) => { + if (activeElement.length > 0) { + var clickedValue = chart.data.labels[activeElement[0].index] + ""; + if (clickedValue && clickedValue.length > 0) { + if (this.canQuery(clickedValue)) { + var chartGroupByField = this.groupBys[groupIdx].fields[0]; + this.toggleQuickAction(e, {}, chartGroupByField, clickedValue); + } } + return true; } - return true; - } - return false; + return false; + }; }, groupByLimitChanged() { if (this.groupByItemsPerPage > this.groupByLimit) { diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index 33ab78264..9edcbc8ca 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -924,3 +924,22 @@ test('filterVisibleFields', () => { expect(comp.filterVisibleFields('module', 'otherData', [])).toEqual('c'); expect(comp.filterVisibleFields('A', 'B', [])).toEqual('default'); }); + +test('handleChartClick', () => { + const orig = comp.toggleQuickAction; + comp.toggleQuickAction = jest.fn(); + + let metrics = { "groupby_2|MyField": [] }; + let groupIdx = 2; + comp.queryGroupByOptions = [[], [], ["bar"]] + + + const result = comp.populateGroupByTable(metrics, groupIdx); + comp.groupBys[2].chart_options.onClick(null, [{ index: 0 }], { data: {labels: ['value']} }); + + expect(result).toBe(true); + expect(comp.toggleQuickAction).toHaveBeenCalledTimes(1); + expect(comp.toggleQuickAction).toHaveBeenCalledWith(null, {}, 'MyField', 'value'); + + comp.toggleQuickAction = orig; +}); From 5fa5f867f63369b28753eda0a38b21ebd07d44ed Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Tue, 25 Jul 2023 15:01:42 -0600 Subject: [PATCH 17/84] "Add to Case" From Alerts/Dashboards/Hunt --- config/clientparameters.go | 1 + html/css/app.css | 11 ++ html/index.html | 48 +++++++- html/js/i18n.js | 4 + html/js/routes/hunt.js | 223 ++++++++++++++++++++++++++++++++++++- 5 files changed, 285 insertions(+), 2 deletions(-) diff --git a/config/clientparameters.go b/config/clientparameters.go index 774c721b5..fb97fc21b 100644 --- a/config/clientparameters.go +++ b/config/clientparameters.go @@ -82,6 +82,7 @@ type HuntingAction struct { Body string `json:"body"` Options map[string]interface{} `json:"options"` Categories []string `json:"categories"` + JSCall string `json:"jsCall"` } type ToggleFilter struct { diff --git a/html/css/app.css b/html/css/app.css index 16610762d..c27fef3de 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -485,3 +485,14 @@ td { .theme--light .resolved-host { color: rgba(0, 0, 0, 0.5); } + +.gray-box { + background-color: rgb(53, 53, 53); + padding-left: 12px; + padding-right: 12px; + border-radius: 4px; +} + +.theme--light .gray-box { + background-color: rgb(244, 244, 244) !important; +} diff --git a/html/index.html b/html/index.html index dbaf8e9d4..7dd8a1f71 100644 --- a/html/index.html +++ b/html/index.html @@ -830,7 +830,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

{{ i18n.quickActions }} - + {{ action.icon }} @@ -897,6 +897,52 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

+ + + + + + +
+ + + + + + + + + + + + + + + +
+
+ +
+
{{ i18n.artifactDescription }}:
+
{{ selectedOpenCase.payload["so_case.description"] }}
+
+
+ + +
+
+
+ + + + + +
+
diff --git a/html/js/i18n.js b/html/js/i18n.js index 187189d88..92e2f128c 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -19,6 +19,8 @@ const i18n = { ackUndoMultipleTip: 'Reverting acknowledgment on groups of alerts may take a while and will continue in the background.', ackUndoSingleTip: 'Reverted acknowledgement and removed from view.', actions: 'Actions', + actionAddToCase: 'Add to Case', + actionAddToCaseHelp: 'Add to a new or existing case', actionAlert: 'Alert', actionAlertHelp: 'Create an alert for this event', actionCorrelate: 'Correlate', @@ -203,6 +205,7 @@ const i18n = { copyFieldValueToClipboard: 'Copy as field:value', copyToClipboard: 'Copy to clipboard', create: 'Create', + createNewCase: 'Create a new case...', custom: 'Custom', darkMode: 'Dark Mode', dashboards: 'Dashboards', @@ -468,6 +471,7 @@ const i18n = { ok: 'OK', offline: 'Offline', online: 'Online', + openCases: 'Open Cases', operation: 'Operation', options: 'Options', order: 'Order', diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index 088924764..4635c8404 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -134,6 +134,29 @@ const huntComponent = { { text: '<', value: false }, { text: '≤', value: true } ], + addToCaseDialogVisible: false, + openCases: [], + selectedOpenCase: null, + observableForm: { + valid: true, + artifactType: '', + value: '', + description: '', + bulk: false, + tlp: '', + tags: [], + ioc: false, + }, + presets: [], + rules: { + required: value => (value && value.length > 0) || this.$root.i18n.required, + fileSizeLimit: value => (value == null || value.size < this.maxUploadSizeBytes) || this.$root.i18n.fileTooLarge.replace("{maxUploadSizeBytes}", this.$root.formatCount(this.maxUploadSizeBytes)), + fileNotEmpty: value => (value == null || value.size > 0) || this.$root.i18n.fileEmpty, + fileRequired: value => (value != null) || this.$root.i18n.required, + }, + observableAttachment: null, + newCaseTitle: '', + newCaseDescription: '', }}, created() { this.$root.initializeCharts(); @@ -245,6 +268,7 @@ const huntComponent = { } this.setupCharts(); + this.presets = this.$root.parameters.case["presets"]; this.$root.stopLoading(); if (!this.parseUrlParameters()) return; @@ -995,7 +1019,7 @@ const huntComponent = { } } - if (action.enabled) { + if (action.enabled && !action.jsCall) { var link = route.$root.findEligibleActionLinkForEvent(action, event); if (link) { action.linkFormatted = route.$root.formatActionContent(link, event, field, value, true); @@ -1846,6 +1870,203 @@ const huntComponent = { this.$router.push(this.buildFilterRoute(this.quickActionField, range, FILTER_INCLUDE, true)); }, + performAction($event, action) { + if (action && action.jsCall && this[action.jsCall]) { + this[action.jsCall](action); + return true; + } + + return false; + }, + async openAddToCaseDialog() { + // this function is meant to be called by performAction($event, action) + this.addToCaseDialogVisible = true; + this.newCaseTitle = ''; + this.newCaseDescription = ''; + this.resetForm(); + + if (this.$refs && this.$refs['evidence']) { + this.$refs['evidence'].resetValidation() + } + + this.openCases = [ + { + text: this.i18n.createNewCase, + value: 'New Case', + } + ]; + this.selectedOpenCase = 'New Case'; + + const cases = await this.getOpenCases(); + for (let i = 0; i < cases.length; i++) { + this.openCases.push({ + text: cases[i].payload["so_case.title"], + value: cases[i], + }); + } + + this.observableForm.artifactType = this.getDefaultPreset('artifactType'); + this.observableForm.value = this.quickActionValue + ''; + this.observableForm.tlp = this.getDefaultPreset('tlp'); + }, + async getOpenCases() { + try { + const now = moment().format(this.i18n.timePickerFormat); + const then = moment(0).format(this.i18n.timePickerFormat); + + let query = this.$root.parameters.cases.queries.find(q => q.name === 'Open Cases'); + if (query) { + query = `(${query.query}) AND _index:"*:so-case" AND so_kind:case`; + } else { + query = '(NOT so_case.status:closed AND NOT so_case.category:template) AND _index:"*:so-case" AND so_kind:case' + } + + const response = await this.$root.papi.get('events/', { + params: { + query: query, + range: `${then} - ${now}`, + zone: this.zone, + format: this.i18n.timePickerSample, + metricLimit: this.groupByLimit, + eventLimit: this.eventLimit, + } + }); + + return response.data.events; + } catch (error) { + this.$root.showError(error); + } + }, + cancelAddToCaseDialog() { + this.resetForm(); + + this.addToCaseDialogVisible = false; + }, + resetForm() { + this.observableForm = { + valid: true, + artifactType: '', + value: '', + description: '', + bulk: false, + tlp: '', + tags: [], + ioc: false, + }; + }, + async addToCase() { + if (this.$refs && this.$refs['evidence'] && !this.$refs['evidence'].validate()) return; + + this.addToCaseDialogVisible = false; + + this.observableForm.groupType = 'evidence'; + if (this.selectedOpenCase !== 'New Case') { + this.observableForm.caseId = this.selectedOpenCase.id; + } + + this.observableForm.id = ''; + if (this.observableForm.value) { + this.observableForm.value = this.observableForm.value.trim(); + } + + if (this.selectedOpenCase === 'New Case') { + let c = await this.createNewCase(); + this.observableForm.caseId = c.id; + } + + this.addToExistingCase(); + }, + async createNewCase() { + let payload = { + title: this.newCaseTitle, + description: this.newCaseDescription, + }; + + if (!payload.description) payload.description = this.i18n.caseDefaultDescription; + + let response = await this.$root.papi.post('case', payload); + return response.data; + }, + async addToExistingCase() { + try { + let response = null; + if (this.attachment && this.observableForm.artifactType === 'file') { + const data = new FormData(); + data.append("json", JSON.stringify(this.observableForm)); + data.append("attachment", this.attachment); + + const headers = { 'Content-Type': 'multipart/form-data; boundary=' + data._boundary } + let config = { 'headers': headers }; + + response = await this.$root.papi.post('case/artifacts', data, config); + } else if (this.observableForm.artifactType != 'file' && this.observableForm.bulk) { + let added = 0; + const combined = this.observableForm.value; + + const values = combined.split("\n") + for (var i = 0; i < values.length; i++) { + const val = values[i]; + if (val.trim().length > 0) { + this.observableForm.value = val.trim(); + + let data = JSON.stringify(this.observableForm); + response = await this.$root.papi.post('case/artifacts', data); + + if (response && response.data) { + added++; + } + + response = null; + } + } + + if (added > 0) { + this.resetForm(); + this.$root.showTip(this.i18n.saveSuccess); + } + } else { + let data = JSON.stringify(this.observableForm); + response = await this.$root.papi.post('case/artifacts', data); + } + + if (response && response.data) { + this.resetForm(); + this.$root.showTip(this.i18n.saveSuccess); + } + } catch (error) { + this.$root.showError(error); + } + }, + isPresetCustomEnabled(kind) { + if (this.presets && this.presets[kind]) { + return this.presets[kind].customEnabled == true; + } + return false; + }, + selectList(field, value) { + const presets = this.getPresets(field); + return this.isPresetCustomEnabled(field) && value + ? presets.concat(value) + : presets + }, + getPresets(kind) { + if (this.presets && this.presets[kind]) { + return this.presets[kind].labels; + } + return []; + }, + getAttachmentHelp() { + return this.i18n.attachmentHelp.replace("{maxUploadSizeBytes}", this.$root.formatCount(this.maxUploadSizeBytes)); + }, + getDefaultPreset(preset) { + if (this.presets) { + const presets = this.presets[preset]; + if (presets && presets.labels && presets.labels.length > 0) { + return presets.labels[0]; + } + } + return ""; + } } }; From 58e9ddd5083caf99ee2f37a34dc6beed8b182ead Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Tue, 25 Jul 2023 16:39:03 -0600 Subject: [PATCH 18/84] "test helpers" --- html/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/html/index.html b/html/index.html index 7dd8a1f71..b6bb77cb0 100644 --- a/html/index.html +++ b/html/index.html @@ -931,8 +931,8 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

{{ selectedOpenCase.payload["so_case.description"] }}

- - + +
From 2fe2b8b1039a63dfbbb32e414ad0b345c7ff00c2 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Wed, 26 Jul 2023 10:19:19 -0600 Subject: [PATCH 19/84] Added tests relating to cases in Hunt --- html/js/routes/hunt.test.js | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index 9edcbc8ca..5486353b7 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -943,3 +943,70 @@ test('handleChartClick', () => { comp.toggleQuickAction = orig; }); + +test('getOpenCases', async () => { + const mock1 = mockPapi("get", { data: { events: "success" } }); + + comp.$root.parameters.cases = { queries: [{ name: 'Open Cases', query: 'CUSTOM_QUERY' }] }; + comp.zone = 'Etc/Utc'; + + const data1 = await comp.getOpenCases(); + + expect(data1).toBe("success"); + expect(mock1).toHaveBeenCalledWith('events/', { + params: { + eventLimit: 100, + format: "2006/01/02 3:04:05 PM", + metricLimit: 10, + zone: "Etc/Utc", + range: expect.stringMatching(/^\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2} (A|P)M - \d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2} (A|P)M$/), + query: expect.stringContaining('CUSTOM_QUERY') + } + }); + + resetPapi(); + const mock2 = mockPapi("get", { data: { events: "success" } }); + comp.$root.parameters.cases.queries = []; + + const data2 = await comp.getOpenCases(); + + expect(data2).toBe("success"); + expect(mock2).toHaveBeenCalledWith('events/', { + params: { + eventLimit: 100, + format: "2006/01/02 3:04:05 PM", + metricLimit: 10, + zone: "Etc/Utc", + range: expect.stringMatching(/^\d{4}\/\d{1,2}\/\d{1,2} \d{1,2}:\d{2}:\d{2} (A|P)M - \d{4}\/\d{1,2}\/\d{1,2} \d{1,2}:\d{2}:\d{2} (A|P)M$/), + query: '(NOT so_case.status:closed AND NOT so_case.category:template) AND _index:"*:so-case" AND so_kind:case' + } + }); +}); + +test('createNewCase', async () => { + const mock1 = mockPapi("post", { data: "success" }); + + comp.newCaseTitle = 'Title'; + comp.newCaseDescription = 'Description'; + + const data1 = await comp.createNewCase(); + + expect(data1).toBe("success"); + expect(mock1).toHaveBeenCalledWith('case', { + title: 'Title', + description: 'Description', + }); + + resetPapi(); + const mock2 = mockPapi("post", { data: "success" }); + + comp.newCaseDescription = ''; + + const data2 = await comp.createNewCase(); + + expect(data2).toBe("success"); + expect(mock2).toHaveBeenCalledWith('case', { + title: 'Title', + description: comp.i18n.caseDefaultDescription, + }); +}); From cb016205711f6e67e80a9d4ae154d7c7baf9b897 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Wed, 26 Jul 2023 17:21:52 -0600 Subject: [PATCH 20/84] Rewrite to use Case page Adding to a Case from hunt now involves a dialog that only lists MRU Cases + the option to create a new case. Specifying an existing case will open a page to that case and leave you on the Add Observable page with the value prefilled. Specifying "Create New" creates a new case with the default title and description then leaves you on the Add Observable page with the value prefilled. New or existing, this approach does not save the Observable until you click the 'Add' button on the cases page. This gives the user time to enter metadata. Removes unnecessary changes from previous approach. --- html/css/app.css | 11 --- html/index.html | 34 +------- html/js/i18n.js | 2 +- html/js/routes/case.js | 29 +++++-- html/js/routes/hunt.js | 188 +++++------------------------------------ 5 files changed, 44 insertions(+), 220 deletions(-) diff --git a/html/css/app.css b/html/css/app.css index c27fef3de..16610762d 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -485,14 +485,3 @@ td { .theme--light .resolved-host { color: rgba(0, 0, 0, 0.5); } - -.gray-box { - background-color: rgb(53, 53, 53); - padding-left: 12px; - padding-right: 12px; - border-radius: 4px; -} - -.theme--light .gray-box { - background-color: rgb(244, 244, 244) !important; -} diff --git a/html/index.html b/html/index.html index b6bb77cb0..3035ff4e5 100644 --- a/html/index.html +++ b/html/index.html @@ -902,39 +902,7 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

- -
- - - - - - - - - - - - - - - -
-
- -
-
{{ i18n.artifactDescription }}:
-
{{ selectedOpenCase.payload["so_case.description"] }}
-
-
- - -
-
+
diff --git a/html/js/i18n.js b/html/js/i18n.js index 92e2f128c..811ed012b 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -442,6 +442,7 @@ const i18n = { model: 'Model', module: 'Module', months: 'months', + mruCases: 'Recently Viewed Cases', mruQuery: 'Recently Used', mruQueryHelp: 'This query is a user-defined query and is only available on this browser.', na: 'N/A', @@ -471,7 +472,6 @@ const i18n = { ok: 'OK', offline: 'Offline', online: 'Online', - openCases: 'Open Cases', operation: 'Operation', options: 'Options', order: 'Order', diff --git a/html/js/routes/case.js b/html/js/routes/case.js index 79c01ccb5..ab8c72e52 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -162,13 +162,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { }, created() { }, - async mounted() { + mounted() { this.$root.loadParameters('case', this.initCase); - if (this.$route.params.id == 'create') { - await this.createCase(); - } else { - await this.loadData(); - } this.$root.subscribe("job", this.updateJob); }, beforeDestroy() { @@ -182,7 +177,13 @@ routes.push({ path: '/case/:id', name: 'case', component: { '$route': 'loadData', }, methods: { - initCase(params) { + async initCase(params) { + if (this.$route.params.id == 'create') { + await this.createCase(); + } else { + await this.loadData(); + } + this.params = params; this.mruCaseLimit = params["mostRecentlyUsedLimit"]; this.renderAbbreviatedCount = params["renderAbbreviatedCount"]; @@ -195,6 +196,18 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.resetForm('attachments'); this.resetForm('evidence'); this.resetForm('comments'); + + this.loadUrlParameters(); + }, + loadUrlParameters() { + if (this.$route.query.type) { + this.activeTab = this.$route.query.type; + } + + if (this.activeTab === 'evidence' && this.$route.query.value) { + this.enableAdding('evidence'); + this.associatedForms['evidence'].value = this.$route.query.value; + } }, getAttachmentHelp() { return this.i18n.attachmentHelp.replace("{maxUploadSizeBytes}", this.$root.formatCount(this.maxUploadSizeBytes)); @@ -415,7 +428,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { description: this.i18n.caseDefaultDescription, }); if (response && response.data && response.data.id) { - this.$router.replace({ name: 'case', params: { id: response.data.id } }); + this.$router.replace({ name: 'case', params: { id: response.data.id }, query: this.$route.query }); } else { this.$root.showError(i18n.createFailed); } diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index 4635c8404..e990f0056 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -135,28 +135,8 @@ const huntComponent = { { text: '≤', value: true } ], addToCaseDialogVisible: false, - openCases: [], - selectedOpenCase: null, - observableForm: { - valid: true, - artifactType: '', - value: '', - description: '', - bulk: false, - tlp: '', - tags: [], - ioc: false, - }, - presets: [], - rules: { - required: value => (value && value.length > 0) || this.$root.i18n.required, - fileSizeLimit: value => (value == null || value.size < this.maxUploadSizeBytes) || this.$root.i18n.fileTooLarge.replace("{maxUploadSizeBytes}", this.$root.formatCount(this.maxUploadSizeBytes)), - fileNotEmpty: value => (value == null || value.size > 0) || this.$root.i18n.fileEmpty, - fileRequired: value => (value != null) || this.$root.i18n.required, - }, - observableAttachment: null, - newCaseTitle: '', - newCaseDescription: '', + mruCases: [], + selectedMruCase: null, }}, created() { this.$root.initializeCharts(); @@ -268,7 +248,6 @@ const huntComponent = { } this.setupCharts(); - this.presets = this.$root.parameters.case["presets"]; this.$root.stopLoading(); if (!this.parseUrlParameters()) return; @@ -1889,52 +1868,23 @@ const huntComponent = { this.$refs['evidence'].resetValidation() } - this.openCases = [ + this.mruCases = [ { text: this.i18n.createNewCase, value: 'New Case', } ]; - this.selectedOpenCase = 'New Case'; - - const cases = await this.getOpenCases(); - for (let i = 0; i < cases.length; i++) { - this.openCases.push({ - text: cases[i].payload["so_case.title"], - value: cases[i], - }); - } - - this.observableForm.artifactType = this.getDefaultPreset('artifactType'); - this.observableForm.value = this.quickActionValue + ''; - this.observableForm.tlp = this.getDefaultPreset('tlp'); - }, - async getOpenCases() { - try { - const now = moment().format(this.i18n.timePickerFormat); - const then = moment(0).format(this.i18n.timePickerFormat); - - let query = this.$root.parameters.cases.queries.find(q => q.name === 'Open Cases'); - if (query) { - query = `(${query.query}) AND _index:"*:so-case" AND so_kind:case`; - } else { - query = '(NOT so_case.status:closed AND NOT so_case.category:template) AND _index:"*:so-case" AND so_kind:case' + this.selectedMruCase = 'New Case'; + + const rawMRU = localStorage.getItem('settings.case.mruCases'); + if (rawMRU) { + const cases = JSON.parse(rawMRU); + for (let i = 0; i < cases.length; i++) { + this.mruCases.push({ + text: cases[i].title, + value: cases[i], + }); } - - const response = await this.$root.papi.get('events/', { - params: { - query: query, - range: `${then} - ${now}`, - zone: this.zone, - format: this.i18n.timePickerSample, - metricLimit: this.groupByLimit, - eventLimit: this.eventLimit, - } - }); - - return response.data.events; - } catch (error) { - this.$root.showError(error); } }, cancelAddToCaseDialog() { @@ -1954,118 +1904,22 @@ const huntComponent = { ioc: false, }; }, - async addToCase() { + addToCase() { if (this.$refs && this.$refs['evidence'] && !this.$refs['evidence'].validate()) return; this.addToCaseDialogVisible = false; - this.observableForm.groupType = 'evidence'; - if (this.selectedOpenCase !== 'New Case') { - this.observableForm.caseId = this.selectedOpenCase.id; - } - - this.observableForm.id = ''; - if (this.observableForm.value) { - this.observableForm.value = this.observableForm.value.trim(); - } + let url = window.location.origin + '/#/case/'; - if (this.selectedOpenCase === 'New Case') { - let c = await this.createNewCase(); - this.observableForm.caseId = c.id; + if (this.selectedMruCase !== 'New Case') { + url += this.selectedMruCase.id; + } else { + url += 'create'; } - this.addToExistingCase(); - }, - async createNewCase() { - let payload = { - title: this.newCaseTitle, - description: this.newCaseDescription, - }; - - if (!payload.description) payload.description = this.i18n.caseDefaultDescription; - - let response = await this.$root.papi.post('case', payload); - return response.data; - }, - async addToExistingCase() { - try { - let response = null; - if (this.attachment && this.observableForm.artifactType === 'file') { - const data = new FormData(); - data.append("json", JSON.stringify(this.observableForm)); - data.append("attachment", this.attachment); - - const headers = { 'Content-Type': 'multipart/form-data; boundary=' + data._boundary } - let config = { 'headers': headers }; - - response = await this.$root.papi.post('case/artifacts', data, config); - } else if (this.observableForm.artifactType != 'file' && this.observableForm.bulk) { - let added = 0; - const combined = this.observableForm.value; - - const values = combined.split("\n") - for (var i = 0; i < values.length; i++) { - const val = values[i]; - if (val.trim().length > 0) { - this.observableForm.value = val.trim(); - - let data = JSON.stringify(this.observableForm); - response = await this.$root.papi.post('case/artifacts', data); - - if (response && response.data) { - added++; - } - - response = null; - } - } + url += '?type=evidence&value=' + encodeURIComponent(this.quickActionValue); - if (added > 0) { - this.resetForm(); - this.$root.showTip(this.i18n.saveSuccess); - } - } else { - let data = JSON.stringify(this.observableForm); - response = await this.$root.papi.post('case/artifacts', data); - } - - if (response && response.data) { - this.resetForm(); - this.$root.showTip(this.i18n.saveSuccess); - } - } catch (error) { - this.$root.showError(error); - } - }, - isPresetCustomEnabled(kind) { - if (this.presets && this.presets[kind]) { - return this.presets[kind].customEnabled == true; - } - return false; - }, - selectList(field, value) { - const presets = this.getPresets(field); - return this.isPresetCustomEnabled(field) && value - ? presets.concat(value) - : presets - }, - getPresets(kind) { - if (this.presets && this.presets[kind]) { - return this.presets[kind].labels; - } - return []; - }, - getAttachmentHelp() { - return this.i18n.attachmentHelp.replace("{maxUploadSizeBytes}", this.$root.formatCount(this.maxUploadSizeBytes)); - }, - getDefaultPreset(preset) { - if (this.presets) { - const presets = this.presets[preset]; - if (presets && presets.labels && presets.labels.length > 0) { - return presets.labels[0]; - } - } - return ""; + window.open(url, '_blank'); } } }; From b2eb17d287ea96bda5e3c38bc881a510d14e82da Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Thu, 27 Jul 2023 11:34:53 -0600 Subject: [PATCH 21/84] Cleanup These should have been removed when the dialog was rewritten to use MRU Cases. --- html/js/routes/hunt.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index e990f0056..2207ae21c 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -1860,8 +1860,6 @@ const huntComponent = { async openAddToCaseDialog() { // this function is meant to be called by performAction($event, action) this.addToCaseDialogVisible = true; - this.newCaseTitle = ''; - this.newCaseDescription = ''; this.resetForm(); if (this.$refs && this.$refs['evidence']) { From 4f9adbbd429b3dc6ae695625a9fffe236c6e228e Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Thu, 27 Jul 2023 12:47:42 -0600 Subject: [PATCH 22/84] Fix MRU Cases Change timing of when we load what so we don't blow away mruCases by updating it before it's initialized. --- html/js/routes/case.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/html/js/routes/case.js b/html/js/routes/case.js index ab8c72e52..bd8c5c7f9 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -178,10 +178,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { }, methods: { async initCase(params) { - if (this.$route.params.id == 'create') { + if (this.$route.params.id === 'create') { await this.createCase(); - } else { - await this.loadData(); } this.params = params; @@ -193,6 +191,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { } this.analyzerNodeId = params["analyzerNodeId"]; this.loadLocalSettings(); + await this.loadData(); this.resetForm('attachments'); this.resetForm('evidence'); this.resetForm('comments'); From e92b2b72e96cb76dfd33999d9cdd509cc1d2b554 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Thu, 27 Jul 2023 16:03:05 -0600 Subject: [PATCH 23/84] Option for "Add in New Tab" in Add to Case dialog, cleanup, tests --- html/index.html | 3 +- html/js/i18n.js | 1 + html/js/routes/case.js | 11 +++++- html/js/routes/hunt.js | 28 ++++++-------- html/js/routes/hunt.test.js | 73 +++++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 19 deletions(-) diff --git a/html/index.html b/html/index.html index 3035ff4e5..998720d60 100644 --- a/html/index.html +++ b/html/index.html @@ -906,7 +906,8 @@

{{ i18n.eventTotal }} {{ totalEvents.toLocaleString() }}

- + +
diff --git a/html/js/i18n.js b/html/js/i18n.js index 811ed012b..9b0cc1129 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -41,6 +41,7 @@ const i18n = { add: 'Add', addAttachmentHelp: 'Add a new attachment to this case', addCommentHelp: 'Add a new comment to this case', + addInNewTab: 'Add in New Tab', addObservableHelp: 'Add a new observable to this case', addObservable: 'Add as new observable...', addSuccessful: 'Added successfully!', diff --git a/html/js/routes/case.js b/html/js/routes/case.js index bd8c5c7f9..14314e958 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -165,6 +165,11 @@ routes.push({ path: '/case/:id', name: 'case', component: { mounted() { this.$root.loadParameters('case', this.initCase); this.$root.subscribe("job", this.updateJob); + this.$watch( + () => this.$route.params, + (to, prev) => { + this.loadUrlParameters(); + }); }, beforeDestroy() { this.$root.setSubtitle(""); @@ -205,7 +210,11 @@ routes.push({ path: '/case/:id', name: 'case', component: { if (this.activeTab === 'evidence' && this.$route.query.value) { this.enableAdding('evidence'); - this.associatedForms['evidence'].value = this.$route.query.value; + this.$nextTick(() => { + this.associatedForms['evidence'].value = this.$route.query.value; + this.$refs['evidence'].validate(); + }); + window.name = encodeURIComponent(this.caseObj.title); } }, getAttachmentHelp() { diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index 2207ae21c..7cf3273ec 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -1860,7 +1860,6 @@ const huntComponent = { async openAddToCaseDialog() { // this function is meant to be called by performAction($event, action) this.addToCaseDialogVisible = true; - this.resetForm(); if (this.$refs && this.$refs['evidence']) { this.$refs['evidence'].resetValidation() @@ -1886,23 +1885,9 @@ const huntComponent = { } }, cancelAddToCaseDialog() { - this.resetForm(); - this.addToCaseDialogVisible = false; }, - resetForm() { - this.observableForm = { - valid: true, - artifactType: '', - value: '', - description: '', - bulk: false, - tlp: '', - tags: [], - ioc: false, - }; - }, - addToCase() { + addToCase(newTab) { if (this.$refs && this.$refs['evidence'] && !this.$refs['evidence'].validate()) return; this.addToCaseDialogVisible = false; @@ -1917,7 +1902,16 @@ const huntComponent = { url += '?type=evidence&value=' + encodeURIComponent(this.quickActionValue); - window.open(url, '_blank'); + let target = '_self'; + if (newTab) { + if (this.selectedMruCase === 'New Case') { + target = '_blank'; + } else { + target = encodeURIComponent(this.selectedMruCase.title); + } + } + + window.open(url, target); } } }; diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index 5486353b7..3747dcba8 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -1010,3 +1010,76 @@ test('createNewCase', async () => { description: comp.i18n.caseDefaultDescription, }); }); + +test('performAction', () => { + const mock = jest.fn(); + comp.testFunc = mock; + + let action = { jsCall: 'nonExistentFunc' }; + + let result = comp.performAction(undefined, action); + + expect(mock).toHaveBeenCalledTimes(0); + expect(result).toBe(false); + + action.jsCall = 'testFunc'; + + result = comp.performAction(undefined, action); + + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(action); + expect(result).toBe(true); + + delete comp.testFunc; +}); + +test('openAddToCaseDialog', () => { + localStorage['settings.case.mruCases'] = `[{ "id": "1", "title": "Case 1" }, { "id": "2", "title": "Case 2" }]`; + comp.$refs = { + evidence: { + resetValidation: jest.fn(), + } + }; + comp.openAddToCaseDialog(); + + expect(comp.addToCaseDialogVisible).toBe(true); + expect(comp.mruCases).toEqual([{ value: 'New Case', text: comp.i18n.createNewCase }, { value: { id: "1", title: 'Case 1' }, text: 'Case 1' }, { value: { id: "2", title: 'Case 2' }, text: 'Case 2' }]); + expect(comp.selectedMruCase).toBe('New Case'); + expect(comp.$refs.evidence.resetValidation).toHaveBeenCalledTimes(1); +}); + +test('addToCase', () => { + const origOpen = window.open; + window.open = jest.fn(); + comp.$refs = { + evidence: { + validate: jest.fn(), + } + }; + comp.$refs.evidence.validate.mockReturnValue(true); + + comp.quickActionValue = 'test'; + comp.selectedMruCase = 'New Case'; + + comp.addToCase(false); + + expect(window.open).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenCalledWith('http://localhost/#/case/create?type=evidence&value=test', '_self'); + expect(comp.addToCaseDialogVisible).toBe(false); + + comp.addToCase(true) + + expect(window.open).toHaveBeenCalledTimes(2); + expect(window.open).toHaveBeenCalledWith('http://localhost/#/case/create?type=evidence&value=test', '_blank'); + expect(comp.addToCaseDialogVisible).toBe(false); + + comp.selectedMruCase = { id: '1', title: 'Case 1' }; + + comp.addToCase(true); + + expect(window.open).toHaveBeenCalledTimes(3); + expect(window.open).toHaveBeenCalledWith('http://localhost/#/case/1?type=evidence&value=test', 'Case%201'); + expect(comp.addToCaseDialogVisible).toBe(false); + + window.open = origOpen; +}); \ No newline at end of file From 430b32f0b048b4d5669d5cccb31fcdc2e8b4b592 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Fri, 28 Jul 2023 09:55:01 -0600 Subject: [PATCH 24/84] Updated test --- html/js/routes/case.test.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/html/js/routes/case.test.js b/html/js/routes/case.test.js index 5dfd955c6..292dd301f 100644 --- a/html/js/routes/case.test.js +++ b/html/js/routes/case.test.js @@ -56,14 +56,19 @@ beforeEach(() => { resetPapi(); }); -test('initParams', () => { +test('initParams', async () => { + comp.$route.query = {}; comp.mruCases.push({id:"123"}); comp.saveLocalSettings() comp.mruCases = []; - comp.initCase({"foo":"bar", "mostRecentlyUsedLimit": 23}); + const mock = jest.fn().mockReturnValue(Promise.resolve({ data: [] })); + comp.$root.papi['get'] = mock; + + await comp.initCase({"foo":"bar", "mostRecentlyUsedLimit": 23}); expect(comp.params.foo).toBe("bar"); expect(comp.mruCaseLimit).toBe(23); - expect(comp.mruCases.length).toBe(1) + expect(comp.mruCases.length).toBe(2); + expect(mock).toHaveBeenCalledTimes(7); }); test('addMRUCaseObj', () => { @@ -125,7 +130,7 @@ test('loadAssociations', () => { comp.caseObj = {id: 'myCaseId'}; comp.loadAssociation = jest.fn(); comp.loadAssociations(); - + expect(comp.loadAssociation).toHaveBeenCalledWith('comments'); expect(comp.loadAssociation).toHaveBeenCalledWith('attachments'); expect(comp.loadAssociation).toHaveBeenCalledWith('evidence'); @@ -171,7 +176,7 @@ expectCaseDetails = () => { } test('createCase', async () => { - const params = { + const params = { "description": comp.i18n.caseDefaultDescription, "title": comp.i18n.caseDefaultTitle, }; @@ -527,7 +532,7 @@ test('startEdit', async () => { await comp.startEdit('myFid', 'myVal', 'myRoId', 'myField', fn, ['foo'], true); - const expectedObj = { + const expectedObj = { callback: fn, callbackArgs: ['foo'], field: 'myField', @@ -536,7 +541,7 @@ test('startEdit', async () => { orig: 'myVal', roId: 'myRoId', val: 'myVal', - valid: true, + valid: true, }; expect(comp.editForm).toStrictEqual(expectedObj); }) @@ -585,7 +590,7 @@ test('selectList', () => { 'customEnabled': false }, } - + const expectedList = [ 'presetSeverity1', 'presetSeverity2' @@ -604,7 +609,7 @@ test('selectList_CustomEnabledNoCustomVal', () => { 'customEnabled': true }, } - + const expectedList = [ 'presetSeverity1', 'presetSeverity2', @@ -854,7 +859,7 @@ test('shouldLoadAndUpdateAnalyzeJobs', async () => { mock = mockPapi("get", { data: [job3, job1, job2]}); const showErrorMock = mockShowError(); - comp.associations['evidence'] = [ + comp.associations['evidence'] = [ { id: 'artifact1' }, { id: 'artifact2' }, ]; From b021967f43fac276a1b09468574c70465df5c3bd Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Fri, 28 Jul 2023 10:07:00 -0600 Subject: [PATCH 25/84] Change naming scheme for new tabs in Add to Case Using a title will collide when creating multiple cases from scratch as they'll all be generated with the same default title. --- html/js/routes/case.js | 2 +- html/js/routes/hunt.js | 2 +- html/js/routes/hunt.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/html/js/routes/case.js b/html/js/routes/case.js index 14314e958..09de8cd34 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -214,7 +214,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.associatedForms['evidence'].value = this.$route.query.value; this.$refs['evidence'].validate(); }); - window.name = encodeURIComponent(this.caseObj.title); + window.name = encodeURIComponent(this.caseObj.id); } }, getAttachmentHelp() { diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index 7cf3273ec..fbb1e6538 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -1907,7 +1907,7 @@ const huntComponent = { if (this.selectedMruCase === 'New Case') { target = '_blank'; } else { - target = encodeURIComponent(this.selectedMruCase.title); + target = encodeURIComponent(this.selectedMruCase.id); } } diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index 3747dcba8..9fb8409f6 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -1078,7 +1078,7 @@ test('addToCase', () => { comp.addToCase(true); expect(window.open).toHaveBeenCalledTimes(3); - expect(window.open).toHaveBeenCalledWith('http://localhost/#/case/1?type=evidence&value=test', 'Case%201'); + expect(window.open).toHaveBeenCalledWith('http://localhost/#/case/1?type=evidence&value=test', '1'); expect(comp.addToCaseDialogVisible).toBe(false); window.open = origOpen; From 3a78494add54c1d4852c0dcfbdf2195cdd030d6f Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Fri, 28 Jul 2023 14:47:35 -0600 Subject: [PATCH 26/84] Cleanup rebase --- html/js/routes/hunt.test.js | 67 ------------------------------------- 1 file changed, 67 deletions(-) diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index 9fb8409f6..401149e6d 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -944,73 +944,6 @@ test('handleChartClick', () => { comp.toggleQuickAction = orig; }); -test('getOpenCases', async () => { - const mock1 = mockPapi("get", { data: { events: "success" } }); - - comp.$root.parameters.cases = { queries: [{ name: 'Open Cases', query: 'CUSTOM_QUERY' }] }; - comp.zone = 'Etc/Utc'; - - const data1 = await comp.getOpenCases(); - - expect(data1).toBe("success"); - expect(mock1).toHaveBeenCalledWith('events/', { - params: { - eventLimit: 100, - format: "2006/01/02 3:04:05 PM", - metricLimit: 10, - zone: "Etc/Utc", - range: expect.stringMatching(/^\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2} (A|P)M - \d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2} (A|P)M$/), - query: expect.stringContaining('CUSTOM_QUERY') - } - }); - - resetPapi(); - const mock2 = mockPapi("get", { data: { events: "success" } }); - comp.$root.parameters.cases.queries = []; - - const data2 = await comp.getOpenCases(); - - expect(data2).toBe("success"); - expect(mock2).toHaveBeenCalledWith('events/', { - params: { - eventLimit: 100, - format: "2006/01/02 3:04:05 PM", - metricLimit: 10, - zone: "Etc/Utc", - range: expect.stringMatching(/^\d{4}\/\d{1,2}\/\d{1,2} \d{1,2}:\d{2}:\d{2} (A|P)M - \d{4}\/\d{1,2}\/\d{1,2} \d{1,2}:\d{2}:\d{2} (A|P)M$/), - query: '(NOT so_case.status:closed AND NOT so_case.category:template) AND _index:"*:so-case" AND so_kind:case' - } - }); -}); - -test('createNewCase', async () => { - const mock1 = mockPapi("post", { data: "success" }); - - comp.newCaseTitle = 'Title'; - comp.newCaseDescription = 'Description'; - - const data1 = await comp.createNewCase(); - - expect(data1).toBe("success"); - expect(mock1).toHaveBeenCalledWith('case', { - title: 'Title', - description: 'Description', - }); - - resetPapi(); - const mock2 = mockPapi("post", { data: "success" }); - - comp.newCaseDescription = ''; - - const data2 = await comp.createNewCase(); - - expect(data2).toBe("success"); - expect(mock2).toHaveBeenCalledWith('case', { - title: 'Title', - description: comp.i18n.caseDefaultDescription, - }); -}); - test('performAction', () => { const mock = jest.fn(); comp.testFunc = mock; From 7a25538dfc06a3de3c71ac110e17793d01014420 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Tue, 1 Aug 2023 10:02:59 -0600 Subject: [PATCH 27/84] A fix for successfully escaping slashes before ending a string in the query parser This approach recognizes that escaped slashes do not escape other characters such as quotes ending a string. --- html/js/routes/hunt.js | 2 +- html/js/routes/hunt.test.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index fbb1e6538..8232c258a 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -697,7 +697,7 @@ const huntComponent = { break; } else if (this.query[i] == "\"" && !escaping) { insideQuote = !insideQuote; - } else if (this.query[i] == "\\") { + } else if (this.query[i] == "\\" && !escaping) { escaping = true; } else { escaping = false; diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index 401149e6d..2c9a8a5ed 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -736,6 +736,16 @@ test('obtainQueryDetails_queryGroupedFilterPipe', () => { expect(comp.querySortBys).toStrictEqual([]); }); +test('obtainQueryDetails_trickyEscapeSequence', () => { + comp.query = `process.working_directory:"C:\\\\Windows\\\\system32\\\\" | groupby host.name`; + comp.obtainQueryDetails(); + expect(comp.queryName).toBe("Custom"); + expect(comp.queryFilters).toStrictEqual([`process.working_directory:"C:\\\\Windows\\\\system32\\\\"`]); + expect(comp.queryGroupBys).toStrictEqual([["host.name"]]); + expect(comp.queryGroupByOptions).toStrictEqual([[]]); + expect(comp.querySortBys).toStrictEqual([]); +}); + test('query string filterToggles', () => { comp.$route = { path: "hunt", query: { socExcludeToggle: false } }; comp.filterToggles = [{ From 325f1753fbfa0259ea4a6cf258ea7658bffb8091 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Tue, 1 Aug 2023 10:38:59 -0600 Subject: [PATCH 28/84] Add AuthCheck to Import endpoint Removed call to GetMembers which required nodes/read permission that only superuser has. Added events/write Auth checks to previously added salt store actions. UI no longer shuffles users to the login screen when they get a 401 from an API request. --- html/js/app.js | 2 +- server/gridmembershandler.go | 25 ++++++++++--------------- server/modules/salt/saltstore.go | 10 ++++++++-- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/html/js/app.js b/html/js/app.js index 9adf43449..da26ec4d4 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -711,7 +711,7 @@ $(document).ready(function() { if (response) { const redirectCookie = this.getCookie('AUTH_REDIRECT'); if ((response.headers && response.headers['content-type'] == "text/html") || - (response.status == 401) || + (response.status == 401 && !response.request.responseURL.indexOf('/api/')) || (redirectCookie != null && redirectCookie.length > 0)) { this.deleteCookie('AUTH_REDIRECT'); this.showLogin(); diff --git a/server/gridmembershandler.go b/server/gridmembershandler.go index 6f9eb2492..d194e7847 100644 --- a/server/gridmembershandler.go +++ b/server/gridmembershandler.go @@ -76,26 +76,12 @@ func (h *GridMembersHandler) postImport(w http.ResponseWriter, r *http.Request) return } - members, err := h.server.GridMembersstore.GetMembers(ctx) - if err != nil { - web.Respond(w, r, http.StatusInternalServerError, err) - return - } - - _, gmExists := lo.Find(members, func(m *model.GridMember) bool { - return strings.EqualFold(m.Id, id) - }) - if !gmExists { - web.Respond(w, r, http.StatusNotFound, errors.New("grid member not found")) - return - } - uploadLimit := int64(h.server.Config.ClientParams.GridParams.MaxUploadSize) if uploadLimit == 0 { uploadLimit = 25 * 1024 * 1024 // 25 MiB } - err = r.ParseMultipartForm(uploadLimit) + err := r.ParseMultipartForm(uploadLimit) if err != nil { web.Respond(w, r, http.StatusBadRequest, err) return @@ -160,6 +146,15 @@ func (h *GridMembersHandler) postImport(w http.ResponseWriter, r *http.Request) ext = ext[1:] + // auth check before we do anything async, before we even process the upload, + // if the user is authorized to complete this action + // TODO: When we re-evaluate the permissions needed to SendFile, we should + // add that same permission check here + if err := h.server.CheckAuthorized(ctx, "write", "events"); err != nil { + web.Respond(w, r, http.StatusUnauthorized, err) + return + } + baseTargetDir := h.server.Config.ImportUploadDir // "/opt/sensoroni/uploads/" targetDir := filepath.Join(baseTargetDir, "processing", id) diff --git a/server/modules/salt/saltstore.go b/server/modules/salt/saltstore.go index 8ef46172c..3ee67020c 100644 --- a/server/modules/salt/saltstore.go +++ b/server/modules/salt/saltstore.go @@ -986,7 +986,11 @@ func (store *Saltstore) ManageMember(ctx context.Context, operation string, id s } func (store *Saltstore) SendFile(ctx context.Context, node string, from string, to string, cleanup bool) error { - // TODO: Auth Check + // TODO: re-evaluate necessary permissions when this feature is used for more + // than importing pcap/evtx. + if err := store.server.CheckAuthorized(ctx, "write", "events"); err != nil { + return err + } args := map[string]string{ "command": "send-file", @@ -1005,7 +1009,9 @@ func (store *Saltstore) SendFile(ctx context.Context, node string, from string, } func (store *Saltstore) Import(ctx context.Context, node string, file string, importer string) (*string, error) { - // TODO: Auth Check + if err := store.server.CheckAuthorized(ctx, "write", "events"); err != nil { + return nil, err + } args := map[string]string{ "command": "import-file", From d9b1b0733bfaa1bd454c6248cc14a372625efe31 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Tue, 1 Aug 2023 12:05:14 -0600 Subject: [PATCH 29/84] Add test for Auth Check --- server/gridmembershandler_test.go | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 server/gridmembershandler_test.go diff --git a/server/gridmembershandler_test.go b/server/gridmembershandler_test.go new file mode 100644 index 000000000..b2e4facd3 --- /dev/null +++ b/server/gridmembershandler_test.go @@ -0,0 +1,68 @@ +package server + +import ( + "bytes" + "context" + "encoding/hex" + "mime/multipart" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/security-onion-solutions/securityonion-soc/config" + "github.com/security-onion-solutions/securityonion-soc/model" + "github.com/security-onion-solutions/securityonion-soc/web" + "github.com/stretchr/testify/assert" +) + +type rejectAuthorizer struct{} + +func (auth *rejectAuthorizer) CheckContextOperationAuthorized(ctx context.Context, operation string, target string) error { + return model.NewUnauthorized("", operation, target) +} +func (auth *rejectAuthorizer) CheckUserOperationAuthorized(user *model.User, operation string, target string) error { + return model.NewUnauthorized("", operation, target) +} + +func TestImportAuth(t *testing.T) { + h := &GridMembersHandler{ + server: &Server{ + Authorizer: &rejectAuthorizer{}, + Config: &config.ServerConfig{}, + }, + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + ff, err := writer.CreateFormFile("attachment", "file.pcap") + if err != nil { + t.Fatal(err) + } + + content, err := hex.DecodeString("a1b2c3d4000000") + assert.NoError(t, err) + + _, err = ff.Write(content) + assert.NoError(t, err) + + assert.NoError(t, writer.Close()) + + w := httptest.NewRecorder() + + r := httptest.NewRequest("POST", "/1_standalone/import", bytes.NewReader(body.Bytes())) + r.Header.Add("Content-Type", "multipart/form-data; boundary="+writer.Boundary()) + + c := chi.NewRouteContext() + c.URLParams.Add("id", "1_standalone") + ctx := context.WithValue(context.Background(), chi.RouteCtxKey, c) + ctx = context.WithValue(ctx, web.ContextKeyRequestStart, time.Now()) + + r = r.WithContext(ctx) + + h.postImport(w, r) + + assert.Equal(t, 401, w.Code) + assert.Equal(t, web.GENERIC_ERROR_MESSAGE, w.Body.String()) +} From 72dedf9e7b247a979896b098c9821d1b9d993046 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 1 Aug 2023 16:59:34 -0400 Subject: [PATCH 30/84] prevent login flicker --- html/js/app.js | 4 ++-- html/js/app.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/html/js/app.js b/html/js/app.js index da26ec4d4..e62bf17a9 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -711,8 +711,8 @@ $(document).ready(function() { if (response) { const redirectCookie = this.getCookie('AUTH_REDIRECT'); if ((response.headers && response.headers['content-type'] == "text/html") || - (response.status == 401 && !response.request.responseURL.indexOf('/api/')) || - (redirectCookie != null && redirectCookie.length > 0)) { + (response.status == 401 && response.request.responseURL.indexOf('/api/') == -1) || + (response.request.responseURL.indexOf("/login/") == -1 && redirectCookie != null && redirectCookie.length > 0)) { this.deleteCookie('AUTH_REDIRECT'); this.showLogin(); return null diff --git a/html/js/app.test.js b/html/js/app.test.js index ec29ccab0..57f470823 100644 --- a/html/js/app.test.js +++ b/html/js/app.test.js @@ -407,3 +407,42 @@ test('isIPv6', () => { expect(app.isIPv6('::8')).toBe(true); expect(app.isIPv6('::')).toBe(true); }); + + // if (response) { + // const redirectCookie = this.getCookie('AUTH_REDIRECT'); + // if ((response.headers && response.headers['content-type'] == "text/html") || + // (response.status == 401 && !response.request.responseURL.indexOf('/api/')) || + // (response.request.responseURL.indexOf("/login/") == -1 && redirectCookie != null && redirectCookie.length > 0)) { + // this.deleteCookie('AUTH_REDIRECT'); + // this.showLogin(); + // return null + // } + // } + // return response; + +function testCheckForUnauthorized(url, response, authRedirectCookie, unauthorized) { + app.showLogin = jest.fn(); + app.getCookie = jest.fn(cookie => authRedirectCookie); + app.deleteCookie = jest.fn(); + + response.request = {responseURL: url}; + var result = app.checkForUnauthorized(response); + if (unauthorized) { + expect(result).toBe(null); + expect(app.showLogin).toHaveBeenCalled(); + expect(app.deleteCookie).toHaveBeenCalledWith('AUTH_REDIRECT'); + } else { + expect(result).toBe(response); + } +} + +test('checkForUnauthorized', () => { + testCheckForUnauthorized('/foo/', {headers: {'content-type': 'text/html'}}, null, true); + testCheckForUnauthorized('/foo/', {headers: {'content-type': 'application/json'}}, null, false); + testCheckForUnauthorized('/foo/', {status: 401}, null, true); + testCheckForUnauthorized('/foo/', {status: 200}, null, false); + testCheckForUnauthorized('/api/', {status: 401}, null, false); + testCheckForUnauthorized('/foo/', {}, '/blah', true); + testCheckForUnauthorized('/foo/', {}, null, false); + testCheckForUnauthorized('/login/', {}, '/blah', false); +}); \ No newline at end of file From e5a9d187e5c2fe729141f34426ac7292be197e72 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 1 Aug 2023 17:01:17 -0400 Subject: [PATCH 31/84] prevent login flicker --- html/js/app.test.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/html/js/app.test.js b/html/js/app.test.js index 57f470823..8f5cff19a 100644 --- a/html/js/app.test.js +++ b/html/js/app.test.js @@ -408,18 +408,6 @@ test('isIPv6', () => { expect(app.isIPv6('::')).toBe(true); }); - // if (response) { - // const redirectCookie = this.getCookie('AUTH_REDIRECT'); - // if ((response.headers && response.headers['content-type'] == "text/html") || - // (response.status == 401 && !response.request.responseURL.indexOf('/api/')) || - // (response.request.responseURL.indexOf("/login/") == -1 && redirectCookie != null && redirectCookie.length > 0)) { - // this.deleteCookie('AUTH_REDIRECT'); - // this.showLogin(); - // return null - // } - // } - // return response; - function testCheckForUnauthorized(url, response, authRedirectCookie, unauthorized) { app.showLogin = jest.fn(); app.getCookie = jest.fn(cookie => authRedirectCookie); From 683add3ccfef2582ab4e381a8e37e2297a2067a6 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Thu, 3 Aug 2023 12:22:43 -0400 Subject: [PATCH 32/84] list settings with blank strings will store as empty lists --- server/modules/salt/saltstore.go | 5 +++- server/modules/salt/saltstore_test.go | 37 ++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/server/modules/salt/saltstore.go b/server/modules/salt/saltstore.go index 3ee67020c..9f81dbd17 100644 --- a/server/modules/salt/saltstore.go +++ b/server/modules/salt/saltstore.go @@ -833,7 +833,10 @@ func (store *Saltstore) forceType(newValue string, forcedType string) (interface case "[]float": return store.alignFloat64List(newValue) case "[]string": - return strings.Split(newValue, "\n"), nil + if len(newValue) > 0 { + return strings.Split(newValue, "\n"), nil + } + return make([]string, 0, 0), nil case "[][]": return store.alignListList(newValue) case "[]{}": diff --git a/server/modules/salt/saltstore_test.go b/server/modules/salt/saltstore_test.go index 8f069c105..a4a9ca216 100644 --- a/server/modules/salt/saltstore_test.go +++ b/server/modules/salt/saltstore_test.go @@ -996,6 +996,41 @@ func TestUpdateSetting_AlignNonStringListType(tester *testing.T) { assert.Equal(tester, "123\n456\n", updated_setting.Value) } +func TestUpdateSetting_AlignBlankStringListType(tester *testing.T) { + defer Cleanup() + salt := NewTestSalt() + + // default should be an empty list + settings, get_err := salt.GetSettings(ctx()) + assert.NoError(tester, get_err) + updated_setting := findSetting(settings, "myapp.empty_lists.list_str", "") + assert.Equal(tester, "", updated_setting.Value) + + // Update empty setting with non-blank value + setting := model.NewSetting("myapp.empty_lists.list_str") + setting.Value = "foo" + err := salt.UpdateSetting(ctx(), setting, false) + assert.NoError(tester, err) + + // should now contain non-blank value + settings, get_err = salt.GetSettings(ctx()) + assert.NoError(tester, get_err) + updated_setting = findSetting(settings, "myapp.empty_lists.list_str", "") + assert.Equal(tester, "foo\n", updated_setting.Value) + + // Update empty setting with empty lines value + setting = model.NewSetting("myapp.empty_lists.list_str") + setting.Value = "\n" + err = salt.UpdateSetting(ctx(), setting, false) + assert.NoError(tester, err) + + // should be an empty list again + settings, get_err = salt.GetSettings(ctx()) + assert.NoError(tester, get_err) + updated_setting = findSetting(settings, "myapp.empty_lists.list_str", "") + assert.Equal(tester, "", updated_setting.Value) +} + func TestRelPathFromId(tester *testing.T) { defer Cleanup() salt := NewTestSalt() @@ -1185,7 +1220,7 @@ func TestForceType(tester *testing.T) { {value: "true\nfalse", forcedType: "[]bool", expected: []bool{true, false}, errorString: ""}, {value: "blah", forcedType: "[]bool", expected: []bool{}, errorString: "invalid syntax"}, {value: "hello", forcedType: "string", expected: "hello", errorString: ""}, - {value: "hello", forcedType: "[]string", expected: []string{"hello"}, errorString: ""}, + {value: "", forcedType: "[]string", expected: []string{}, errorString: ""}, {value: "hello\nthere", forcedType: "[]string", expected: []string{"hello", "there"}, errorString: ""}, {value: "blah", forcedType: "[]string", expected: []string{"blah"}, errorString: ""}, {value: "[\"hello\"]", forcedType: "[][]", expected: [][]interface{}([][]interface{}{[]interface{}{"hello"}}), errorString: ""}, From 519ee2f14eb20d5682a4c52a8498dda27a9fbe0b Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Thu, 3 Aug 2023 12:30:01 -0400 Subject: [PATCH 33/84] fix raid status --- html/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/html/index.html b/html/index.html index 998720d60..cd63039ec 100644 --- a/html/index.html +++ b/html/index.html @@ -3069,13 +3069,13 @@

{{ i18n.gridEps }} {{ gridEps | formatCount }}

{{ i18n.nodeStatusRaid }} - + {{ i18n.featureRequiresAppliance }} fa-circle-question - +
From 2a946993a5f3cf69af000b3e66afa2602bce628c Mon Sep 17 00:00:00 2001 From: Doug Burks Date: Tue, 8 Aug 2023 20:11:04 -0400 Subject: [PATCH 34/84] FIX: Improve wording for Firewall entries under Grid Administration Quick Links Security-Onion-Solutions/securityonion#10990 --- html/js/i18n.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/html/js/i18n.js b/html/js/i18n.js index 9b0cc1129..a87b5a627 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -168,13 +168,13 @@ const i18n = { configQLNTP: 'Specify custom Network Time Protocol server(s)', configQLNTPHeader: 'NTP', configQLFirewallHeader: 'Firewall', - configQLFirewallWebInterface: 'Allow access to SO Web Interface', - configQLFirewallElastic: 'Allow access to Elastic Agent endpoints', - configQLFirewallFleet: 'Allow access to Elastic Fleet Nodes', - configQLFirewallIDH: 'Allow access to IDH Nodes', - configQLFirewallReceiver: 'Allow access to Receiver Nodes', - configQLFirewallSearch: 'Allow access to Search Nodes', - configQLFirewallSensor: 'Allow access to Sensors (Forward Nodes)', + configQLFirewallWebInterface: 'Allow web browsers to login to Security Onion Console', + configQLFirewallElastic: 'Allow Elastic Agent endpoints to send logs', + configQLFirewallFleet: 'Allow Elastic Fleet Nodes to connect to Manager', + configQLFirewallIDH: 'Allow IDH Nodes to connect to Manager', + configQLFirewallReceiver: 'Allow Receiver Nodes to connect to Manager', + configQLFirewallSearch: 'Allow Search Nodes to connect to Manager', + configQLFirewallSensor: 'Allow Sensors (Forward Nodes) to connect to Manager', configQLNIDSHeader: 'NIDS', configQLNIDSRuleset: 'Change NIDS Ruleset', configQLNIDSOinkCode: 'Paid NIDS Rulesets Registration Code (oinkcode)', From 69d402fcf68ac9221dc40157911dc43c3a2c37cd Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Mon, 21 Aug 2023 15:00:43 -0400 Subject: [PATCH 35/84] ensure all features show in license pillar if none explicitly specified --- licensing/license_manager.go | 23 +++++++++++++++++------ licensing/license_manager_test.go | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/licensing/license_manager.go b/licensing/license_manager.go index 215b1f397..b7b7cf1b4 100644 --- a/licensing/license_manager.go +++ b/licensing/license_manager.go @@ -160,11 +160,16 @@ func verify(key string) (*LicenseKey, error) { return license, rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], sigBytes) } -func Init(key string) { +func CreateAvailableFeatureList() []string { available := make([]string, 0, 0) available = append(available, FEAT_FIPS) available = append(available, FEAT_STIG) available = append(available, FEAT_TIMETRACKING) + return available +} + +func Init(key string) { + available := CreateAvailableFeatureList() status := LICENSE_STATUS_UNPROVISIONED licenseKey := &LicenseKey{} @@ -194,11 +199,17 @@ func Init(key string) { } func Test(feat string, users int, nodes int, socUrl string, dataUrl string) { - available := make([]string, 0, 0) - available = append(available, feat) + available := CreateAvailableFeatureList() + licenseKey := &LicenseKey{} licenseKey.Expiration = time.Now().Add(time.Minute * 1) - licenseKey.Features = available + + if len(feat) > 0 { + features := make([]string, 0, 0) + features = append(features, feat) + licenseKey.Features = features + } + licenseKey.Users = users licenseKey.Nodes = nodes licenseKey.SocUrl = socUrl @@ -314,9 +325,9 @@ func startPillarMonitor() { # This file is generated by Security Onion and contains a list of license-enabled features. ` - if manager.status == LICENSE_STATUS_ACTIVE && len(manager.licenseKey.Features) > 0 { + if manager.status == LICENSE_STATUS_ACTIVE { contents += "features:\n" - for _, feature := range manager.licenseKey.Features { + for _, feature := range ListEnabledFeatures() { contents += "- " + feature + "\n" } } else { diff --git a/licensing/license_manager_test.go b/licensing/license_manager_test.go index 4c7cc3b6b..9e2b6ed5e 100644 --- a/licensing/license_manager_test.go +++ b/licensing/license_manager_test.go @@ -221,6 +221,24 @@ func TestPillarMonitor(tester *testing.T) { assert.Equal(tester, manager.status, LICENSE_STATUS_ACTIVE) contents, _ := os.ReadFile(manager.pillarFilename) + expected := ` +features: +- stig +` + + assert.Contains(tester, string(contents), expected) +} + +func TestPillarMonitorAllFeatures(tester *testing.T) { + Test("", 0, 0, "", "") + + manager.pillarFilename = "/tmp/soc_test_pillar_monitor.sls" + + os.Remove(manager.pillarFilename) + startPillarMonitor() + assert.Equal(tester, manager.status, LICENSE_STATUS_ACTIVE) + contents, _ := os.ReadFile(manager.pillarFilename) + expected := ` # Copyright Jason Ertel (github.com/jertel). # Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one @@ -236,7 +254,9 @@ func TestPillarMonitor(tester *testing.T) { # This file is generated by Security Onion and contains a list of license-enabled features. features: +- fips - stig +- timetracking ` assert.Equal(tester, expected, string(contents)) From 70ad1743a01190ddf469711105760309abb3c7bb Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Wed, 30 Aug 2023 11:02:53 -0600 Subject: [PATCH 36/84] Salt Options The salt relay has a default timeout of 30s. This is not long enough to process the default file size limit of pcap/evtx imports (25MB). Instead of enlarging the timeout for all salt operations, salt now respects a variable on the passed in context. If that variable holds a longer timeout than the currently configured one, the new timeout is used instead. Sending files and importing files now request a 2 minute timeout. --- server/gridmembershandler.go | 7 +++-- server/modules/salt/options/context.go | 29 +++++++++++++++++++++ server/modules/salt/options/context_test.go | 24 +++++++++++++++++ server/modules/salt/saltstore.go | 10 ++++++- 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 server/modules/salt/options/context.go create mode 100644 server/modules/salt/options/context_test.go diff --git a/server/gridmembershandler.go b/server/gridmembershandler.go index d194e7847..578dea455 100644 --- a/server/gridmembershandler.go +++ b/server/gridmembershandler.go @@ -19,6 +19,7 @@ import ( "unicode" "github.com/security-onion-solutions/securityonion-soc/model" + "github.com/security-onion-solutions/securityonion-soc/server/modules/salt/options" "github.com/security-onion-solutions/securityonion-soc/web" "github.com/go-chi/chi" @@ -218,7 +219,9 @@ func (h *GridMembersHandler) postImport(w http.ResponseWriter, r *http.Request) baseTargetDir += "/" } - err = h.server.GridMembersstore.SendFile(ctx, id, targetFile, baseTargetDir, true) + ctxTimeout := options.WithTimeoutMs(ctx, 120000) + + err = h.server.GridMembersstore.SendFile(ctxTimeout, id, targetFile, baseTargetDir, true) if err != nil { needsCleanup = true web.Respond(nil, r, http.StatusInternalServerError, err) @@ -228,7 +231,7 @@ func (h *GridMembersHandler) postImport(w http.ResponseWriter, r *http.Request) targetFile = filepath.Join(baseTargetDir, filename) - dashboardURL, err := h.server.GridMembersstore.Import(ctx, id, targetFile, ext) + dashboardURL, err := h.server.GridMembersstore.Import(ctxTimeout, id, targetFile, ext) if err != nil { web.Respond(nil, r, http.StatusInternalServerError, err) return diff --git a/server/modules/salt/options/context.go b/server/modules/salt/options/context.go new file mode 100644 index 000000000..8edadb074 --- /dev/null +++ b/server/modules/salt/options/context.go @@ -0,0 +1,29 @@ +package options + +import "context" + +type ContextKey string + +const ( + ContextKeySaltExecTimeoutMs ContextKey = "timeoutMs" +) + +func WithTimeoutMs(ctx context.Context, timeoutMs int) context.Context { + if ctx == nil { + ctx = context.Background() + } + + return context.WithValue(ctx, ContextKeySaltExecTimeoutMs, timeoutMs) +} + +func GetTimeoutMs(ctx context.Context) int { + if ctx == nil { + return 0 + } + + if timeoutMs, ok := ctx.Value(ContextKeySaltExecTimeoutMs).(int); ok { + return timeoutMs + } + + return 0 +} diff --git a/server/modules/salt/options/context_test.go b/server/modules/salt/options/context_test.go new file mode 100644 index 000000000..8ad8eab18 --- /dev/null +++ b/server/modules/salt/options/context_test.go @@ -0,0 +1,24 @@ +package options + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +// nolint: staticcheck // test file +func TestTimeout(t *testing.T) { + ctx := WithTimeoutMs(nil, 100) + assert.NotNil(t, ctx) + + timeout := GetTimeoutMs(ctx) + assert.Equal(t, 100, timeout) + + timeout = GetTimeoutMs(nil) + assert.Equal(t, 0, timeout) + + bg := context.Background() + timeout = GetTimeoutMs(bg) + assert.Equal(t, 0, timeout) +} diff --git a/server/modules/salt/saltstore.go b/server/modules/salt/saltstore.go index 9f81dbd17..5d36b50ed 100644 --- a/server/modules/salt/saltstore.go +++ b/server/modules/salt/saltstore.go @@ -21,6 +21,7 @@ import ( "github.com/security-onion-solutions/securityonion-soc/json" "github.com/security-onion-solutions/securityonion-soc/model" "github.com/security-onion-solutions/securityonion-soc/server" + "github.com/security-onion-solutions/securityonion-soc/server/modules/salt/options" "github.com/security-onion-solutions/securityonion-soc/syntax" "github.com/security-onion-solutions/securityonion-soc/web" "gopkg.in/yaml.v3" @@ -75,8 +76,15 @@ func (store *Saltstore) execCommand(ctx context.Context, args map[string]string) "timeoutMs": store.timeoutMs, }).Info("Waiting for response") + timeoutMs := store.timeoutMs + optTimeoutMs := options.GetTimeoutMs(ctx) + + if optTimeoutMs > timeoutMs { + timeoutMs = optTimeoutMs + } + var response string - expiration := time.Duration(store.timeoutMs) * time.Millisecond + expiration := time.Duration(timeoutMs) * time.Millisecond for timeoutTime := time.Now().Add(expiration); time.Now().Before(timeoutTime); { if _, err = os.Stat(responseFilename); err == nil { var data []byte From cdbf2c3acf71a9d6bc019454d0a2023d4820d258 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Wed, 30 Aug 2023 15:36:35 -0600 Subject: [PATCH 37/84] Make configurable The longer timeout is now configurable. If the long timeout is set to be shorter than the normal timeout, the normal timeout is used instead. Also, the option is applied inside of the `SendFile` and `Import` methods so every sent file and import benefits from the new timeout instead of just the calls in gridmembershandler.go. --- server/gridmembershandler.go | 7 ++----- server/modules/salt/salt.go | 4 +++- server/modules/salt/saltstore.go | 24 +++++++++++++++--------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/server/gridmembershandler.go b/server/gridmembershandler.go index 578dea455..d194e7847 100644 --- a/server/gridmembershandler.go +++ b/server/gridmembershandler.go @@ -19,7 +19,6 @@ import ( "unicode" "github.com/security-onion-solutions/securityonion-soc/model" - "github.com/security-onion-solutions/securityonion-soc/server/modules/salt/options" "github.com/security-onion-solutions/securityonion-soc/web" "github.com/go-chi/chi" @@ -219,9 +218,7 @@ func (h *GridMembersHandler) postImport(w http.ResponseWriter, r *http.Request) baseTargetDir += "/" } - ctxTimeout := options.WithTimeoutMs(ctx, 120000) - - err = h.server.GridMembersstore.SendFile(ctxTimeout, id, targetFile, baseTargetDir, true) + err = h.server.GridMembersstore.SendFile(ctx, id, targetFile, baseTargetDir, true) if err != nil { needsCleanup = true web.Respond(nil, r, http.StatusInternalServerError, err) @@ -231,7 +228,7 @@ func (h *GridMembersHandler) postImport(w http.ResponseWriter, r *http.Request) targetFile = filepath.Join(baseTargetDir, filename) - dashboardURL, err := h.server.GridMembersstore.Import(ctxTimeout, id, targetFile, ext) + dashboardURL, err := h.server.GridMembersstore.Import(ctx, id, targetFile, ext) if err != nil { web.Respond(nil, r, http.StatusInternalServerError, err) return diff --git a/server/modules/salt/salt.go b/server/modules/salt/salt.go index bbf038e4c..ea5152526 100644 --- a/server/modules/salt/salt.go +++ b/server/modules/salt/salt.go @@ -12,6 +12,7 @@ import ( ) const DEFAULT_TIMEOUT_MS = 30000 +const LONG_TIMEOUT_MS = 120000 const DEFAULT_SALTSTACK_DIR = "/opt/so/saltstack" const DEFAULT_QUEUE_DIR = "/opt/so/conf/soc/queue" const DEFAULT_BYPASS_ERRORS = false @@ -36,10 +37,11 @@ func (mod *Salt) PrerequisiteModules() []string { func (mod *Salt) Init(cfg module.ModuleConfig) error { mod.config = cfg timeoutMs := module.GetIntDefault(cfg, "timeoutMs", DEFAULT_TIMEOUT_MS) + longRelayTimeoutMs := module.GetIntDefault(cfg, "longRelayTimeoutMs", LONG_TIMEOUT_MS) saltstackDir := module.GetStringDefault(cfg, "saltstackDir", DEFAULT_SALTSTACK_DIR) queueDir := module.GetStringDefault(cfg, "queueDir", DEFAULT_QUEUE_DIR) bypassErrors := module.GetBoolDefault(cfg, "bypassErrors", DEFAULT_BYPASS_ERRORS) - err := mod.impl.Init(timeoutMs, saltstackDir, queueDir, bypassErrors) + err := mod.impl.Init(timeoutMs, longRelayTimeoutMs, saltstackDir, queueDir, bypassErrors) if err == nil { mod.server.Configstore = mod.impl mod.server.GridMembersstore = mod.impl diff --git a/server/modules/salt/saltstore.go b/server/modules/salt/saltstore.go index 5d36b50ed..35fd437da 100644 --- a/server/modules/salt/saltstore.go +++ b/server/modules/salt/saltstore.go @@ -28,12 +28,13 @@ import ( ) type Saltstore struct { - server *server.Server - client *web.Client - timeoutMs int - saltstackDir string - queueDir string - bypassErrors bool + server *server.Server + client *web.Client + timeoutMs int + longRelayTimeoutMs int + saltstackDir string + queueDir string + bypassErrors bool } func NewSaltstore(server *server.Server) *Saltstore { @@ -42,8 +43,9 @@ func NewSaltstore(server *server.Server) *Saltstore { } } -func (store *Saltstore) Init(timeoutMs int, saltstackDir string, queueDir string, bypassErrors bool) error { +func (store *Saltstore) Init(timeoutMs int, longRelayTimeoutMs int, saltstackDir string, queueDir string, bypassErrors bool) error { store.timeoutMs = timeoutMs + store.longRelayTimeoutMs = longRelayTimeoutMs store.saltstackDir = strings.TrimSuffix(saltstackDir, "/") store.queueDir = queueDir store.bypassErrors = bypassErrors @@ -1011,7 +1013,9 @@ func (store *Saltstore) SendFile(ctx context.Context, node string, from string, "cleanup": strconv.FormatBool(cleanup), } - output, err := store.execCommand(ctx, args) + ctxTimeout := options.WithTimeoutMs(ctx, store.longRelayTimeoutMs) + + output, err := store.execCommand(ctxTimeout, args) if err == nil && output == "false" { err = errors.New("ERROR_SALT_SEND_FILE") } @@ -1031,7 +1035,9 @@ func (store *Saltstore) Import(ctx context.Context, node string, file string, im "importer": importer, } - output, err := store.execCommand(ctx, args) + ctxTimeout := options.WithTimeoutMs(ctx, store.longRelayTimeoutMs) + + output, err := store.execCommand(ctxTimeout, args) if err == nil && output == "false" { err = errors.New("ERROR_SALT_IMPORT") return nil, err From 3879c10d44cce97554e5b93885506e5a7b2e9d59 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Thu, 31 Aug 2023 09:09:00 -0600 Subject: [PATCH 38/84] Update tests SaltStore.Init received a new parameter and the tests now pass it in. --- server/modules/salt/saltstore_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/modules/salt/saltstore_test.go b/server/modules/salt/saltstore_test.go index a4a9ca216..b47d9e0b0 100644 --- a/server/modules/salt/saltstore_test.go +++ b/server/modules/salt/saltstore_test.go @@ -38,7 +38,7 @@ func NewTestSalt() *Saltstore { srv := server.NewFakeAuthorizedServer(nil) salt := NewSaltstore(srv) - salt.Init(123, TMP_SALTSTACK_PATH+"/saltstack", TMP_QUEUE_DIR, false) + salt.Init(123, 123, TMP_SALTSTACK_PATH+"/saltstack", TMP_QUEUE_DIR, false) return salt } @@ -48,7 +48,7 @@ func NewTestSaltRelayQueue(tester *testing.T, id string, mockedResponse string) exec.Command("mkdir", "-p", TMP_QUEUE_DIR).Run() srv := server.NewFakeAuthorizedServer(nil) salt := NewSaltstore(srv) - salt.Init(10, TMP_SALTSTACK_PATH+"/saltstack", TMP_QUEUE_DIR, false) + salt.Init(10, 10, TMP_SALTSTACK_PATH+"/saltstack", TMP_QUEUE_DIR, false) filename := filepath.Join(TMP_QUEUE_DIR, id+".response") responseData, err := os.ReadFile("test_resources/queue/" + mockedResponse) @@ -68,7 +68,7 @@ func ReadRequest(tester *testing.T, filename string) string { func TestSaltstoreInit(tester *testing.T) { salt := NewSaltstore(nil) - salt.Init(123, "saltstack/path", "salt/control", false) + salt.Init(123, 123, "saltstack/path", "salt/control", false) assert.Equal(tester, 123, salt.timeoutMs) assert.Equal(tester, "saltstack/path", salt.saltstackDir) assert.Equal(tester, "salt/control", salt.queueDir) @@ -109,7 +109,7 @@ func ctx() context.Context { func TestGetMembers_BadQueueDir(tester *testing.T) { srv := server.NewFakeAuthorizedServer(nil) salt := NewSaltstore(srv) - salt.Init(123, TMP_SALTSTACK_PATH+"/saltstack", "/invalid/path", false) + salt.Init(123, 123, TMP_SALTSTACK_PATH+"/saltstack", "/invalid/path", false) _, err := salt.GetMembers(ctx()) assert.ErrorContains(tester, err, "no such file or directory") } @@ -156,7 +156,7 @@ func TestManageMemberUnauthorized(tester *testing.T) { func TestManageMember_BadQueuePath(tester *testing.T) { srv := server.NewFakeAuthorizedServer(nil) salt := NewSaltstore(srv) - salt.Init(123, TMP_SALTSTACK_PATH+"/saltstack", "invalid/path", false) + salt.Init(123, 123, TMP_SALTSTACK_PATH+"/saltstack", "invalid/path", false) for _, op := range []string{"add", "reject", "delete"} { err := salt.ManageMember(ctx(), op, "foo") From fe58a89f7afe6ff03864ca80e43047469fc7f8df Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 12 Sep 2023 13:34:25 -0400 Subject: [PATCH 39/84] desktop node support --- html/index.html | 20 +++++++++++--------- html/js/i18n.js | 3 +++ html/js/routes/grid.js | 4 ++-- html/js/routes/grid.test.js | 4 +++- model/node.go | 3 +++ server/modules/sostatus/sostatus.go | 2 +- server/modules/sostatus/sostatus_test.go | 11 ++++++++++- 7 files changed, 33 insertions(+), 14 deletions(-) diff --git a/html/index.html b/html/index.html index cd63039ec..6c59c25dc 100644 --- a/html/index.html +++ b/html/index.html @@ -2987,7 +2987,7 @@

{{ i18n.gridEps }} {{ gridEps | formatCount }}

- {{ props.item.status | iconNodeStatus }} + {{ props.item.status | iconNodeStatus }} @@ -2996,13 +2996,13 @@

{{ i18n.gridEps }} {{ gridEps | formatCount }}

- {{ props.item.productionEps | formatCount }} / {{ props.item.consumptionEps | formatCount }} + {{ props.item.consumptionEps | formatCount }} {{ i18n.na }} {{ props.item.updateTime | toTZ }} {{ props.item.epochTime | toTZ }} {{ props.item.uptimeSeconds | formatDuration }} - {{ $root.localizeMessage(props.item.status) }} + {{ $root.localizeMessage(props.item.status) }}