Files
kubescape/core/cautils/getter/kscloudapi.go
David Wertenteil de8d365919 load account details
Signed-off-by: David Wertenteil <dwertent@armosec.io>
2023-03-28 16:03:31 +03:00

834 lines
21 KiB
Go

package getter
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
const (
// Kubescape API endpoints
// production
ksCloudERURL = "report.armo.cloud" // API reports URL
ksCloudBEURL = "api.armosec.io" // API backend URL
ksCloudFEURL = "cloud.armosec.io" // API frontend (UI) URIL
ksCloudAUTHURL = "auth.armosec.io" // API login URL
// staging
ksCloudStageERURL = "report-ks.eustage2.cyberarmorsoft.com"
ksCloudStageBEURL = "api-stage.armosec.io"
ksCloudStageFEURL = "armoui-stage.armosec.io"
ksCloudStageAUTHURL = "eggauth-stage.armosec.io"
// dev
ksCloudDevERURL = "report.eudev3.cyberarmorsoft.com"
ksCloudDevBEURL = "api-dev.armosec.io"
ksCloudDevFEURL = "cloud-dev.armosec.io"
ksCloudDevAUTHURL = "eggauth-dev.armosec.io"
// Kubescape API routes
pathAttackTracks = "/api/v1/attackTracks"
pathFrameworks = "/api/v1/armoFrameworks"
pathExceptions = "/api/v1/armoPostureExceptions"
pathTenant = "/api/v1/tenants/createTenant"
pathExceptionPolicy = "/api/v1/postureExceptionPolicy"
pathCustomerConfig = "/api/v1/armoCustomerConfiguration"
pathLogin = "/identity/resources/auth/v1/api-token"
pathToken = "/api/v1/openid_customers" //nolint:gosec
// reports upload route
pathReport = "/k8s/v2/postureReport"
// Kubescape UI routes
pathUIScan = "/compliance/%s"
pathUIRBAC = "/rbac-visualizer"
pathUIRepository = "/repository-scanning/%s"
pathUIDashboard = "/dashboard/"
pathUISign = "/account/sign-up"
)
const (
// default dummy GUID when not defined
fallbackGUID = "11111111-1111-1111-1111-111111111111"
// URL query parameters
queryParamGUID = "customerGUID"
queryParamScope = "scope"
queryParamFrameworkName = "frameworkName"
queryParamPolicyName = "policyName"
queryParamClusterName = "clusterName"
queryParamContextName = "contextName"
queryParamUTMSource = "utm_source"
queryParamUTMMedium = "utm_medium"
// queryParamUTMCampaign = "utm_campaign"
queryParamReport = "reportGUID"
queryParamInvitationToken = "invitationToken"
authenticationCookie = "auth"
)
var (
// Errors returned by the API
ErrLoginMissingAccountID = errors.New("failed to login, missing accountID")
ErrLoginMissingClientID = errors.New("failed to login, missing clientID")
ErrLoginMissingSecretKey = errors.New("failed to login, missing secretKey")
ErrAPINotPublic = errors.New("control api is not public")
)
var (
// globalKSCloudAPIConnector is a static global instance of the KS Cloud client,
// to be initialized with SetKSCloudAPIConnector.
globalKSCloudAPIConnector *KSCloudAPI
_ IPolicyGetter = &KSCloudAPI{}
_ IExceptionsGetter = &KSCloudAPI{}
_ IAttackTracksGetter = &KSCloudAPI{}
_ IControlsInputsGetter = &KSCloudAPI{}
)
// KSCloudAPI allows to access the API of the Kubescape Cloud offering.
type KSCloudAPI struct {
authCookie *http.Cookie
*ksCloudOptions
authhost string
cloudAPIURL string
secretKey string
accountID string
cloudAuthURL string
invitationToken string
reporthost string
scheme string
host string
authscheme string
clientID string
uischeme string
uihost string
reportscheme string
feToken feLoginResponse
loggedIn bool
}
// SetKSCloudAPIConnector registers a global instance of the KS Cloud client.
//
// NOTE: cannot be used concurrently.
func SetKSCloudAPIConnector(ksCloudAPI *KSCloudAPI) {
globalKSCloudAPIConnector = ksCloudAPI
}
// GetKSCloudAPIConnector returns a shallow clone of the KS Cloud client registered for this package.
//
// NOTE: cannot be used concurrently with SetKSCloudAPIConnector.
func GetKSCloudAPIConnector() *KSCloudAPI {
if globalKSCloudAPIConnector == nil {
SetKSCloudAPIConnector(NewKSCloudAPIProd())
}
// we return a shallow clone that may be freely modified by the caller.
client := *globalKSCloudAPIConnector
options := *globalKSCloudAPIConnector.ksCloudOptions
client.ksCloudOptions = &options
return &client
}
// NewKSCloudAPIDev returns a KS Cloud client pointing to a development environment.
func NewKSCloudAPIDev(opts ...KSCloudOption) *KSCloudAPI {
devOpts := []KSCloudOption{
WithFrontendURL(ksCloudDevFEURL),
WithReportURL(ksCloudDevERURL),
}
devOpts = append(devOpts, opts...)
apiObj := newKSCloudAPI(
ksCloudDevBEURL,
ksCloudDevAUTHURL,
devOpts...,
)
return apiObj
}
// NewKSCloudAPIDProd returns a KS Cloud client pointing to a production environment.
func NewKSCloudAPIProd(opts ...KSCloudOption) *KSCloudAPI {
prodOpts := []KSCloudOption{
WithFrontendURL(ksCloudFEURL),
WithReportURL(ksCloudERURL),
}
prodOpts = append(prodOpts, opts...)
return newKSCloudAPI(
ksCloudBEURL,
ksCloudAUTHURL,
prodOpts...,
)
}
// NewKSCloudAPIStaging returns a KS Cloud client pointing to a testing environment.
func NewKSCloudAPIStaging(opts ...KSCloudOption) *KSCloudAPI {
stagingOpts := []KSCloudOption{
WithFrontendURL(ksCloudStageFEURL),
WithReportURL(ksCloudStageERURL),
}
stagingOpts = append(stagingOpts, opts...)
return newKSCloudAPI(
ksCloudStageBEURL,
ksCloudStageAUTHURL,
stagingOpts...,
)
}
// NewKSCloudAPICustomed returns a KS Cloud client with configurable API and authentication endpoints.
func NewKSCloudAPICustomized(ksCloudAPIURL, ksCloudAuthURL string, opts ...KSCloudOption) *KSCloudAPI {
return newKSCloudAPI(
ksCloudAPIURL,
ksCloudAuthURL,
opts...,
)
}
func newKSCloudAPI(apiURL, authURL string, opts ...KSCloudOption) *KSCloudAPI {
api := &KSCloudAPI{
cloudAPIURL: apiURL,
cloudAuthURL: authURL,
ksCloudOptions: ksCloudOptionsWithDefaults(opts),
}
api.SetCloudAPIURL(apiURL)
api.SetCloudAuthURL(authURL)
api.SetCloudUIURL(api.cloudUIURL)
api.SetCloudReportURL(api.cloudReportURL)
return api
}
// Get retrieves an API resource.
//
// The response is serialized as a string.
//
// The caller may specify extra headers.
//
// By default, all authentication headers are added.
func (api *KSCloudAPI) Get(fullURL string, headers map[string]string) (string, error) {
rdr, size, err := api.get(fullURL, withExtraHeaders(headers))
if err != nil {
return "", err
}
defer rdr.Close()
return readString(rdr, size)
}
// Post creates an API resource.
//
// The response is serialized as a string.
//
// The caller may specify extra headers.
//
// By default, the body content type is set to JSON and all authentication headers are added.
func (api *KSCloudAPI) Post(fullURL string, headers map[string]string, body []byte) (string, error) {
rdr, size, err := api.post(fullURL, body, withContentJSON(true), withExtraHeaders(headers))
if err != nil {
return "", err
}
defer rdr.Close()
return readString(rdr, size)
}
// Delete an API resource.
//
// The response is serialized as a string.
//
// The caller may specify extra headers.
//
// By default, all authentication headers are added.
func (api *KSCloudAPI) Delete(fullURL string, headers map[string]string) (string, error) {
rdr, size, err := api.delete(fullURL, withExtraHeaders(headers))
if err != nil {
return "", err
}
defer rdr.Close()
return readString(rdr, size)
}
// GetAccountID returns the customer account's GUID.
func (api *KSCloudAPI) GetAccountID() string { return api.accountID }
// IsLoggedIn indicates if the client has sucessfully authenticated.
func (api *KSCloudAPI) IsLoggedIn() bool { return api.loggedIn }
func (api *KSCloudAPI) GetClientID() string { return api.clientID }
func (api *KSCloudAPI) GetSecretKey() string { return api.secretKey }
func (api *KSCloudAPI) GetCloudReportURL() string { return api.cloudReportURL }
func (api *KSCloudAPI) GetCloudAPIURL() string { return api.cloudAPIURL }
func (api *KSCloudAPI) GetCloudUIURL() string { return api.cloudUIURL }
func (api *KSCloudAPI) GetCloudAuthURL() string { return api.cloudAuthURL }
func (api *KSCloudAPI) GetInvitationToken() string { return api.invitationToken }
func (api *KSCloudAPI) SetAccountID(accountID string) { api.accountID = accountID }
func (api *KSCloudAPI) SetClientID(clientID string) { api.clientID = clientID }
func (api *KSCloudAPI) SetSecretKey(secretKey string) { api.secretKey = secretKey }
func (api *KSCloudAPI) SetInvitationToken(token string) { api.invitationToken = token }
func (api *KSCloudAPI) SetCloudAPIURL(cloudAPIURL string) {
api.cloudAPIURL = cloudAPIURL
api.scheme, api.host = parseHost(cloudAPIURL)
}
func (api *KSCloudAPI) SetCloudUIURL(cloudUIURL string) {
api.cloudUIURL = cloudUIURL
api.uischeme, api.uihost = parseHost(cloudUIURL)
}
func (api *KSCloudAPI) SetCloudAuthURL(cloudAuthURL string) {
api.cloudAuthURL = cloudAuthURL
api.authscheme, api.authhost = parseHost(cloudAuthURL)
}
func (api *KSCloudAPI) SetCloudReportURL(cloudReportURL string) {
api.cloudReportURL = cloudReportURL
api.reportscheme, api.reporthost = parseHost(cloudReportURL)
}
func (api *KSCloudAPI) GetAttackTracks() ([]AttackTrack, error) {
rdr, _, err := api.get(api.getAttackTracksURL())
if err != nil {
return nil, err
}
defer rdr.Close()
attackTracks, err := decode[[]AttackTrack](rdr)
if err != nil {
return nil, err
}
return attackTracks, nil
}
func (api *KSCloudAPI) getAttackTracksURL() string {
return api.buildAPIURL(
pathAttackTracks,
api.paramsWithGUID()...,
)
}
// GetFramework retrieves a framework by name.
func (api *KSCloudAPI) GetFramework(frameworkName string) (*Framework, error) {
rdr, _, err := api.get(api.getFrameworkURL(frameworkName))
if err != nil {
return nil, err
}
defer rdr.Close()
framework, err := decode[Framework](rdr)
if err != nil {
return nil, err
}
return &framework, err
}
func (api *KSCloudAPI) getFrameworkURL(frameworkName string) string {
if isNativeFramework(frameworkName) {
// Native framework name is normalized as upper case, but for a custom framework the name remains unaltered
frameworkName = strings.ToUpper(frameworkName)
}
return api.buildAPIURL(
pathFrameworks,
append(
api.paramsWithGUID(),
queryParamFrameworkName, frameworkName,
)...,
)
}
// GetFrameworks returns all registered frameworks.
func (api *KSCloudAPI) GetFrameworks() ([]Framework, error) {
rdr, _, err := api.get(api.getListFrameworkURL())
if err != nil {
return nil, err
}
defer rdr.Close()
frameworks, err := decode[[]Framework](rdr)
if err != nil {
return nil, err
}
return frameworks, err
}
func (api *KSCloudAPI) getListFrameworkURL() string {
return api.buildAPIURL(
pathFrameworks,
api.paramsWithGUID()...,
)
}
// ListCustomFrameworks lists the names of all non-native frameworks that have been registered for this account.
func (api *KSCloudAPI) ListCustomFrameworks() ([]string, error) {
frameworks, err := api.GetFrameworks()
if err != nil {
return nil, err
}
frameworkList := make([]string, 0, len(frameworks))
for _, framework := range frameworks {
if isNativeFramework(framework.Name) {
continue
}
frameworkList = append(frameworkList, framework.Name)
}
return frameworkList, nil
}
// ListFrameworks list the names of all registered frameworks.
func (api *KSCloudAPI) ListFrameworks() ([]string, error) {
frameworks, err := api.GetFrameworks()
if err != nil {
return nil, err
}
frameworkList := make([]string, 0, len(frameworks))
for _, framework := range frameworks {
name := framework.Name
if isNativeFramework(framework.Name) {
name = strings.ToLower(framework.Name)
}
frameworkList = append(frameworkList, name)
}
return frameworkList, nil
}
// GetExceptions returns exception policies.
func (api *KSCloudAPI) GetExceptions(clusterName string) ([]PostureExceptionPolicy, error) {
rdr, _, err := api.get(api.getExceptionsURL(clusterName))
if err != nil {
return nil, err
}
defer rdr.Close()
exceptions, err := decode[[]PostureExceptionPolicy](rdr)
if err != nil {
return nil, err
}
return exceptions, nil
}
func (api *KSCloudAPI) getExceptionsURL(clusterName string) string {
return api.buildAPIURL(
pathExceptions,
api.paramsWithGUID()...,
)
// queryParamClusterName, clusterName, // TODO - fix customer name support in Armo BE
}
// GetTenant retrieves the credentials for the calling tenant.
//
// The tenant ID overides any already provided account ID.
func (api *KSCloudAPI) GetTenant() (*TenantResponse, error) {
rdr, _, err := api.get(api.getTenantURL())
if err != nil {
return nil, err
}
defer rdr.Close()
tenant, err := decode[TenantResponse](rdr)
if err != nil {
return nil, err
}
if tenant.TenantID != "" {
api.accountID = tenant.TenantID
}
return &tenant, nil
}
func (api *KSCloudAPI) getTenantURL() string {
var params []string
if api.accountID != "" {
params = []string{
queryParamGUID, api.accountID, // NOTE: no fallback in this case
}
}
return api.buildAPIURL(
pathTenant,
params...,
)
}
// GetAccountConfig yields the account configuration.
func (api *KSCloudAPI) GetAccountConfig(clusterName string) (*CustomerConfig, error) {
if api.accountID == "" {
return &CustomerConfig{}, nil
}
rdr, _, err := api.get(api.getAccountConfig(clusterName))
if err != nil {
return nil, err
}
defer rdr.Close()
accountConfig, err := decode[CustomerConfig](rdr)
if err != nil {
// retry with default scope
rdr, _, err = api.get(api.getAccountConfigDefault(clusterName))
if err != nil {
return nil, err
}
defer rdr.Close()
accountConfig, err = decode[CustomerConfig](rdr)
if err != nil {
return nil, err
}
}
return &accountConfig, nil
}
func (api *KSCloudAPI) getAccountConfig(clusterName string) string {
params := api.paramsWithGUID()
if clusterName != "" { // TODO - fix customer name support in Armo BE
params = append(params, queryParamClusterName, clusterName)
}
return api.buildAPIURL(
pathCustomerConfig,
params...,
)
}
func (api *KSCloudAPI) getAccountConfigDefault(clusterName string) string {
params := append(
api.paramsWithGUID(),
queryParamScope, "customer",
)
if clusterName != "" { // TODO - fix customer name support in Armo BE
params = append(params, queryParamClusterName, clusterName)
}
return api.buildAPIURL(
pathCustomerConfig,
params...,
)
}
// GetControlsInputs returns the controls inputs configured in the account configuration.
func (api *KSCloudAPI) GetControlsInputs(clusterName string) (map[string][]string, error) {
accountConfig, err := api.GetAccountConfig(clusterName)
if err != nil {
return nil, err
}
return accountConfig.Settings.PostureControlInputs, nil
}
// GetControl is currently not exposed as a public API endpoint.
func (api *KSCloudAPI) GetControl(ID string) (*Control, error) {
return nil, ErrAPINotPublic
}
// ListControls is currently not exposed as a public API endpoint.
func (api *KSCloudAPI) ListControls() ([]string, error) {
return nil, ErrAPINotPublic
}
// PostExceptions registers a list of exceptions.
func (api *KSCloudAPI) PostExceptions(exceptions []PostureExceptionPolicy) error {
target := api.exceptionsURL("")
for i := range exceptions {
jazon, err := json.Marshal(exceptions[i])
if err != nil {
return err
}
_, _, err = api.post(target, jazon, withContentJSON(true))
if err != nil {
return err
}
}
return nil
}
// Delete exception removes a registered exception rule.
func (api *KSCloudAPI) DeleteException(exceptionName string) error {
_, _, err := api.delete(api.exceptionsURL(exceptionName))
return err
}
func (api *KSCloudAPI) exceptionsURL(exceptionsPolicyName string) string {
params := api.paramsWithGUID()
if exceptionsPolicyName != "" { // for delete
params = append(params, queryParamPolicyName, exceptionsPolicyName)
}
return api.buildAPIURL(
pathExceptionPolicy,
params...,
)
}
// SubmitReport uploads a posture report.
func (api *KSCloudAPI) SubmitReport(report *PostureReport) error {
jazon, err := json.Marshal(report)
if err != nil {
return err
}
_, _, err = api.post(api.postReportURL(report.ClusterName, report.ReportID), jazon, withContentJSON(true), withToken(api.invitationToken))
return err
}
func (api *KSCloudAPI) postReportURL(cluster, reportID string) string {
return api.buildReportURL(pathReport,
append(
api.paramsWithGUID(),
queryParamContextName, cluster,
queryParamClusterName, cluster, // deprecated
queryParamReport, reportID,
)...,
)
}
// ViewReportURL yields the frontend URL to view a posture report (e.g. from a repository scan).
func (api *KSCloudAPI) ViewReportURL(reportID string) string {
return api.buildUIURL(
fmt.Sprintf(pathUIRepository, reportID),
)
}
// ViewDashboardURL yields the frontend URL for the dashboard.
func (api *KSCloudAPI) ViewDashboardURL() string {
return api.buildUIURL(
pathUIDashboard,
)
}
// ViewRBACURL yields the frontend URL to visualize RBAC.
func (api *KSCloudAPI) ViewRBACURL() string {
return api.buildUIURL(
pathUIRBAC,
)
}
// ViewRBACURL yields the frontend URL to check the compliance of a scanned cluster.
func (api *KSCloudAPI) ViewScanURL(cluster string) string {
return api.buildUIURL(
fmt.Sprintf(pathUIScan, cluster),
)
}
// ViewSignURL yields the frontend login page.
func (api *KSCloudAPI) ViewSignURL() string {
params := api.paramsWithGUID()
params = append(params, api.paramsWithUTM()...)
params = append(params, queryParamInvitationToken, api.invitationToken)
return api.buildUIURL(
pathUISign,
params...,
)
}
// Login to the KS Cloud using the caller's accountID, clientID and secret key.
func (api *KSCloudAPI) Login() error {
if err := api.loginRequirements(); err != nil {
return err
}
// 1. acquire auth token
body, err := json.Marshal(feLoginData{ClientId: api.clientID, Secret: api.secretKey})
if err != nil {
return err
}
rdr, _, err := api.post(api.authTokenURL(), body, withContentJSON(true))
if err != nil {
return err
}
defer rdr.Close()
resp, err := decode[feLoginResponse](rdr)
if err != nil {
return err
}
api.feToken = resp
// 2. acquire auth cookie
// Now that we have the JWT token, acquire a cookie from the API
api.authCookie, err = api.getAuthCookie()
if err != nil {
return err
}
api.loggedIn = true
return nil
}
func (api *KSCloudAPI) authTokenURL() string {
return api.buildAuthURL(pathLogin)
}
func (api *KSCloudAPI) getOpenidURL() string {
return api.buildAPIURL(pathToken)
}
func (api *KSCloudAPI) getAuthCookie() (*http.Cookie, error) {
selectCustomer := ksCloudSelectCustomer{SelectedCustomerGuid: api.accountID}
body, err := json.Marshal(selectCustomer)
if err != nil {
return nil, err
}
target := api.getOpenidURL()
o := api.defaultRequestOptions([]requestOption{withContentJSON(true), withCookie(nil)})
req, err := http.NewRequestWithContext(o.reqContext, http.MethodPost, target, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
o.setHeaders(req)
o.traceReq(req)
resp, err := api.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
o.traceResp(resp)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get cookie from %s: status %d", target, resp.StatusCode)
}
for _, cookie := range resp.Cookies() {
if cookie.Name == authenticationCookie {
return cookie, nil
}
}
return nil, fmt.Errorf("no auth cookie in response from %s", target)
}
func (api *KSCloudAPI) loginRequirements() error {
if api.accountID == "" {
return ErrLoginMissingAccountID
}
if api.clientID == "" {
return ErrLoginMissingClientID
}
if api.secretKey == "" {
return ErrLoginMissingSecretKey
}
return nil
}
// defaultRequestOptions adds standard authentication headers to all requests
func (api *KSCloudAPI) defaultRequestOptions(opts []requestOption) *requestOptions {
optionsWithDefaults := append(make([]requestOption, 0, 4),
withToken(api.feToken.Token),
withCookie(api.authCookie),
withTrace(api.withTrace),
)
optionsWithDefaults = append(optionsWithDefaults, opts...)
return requestOptionsWithDefaults(optionsWithDefaults)
}
func (api *KSCloudAPI) get(fullURL string, opts ...requestOption) (io.ReadCloser, int64, error) {
o := api.defaultRequestOptions(opts)
req, err := http.NewRequestWithContext(o.reqContext, http.MethodGet, fullURL, nil)
if err != nil {
return nil, 0, err
}
return api.do(req, o)
}
func (api *KSCloudAPI) post(fullURL string, body []byte, opts ...requestOption) (io.ReadCloser, int64, error) {
o := api.defaultRequestOptions(opts)
req, err := http.NewRequestWithContext(o.reqContext, http.MethodPost, fullURL, bytes.NewBuffer(body))
if err != nil {
return nil, 0, err
}
return api.do(req, o)
}
func (api *KSCloudAPI) delete(fullURL string, opts ...requestOption) (io.ReadCloser, int64, error) {
o := api.defaultRequestOptions(opts)
req, err := http.NewRequestWithContext(o.reqContext, http.MethodDelete, fullURL, nil)
if err != nil {
return nil, 0, err
}
return api.do(req, o)
}
func (api *KSCloudAPI) do(req *http.Request, o *requestOptions) (io.ReadCloser, int64, error) {
o.setHeaders(req)
o.traceReq(req)
resp, err := api.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
o.traceResp(resp)
if resp.StatusCode >= 400 {
if req.URL.Path == pathLogin {
return nil, 0, errAuth(resp)
}
return nil, 0, errAPI(resp)
}
return resp.Body, resp.ContentLength, err
}
func (api *KSCloudAPI) paramsWithGUID() []string {
return append(make([]string, 0, 6),
queryParamGUID, api.getCustomerGUIDFallBack(),
)
}
func (api *KSCloudAPI) paramsWithUTM() []string {
return append(make([]string, 0, 6),
queryParamUTMSource, "ARMOgithub",
queryParamUTMMedium, "createaccount",
)
}
func (api *KSCloudAPI) getCustomerGUIDFallBack() string {
if api.accountID != "" {
return api.accountID
}
return fallbackGUID
}