From 38145e873f7805748c63a4d7d2ef06bbedc0ee15 Mon Sep 17 00:00:00 2001 From: Richard Boisvert Date: Thu, 21 Nov 2024 14:15:36 -0500 Subject: [PATCH] feat: DEVOPS-3353 add entry Website --- .github/workflows/test.yml | 1 + README.md | 2 +- VERSION | 2 +- authentication.go | 1 + dvlstypes.go | 10 +- entries.go | 3 +- entry_website.go | 246 +++++++++++++++++++++++++++++++++++++ entry_website_test.go | 75 +++++++++++ 8 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 entry_website.go create mode 100644 entry_website_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1126bf8..60f6203 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,5 +77,6 @@ jobs: TEST_CERTIFICATE_ENTRY_ID: ${{ secrets.TEST_CERTIFICATE_ENTRY_ID }} TEST_CERTIFICATE_FILE_PATH: '${{ runner.temp }}/test.p12' TEST_VAULT_ID: ${{ secrets.TEST_VAULT_ID }} + TEST_WEBSITE_ENTRY_ID: ${{ secrets.TEST_WEBSITE_ENTRY_ID }} with: github_token: ${{ secrets.DEVOLUTIONSBOT_TOKEN }} diff --git a/README.md b/README.md index a8602de..4eb12bb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Heavily based on the information found on the [Devolutions.Server](https://githu ## Usage - Run go get `go get github.com/Devolutions/go-dvls` - Add the import `import "github.com/Devolutions/go-dvls"` -- Setup the client (we recommend using an [Application ID](https://helpserver.devolutions.net/webinterface_applications.html?q=application+id)) +- Setup the client (we recommend using an [Application ID](https://docs.devolutions.net/server/web-interface/administration/security-management/applications/)) ``` go package main diff --git a/VERSION b/VERSION index a3df0a6..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.9.0 diff --git a/authentication.go b/authentication.go index 2187a97..d73e159 100644 --- a/authentication.go +++ b/authentication.go @@ -115,6 +115,7 @@ func NewClient(username string, password string, baseUri string) (Client, error) client.Entries = &Entries{ UserCredential: (*EntryUserCredentialService)(&client.common), Certificate: (*EntryCertificateService)(&client.common), + Website: (*EntryWebsiteService)(&client.common), } client.Vaults = (*Vaults)(&client.common) diff --git a/dvlstypes.go b/dvlstypes.go index 9aecbf6..ff875a1 100644 --- a/dvlstypes.go +++ b/dvlstypes.go @@ -216,8 +216,14 @@ const ( type ServerConnectionSubType string const ( - ServerConnectionSubTypeDefault ServerConnectionSubType = "Default" - ServerConnectionSubTypeCertificate ServerConnectionSubType = "Certificate" + ServerConnectionSubTypeDefault ServerConnectionSubType = "Default" + ServerConnectionSubTypeCertificate ServerConnectionSubType = "Certificate" + ServerConnectionSubTypeMicrosoftEdge ServerConnectionSubType = "Edge" + ServerConnectionSubTypeFirefox ServerConnectionSubType = "FireFox" + ServerConnectionSubTypeGoogleChrome ServerConnectionSubType = "GoogleChrome" + ServerConnectionSubTypeInternetExplorer ServerConnectionSubType = "IE" + ServerConnectionSubTypeOpera ServerConnectionSubType = "Opera" + ServerConnectionSubTypeAppleSafari ServerConnectionSubType = "Safari" ) type VaultVisibility int diff --git a/entries.go b/entries.go index 9825e4c..758eceb 100644 --- a/entries.go +++ b/entries.go @@ -11,8 +11,9 @@ const ( ) type Entries struct { - UserCredential *EntryUserCredentialService Certificate *EntryCertificateService + UserCredential *EntryUserCredentialService + Website *EntryWebsiteService } func keywordsToSlice(kw string) []string { diff --git a/entry_website.go b/entry_website.go new file mode 100644 index 0000000..0bc5954 --- /dev/null +++ b/entry_website.go @@ -0,0 +1,246 @@ +package dvls + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type EntryWebsiteService service + +// EntryWebsite represents a website entry in DVLS +type EntryWebsite struct { + ID string `json:"id,omitempty"` + VaultId string `json:"repositoryId"` + EntryName string `json:"name"` + Description string `json:"description"` + EntryFolderPath string `json:"group"` + ModifiedDate *ServerTime `json:"modifiedDate,omitempty"` + ConnectionType ServerConnectionType `json:"connectionType"` + ConnectionSubType ServerConnectionSubType `json:"connectionSubType"` + Tags []string `json:"keywords,omitempty"` + + WebsiteDetails EntryWebsiteAuthDetails `json:"data"` +} + +// MarshalJSON implements the json.Marshaler interface. +func (e EntryWebsite) MarshalJSON() ([]byte, error) { + raw := struct { + ID string `json:"id,omitempty"` + RepositoryId string `json:"repositoryId"` + Name string `json:"name"` + Description string `json:"description"` + Events struct { + OpenCommentPrompt bool `json:"openCommentPrompt"` + CredentialViewedPrompt bool `json:"credentialViewedPrompt"` + TicketNumberIsRequiredOnCredentialViewed bool `json:"ticketNumberIsRequiredOnCredentialViewed"` + TicketNumberIsRequiredOnClose bool `json:"ticketNumberIsRequiredOnClose"` + CredentialViewedCommentIsRequired bool `json:"credentialViewedCommentIsRequired"` + TicketNumberIsRequiredOnOpen bool `json:"ticketNumberIsRequiredOnOpen"` + CloseCommentIsRequired bool `json:"closeCommentIsRequired"` + OpenCommentPromptOnBrowserExtensionLink bool `json:"openCommentPromptOnBrowserExtensionLink"` + CloseCommentPrompt bool `json:"closeCommentPrompt"` + OpenCommentIsRequired bool `json:"openCommentIsRequired"` + WarnIfAlreadyOpened bool `json:"warnIfAlreadyOpened"` + } `json:"events"` + Data string `json:"data"` + Expiration string `json:"expiration"` + CheckOutMode int `json:"checkOutMode"` + Group string `json:"group"` + ConnectionType ServerConnectionType `json:"connectionType"` + ConnectionSubType ServerConnectionSubType `json:"connectionSubType"` + Keywords string `json:"keywords"` + }{} + + raw.ID = e.ID + raw.Keywords = sliceToKeywords(e.Tags) + raw.Description = e.Description + raw.RepositoryId = e.VaultId + raw.Group = e.EntryFolderPath + raw.ConnectionSubType = e.ConnectionSubType + raw.ConnectionType = e.ConnectionType + raw.Name = e.EntryName + sensitiveJson, err := json.Marshal(e.WebsiteDetails) + if err != nil { + return nil, fmt.Errorf("failed to marshal sensitive data. error: %w", err) + } + + raw.Data = string(sensitiveJson) + + entryJson, err := json.Marshal(raw) + if err != nil { + return nil, err + } + + return entryJson, nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (e *EntryWebsite) UnmarshalJSON(d []byte) error { + raw := struct { + ID string `json:"id"` + Description string `json:"description"` + Name string `json:"name"` + Group string `json:"group"` + ModifiedDate *ServerTime `json:"modifiedDate"` + Keywords string `json:"keywords"` + RepositoryId string `json:"repositoryId"` + ConnectionType ServerConnectionType `json:"connectionType"` + ConnectionSubType ServerConnectionSubType `json:"connectionSubType"` + Data json.RawMessage `json:"data"` + }{} + + err := json.Unmarshal(d, &raw) + if err != nil { + return err + } + + e.ID = raw.ID + e.EntryName = raw.Name + e.ConnectionType = raw.ConnectionType + e.ConnectionSubType = raw.ConnectionSubType + e.ModifiedDate = raw.ModifiedDate + e.Description = raw.Description + e.EntryFolderPath = raw.Group + e.VaultId = raw.RepositoryId + e.Tags = keywordsToSlice(raw.Keywords) + + if len(raw.Data) > 0 { + if err := json.Unmarshal(raw.Data, &e.WebsiteDetails); err != nil { + return fmt.Errorf("failed to unmarshal website details: %w", err) + } + } + + return nil +} + +// EntryWebsiteAuthDetails represents website-specific fields +type EntryWebsiteAuthDetails struct { + Username string + Password *string + URL string + WebBrowserApplication int +} + +// MarshalJSON implements the json.Marshaler interface. +func (s EntryWebsiteAuthDetails) MarshalJSON() ([]byte, error) { + raw := struct { + AutoFillLogin bool `json:"AutoFillLogin"` + AutoSubmit bool `json:"AutoSubmit"` + AutomaticRefreshTime int `json:"AutomaticRefreshTime"` + ChromeProxyType int `json:"ChromeProxyType"` + CustomJavaScript string `json:"CustomJavaScript"` + Host string `json:"Host"` + URL string `json:"URL"` + Username string `json:"Username"` + WebBrowserApplication int `json:"WebBrowserApplication"` + PasswordItem struct { + HasSensitiveData bool `json:"HasSensitiveData"` + SensitiveData string `json:"SensitiveData"` + } `json:"PasswordItem"` + VPN struct { + EnableAutoDetectIsOnlineVPN int `json:"EnableAutoDetectIsOnlineVPN"` + } `json:"VPN"` + }{} + + if s.Password != nil { + raw.PasswordItem.HasSensitiveData = true + raw.PasswordItem.SensitiveData = *s.Password + } else { + raw.PasswordItem.HasSensitiveData = false + } + + raw.Username = s.Username + raw.URL = s.URL + raw.WebBrowserApplication = s.WebBrowserApplication + + secretJson, err := json.Marshal(raw) + if err != nil { + return nil, err + } + + return secretJson, nil +} + +// GetWebsiteDetails returns entry with the entry.WebsiteDetails.Password field. +func (c *EntryWebsiteService) GetWebsiteDetails(entry EntryWebsite) (EntryWebsite, error) { + var respData struct { + Data string `json:"data"` + } + + reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, entry.ID, "/sensitive-data") + if err != nil { + return EntryWebsite{}, fmt.Errorf("failed to build entry url. error: %w", err) + } + + resp, err := c.client.Request(reqUrl, http.MethodPost, nil) + if err != nil { + return EntryWebsite{}, fmt.Errorf("error while fetching sensitive data. error: %w", err) + } else if err = resp.CheckRespSaveResult(); err != nil { + return EntryWebsite{}, err + } + + if err := json.Unmarshal(resp.Response, &respData); err != nil { + return EntryWebsite{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + } + + var sensitiveDataResponse struct { + Data struct { + PasswordItem struct { + HasSensitiveData bool `json:"hasSensitiveData"` + SensitiveData *string `json:"sensitiveData,omitempty"` + } `json:"passwordItem"` + } `json:"data"` + } + + if err := json.Unmarshal([]byte(respData.Data), &sensitiveDataResponse); err != nil { + return EntryWebsite{}, fmt.Errorf("failed to unmarshal inner data. error: %w", err) + } + + if sensitiveDataResponse.Data.PasswordItem.HasSensitiveData { + entry.WebsiteDetails.Password = sensitiveDataResponse.Data.PasswordItem.SensitiveData + } else { + entry.WebsiteDetails.Password = nil + } + + return entry, nil +} + +// Get returns a single Entry specified by entryId. Call GetWebsiteDetails with +// the returned Entry to fetch the password. +func (s *EntryWebsiteService) Get(entryId string) (EntryWebsite, error) { + var respData struct { + Data EntryWebsite `json:"data"` + } + + reqUrl, err := url.JoinPath(s.client.baseUri, entryEndpoint, entryId) + if err != nil { + return EntryWebsite{}, fmt.Errorf("failed to build entry url: %w", err) + } + + resp, err := s.client.Request(reqUrl, http.MethodGet, nil) + if err != nil { + return EntryWebsite{}, fmt.Errorf("error fetching entry: %w", err) + } + if err = resp.CheckRespSaveResult(); err != nil { + return EntryWebsite{}, err + } + if resp.Response == nil { + return EntryWebsite{}, fmt.Errorf("response body is nil for request to %s", reqUrl) + } + + if err := json.Unmarshal(resp.Response, &respData); err != nil { + return EntryWebsite{}, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return respData.Data, nil +} + +// NewEntryWebsiteAuthDetails returns an EntryWebsiteAuthDetails with an initialised EntryWebsiteAuthDetails.Password +func (c *EntryWebsiteService) NewWebsiteDetails(username, password string) EntryWebsiteAuthDetails { + return EntryWebsiteAuthDetails{ + Username: username, + Password: &password, + } +} diff --git a/entry_website_test.go b/entry_website_test.go new file mode 100644 index 0000000..789d8d5 --- /dev/null +++ b/entry_website_test.go @@ -0,0 +1,75 @@ +package dvls + +import ( + "os" + "reflect" + "testing" +) + +var ( + testWebsiteEntryId string + testWebsiteEntry EntryWebsite = EntryWebsite{ + Description: "Test website description", + EntryName: "TestWebsite", + ConnectionType: ServerConnectionWebBrowser, + ConnectionSubType: ServerConnectionSubTypeGoogleChrome, + Tags: []string{"Test tag 1", "Test tag 2", "web"}, + } +) + +const ( + testWebsiteUsername string = "testuser" + testWebsiteURL string = "https://test.example.com" + testWebsiteBrowser string = "GoogleChrome" +) + +var testWebsitePassword = "testpass123" + +func Test_EntryWebsite(t *testing.T) { + testWebsiteEntryId = os.Getenv("TEST_WEBSITE_ENTRY_ID") + testWebsiteEntry.ID = testWebsiteEntryId + testWebsiteEntry.VaultId = testVaultId + testWebsiteEntry.WebsiteDetails = EntryWebsiteAuthDetails{ + Username: testWebsiteUsername, + URL: testWebsiteURL, + WebBrowserApplication: 3, + } + testWebsiteEntry.ConnectionSubType = ServerConnectionSubTypeGoogleChrome + + t.Run("GetEntry", test_GetWebsiteEntry) + t.Run("GetEntryWebsite", test_GetWebsiteDetails) +} + +func test_GetWebsiteEntry(t *testing.T) { + entry, err := testClient.Entries.Website.Get(testWebsiteEntry.ID) + if err != nil { + t.Fatal(err) + } + + testWebsiteEntry.ModifiedDate = entry.ModifiedDate + if !reflect.DeepEqual(entry, testWebsiteEntry) { + t.Fatalf("fetched entry did not match test entry. Expected %#v, got %#v", testWebsiteEntry, entry) + } +} + +func test_GetWebsiteDetails(t *testing.T) { + entry, err := testClient.Entries.Website.Get(testWebsiteEntry.ID) + if err != nil { + t.Fatal(err) + } + + entryWithSensitiveData, err := testClient.Entries.Website.GetWebsiteDetails(entry) + if err != nil { + t.Fatal(err) + } + + entry.WebsiteDetails.Password = entryWithSensitiveData.WebsiteDetails.Password + + expectedDetails := testWebsiteEntry.WebsiteDetails + + expectedDetails.Password = &testWebsitePassword + + if !reflect.DeepEqual(expectedDetails, entry.WebsiteDetails) { + t.Fatalf("fetched secret did not match test secret. Expected %#v, got %#v", expectedDetails, entry.WebsiteDetails) + } +}