adding cluster flag - support submiting yaml file

This commit is contained in:
dwertent
2022-01-09 16:13:15 +02:00
parent 740497047d
commit bd089d76af
17 changed files with 134 additions and 42 deletions
+1
View File
@@ -100,6 +100,7 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser
| `--submit` | `false` | If set, Kubescape will send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not sent | `true`/`false` |
| `--keep-local` | `false` | Kubescape will not send scan results to Armo management portal. Use this flag if you ran with the `--submit` flag in the past and you do not want to submit your current scan results | `true`/`false` |
| `--account` | | Armo portal account ID. Default will load account ID from configMap or config file | |
| `--cluster` | current-context | Cluster context to scan | |
| `--verbose` | `false` | Display all of the input resources and not only failed resources | `true`/`false` |
+9 -3
View File
@@ -76,7 +76,7 @@ type LocalConfig struct {
configObj *ConfigObj
}
func NewLocalConfig(backendAPI getter.IBackend, customerGUID string) *LocalConfig {
func NewLocalConfig(backendAPI getter.IBackend, customerGUID, clusterName string) *LocalConfig {
var configObj *ConfigObj
lc := &LocalConfig{
@@ -95,6 +95,9 @@ func NewLocalConfig(backendAPI getter.IBackend, customerGUID string) *LocalConfi
if customerGUID != "" {
lc.configObj.CustomerGUID = customerGUID // override config customerGUID
}
if clusterName != "" {
lc.configObj.ClusterName = AdoptClusterName(clusterName) // override config clusterName
}
if lc.configObj.CustomerGUID != "" {
if err := lc.SetTenant(); err != nil {
fmt.Println(err)
@@ -107,7 +110,7 @@ func NewLocalConfig(backendAPI getter.IBackend, customerGUID string) *LocalConfi
func (lc *LocalConfig) GetConfigObj() *ConfigObj { return lc.configObj }
func (lc *LocalConfig) GetCustomerGUID() string { return lc.configObj.CustomerGUID }
func (lc *LocalConfig) SetCustomerGUID(customerGUID string) { lc.configObj.CustomerGUID = customerGUID }
func (lc *LocalConfig) GetClusterName() string { return "" }
func (lc *LocalConfig) GetClusterName() string { return lc.configObj.ClusterName }
func (lc *LocalConfig) IsConfigFound() bool { return existsConfigFile() }
func (lc *LocalConfig) SetTenant() error {
// ARMO tenant GUID
@@ -163,7 +166,7 @@ type ClusterConfig struct {
configObj *ConfigObj
}
func NewClusterConfig(k8s *k8sinterface.KubernetesApi, backendAPI getter.IBackend, customerGUID string) *ClusterConfig {
func NewClusterConfig(k8s *k8sinterface.KubernetesApi, backendAPI getter.IBackend, customerGUID, clusterName string) *ClusterConfig {
var configObj *ConfigObj
c := &ClusterConfig{
k8s: k8s,
@@ -186,6 +189,9 @@ func NewClusterConfig(k8s *k8sinterface.KubernetesApi, backendAPI getter.IBacken
if customerGUID != "" {
c.configObj.CustomerGUID = customerGUID // override config customerGUID
}
if clusterName != "" {
c.configObj.ClusterName = AdoptClusterName(clusterName) // override config clusterName
}
if c.configObj.CustomerGUID != "" {
if err := c.SetTenant(); err != nil {
fmt.Println(err)
+1
View File
@@ -64,6 +64,7 @@ type ScanInfo struct {
HostSensor BoolPtrFlag // Deploy ARMO K8s host sensor to collect data from certain controls
Local bool // Do not submit results
Account string // account ID
ClusterName string // cluster name
FrameworkScan bool // false if scanning control
ScanAll bool // true if scan all frameworks
}
+4 -4
View File
@@ -34,7 +34,7 @@ func CliDownload(downloadInfo *cautils.DownloadInfo) error {
}
func downloadConfigInputs(downloadInfo *cautils.DownloadInfo) error {
tenant := getTenantConfig(downloadInfo.Account, getKubernetesApi()) // change k8sinterface
tenant := getTenantConfig(downloadInfo.Account, "", getKubernetesApi()) // change k8sinterface
controlsInputsGetter := getConfigInputsGetter(downloadInfo.Name, tenant.GetCustomerGUID(), nil)
controlInputs, err := controlsInputsGetter.GetControlsInputs(tenant.GetClusterName())
if err != nil {
@@ -52,7 +52,7 @@ func downloadConfigInputs(downloadInfo *cautils.DownloadInfo) error {
}
func downloadExceptions(downloadInfo *cautils.DownloadInfo) error {
tenant := getTenantConfig(downloadInfo.Account, getKubernetesApi()) // change k8sinterface
tenant := getTenantConfig(downloadInfo.Account, "", getKubernetesApi()) // change k8sinterface
exceptionsGetter := getExceptionsGetter("")
exceptions, err := exceptionsGetter.GetExceptions(tenant.GetClusterName())
if err != nil {
@@ -70,7 +70,7 @@ func downloadExceptions(downloadInfo *cautils.DownloadInfo) error {
}
func downloadFramework(downloadInfo *cautils.DownloadInfo) error {
tenant := getTenantConfig(downloadInfo.Account, getKubernetesApi()) // change k8sinterface
tenant := getTenantConfig(downloadInfo.Account, "", getKubernetesApi()) // change k8sinterface
g := getPolicyGetter(nil, tenant.GetCustomerGUID(), true, nil)
if downloadInfo.Name == "" {
@@ -92,7 +92,7 @@ func downloadFramework(downloadInfo *cautils.DownloadInfo) error {
}
func downloadControl(downloadInfo *cautils.DownloadInfo) error {
tenant := getTenantConfig(downloadInfo.Account, getKubernetesApi()) // change k8sinterface
tenant := getTenantConfig(downloadInfo.Account, "", getKubernetesApi()) // change k8sinterface
g := getPolicyGetter(nil, tenant.GetCustomerGUID(), false, nil)
if downloadInfo.Name == "" {
+2 -2
View File
@@ -41,14 +41,14 @@ func CliList(listPolicies *cautils.ListPolicies) error {
}
func listFrameworks(listPolicies *cautils.ListPolicies) ([]string, error) {
tenant := getTenantConfig(listPolicies.Account, getKubernetesApi()) // change k8sinterface
tenant := getTenantConfig(listPolicies.Account, "", getKubernetesApi()) // change k8sinterface
g := getPolicyGetter(nil, tenant.GetCustomerGUID(), true, nil)
return listFrameworksNames(g), nil
}
func listControls(listPolicies *cautils.ListPolicies) ([]string, error) {
tenant := getTenantConfig(listPolicies.Account, getKubernetesApi()) // change k8sinterface
tenant := getTenantConfig(listPolicies.Account, "", getKubernetesApi()) // change k8sinterface
g := getPolicyGetter(nil, tenant.GetCustomerGUID(), false, nil)
l := getter.ListName
if listPolicies.ListIDs {
+1 -1
View File
@@ -30,7 +30,7 @@ var getCmd = &cobra.Command{
key := keyValue[0]
k8s := k8sinterface.NewKubernetesApi()
clusterConfig := cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), scanInfo.Account)
clusterConfig := cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), scanInfo.Account, "")
val, err := clusterConfig.GetValueByKeyFromConfigMap(key)
if err != nil {
if err.Error() == "value does not exist." {
+1 -1
View File
@@ -30,7 +30,7 @@ var setCmd = &cobra.Command{
data := keyValue[1]
k8s := k8sinterface.NewKubernetesApi()
clusterConfig := cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), scanInfo.Account)
clusterConfig := cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), scanInfo.Account, "")
if err := clusterConfig.SetKeyValueInConfigmap(key, data); err != nil {
return err
}
+8
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/armosec/k8s-interface/k8sinterface"
"github.com/armosec/kubescape/cautils"
"github.com/spf13/cobra"
)
@@ -33,8 +34,15 @@ var scanCmd = &cobra.Command{
},
}
func frameworkInitConfig() {
k8sinterface.SetClusterContextName(scanInfo.ClusterName)
}
func init() {
cobra.OnInitialize(frameworkInitConfig)
rootCmd.AddCommand(scanCmd)
rootCmd.PersistentFlags().StringVarP(&scanInfo.ClusterName, "cluster", "", "", "Cluster name. Default will use the current-context")
scanCmd.PersistentFlags().StringVar(&scanInfo.ControlsInputs, "controls-config", "", "Path to an controls-config obj. If not set will download controls-config from ARMO management portal")
scanCmd.PersistentFlags().StringVar(&scanInfo.UseExceptions, "exceptions", "", "Path to an exceptions obj. If not set will download exceptions from ARMO management portal")
scanCmd.PersistentFlags().StringVarP(&scanInfo.ExcludedNamespaces, "exclude-namespaces", "e", "", "Namespaces to exclude from scanning. Recommended: kube-system,kube-public")
+1 -1
View File
@@ -20,7 +20,7 @@ func init() {
}
func getSubmittedClusterConfig(k8s *k8sinterface.KubernetesApi) (*cautils.ClusterConfig, error) {
clusterConfig := cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), scanInfo.Account) // TODO - support none cluster env submit
clusterConfig := cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), scanInfo.Account, scanInfo.ClusterName) // TODO - support none cluster env submit
if clusterConfig.GetCustomerGUID() != "" {
if err := clusterConfig.SetTenant(); err != nil {
return clusterConfig, err
+10 -2
View File
@@ -5,6 +5,7 @@ import (
"io/fs"
"os"
"github.com/armosec/k8s-interface/k8sinterface"
"github.com/armosec/kubescape/resultshandling/printer"
printerv1 "github.com/armosec/kubescape/resultshandling/printer/v1"
@@ -34,9 +35,16 @@ type componentInterfaces struct {
func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces {
k8s := getKubernetesApi()
var k8s *k8sinterface.KubernetesApi
if scanInfo.GetScanningEnvironment() == cautils.ScanCluster {
k8s = getKubernetesApi()
if k8s == nil {
fmt.Println("Failed connecting to Kubernetes cluster")
os.Exit(1)
}
}
tenantConfig := getTenantConfig(scanInfo.Account, k8s)
tenantConfig := getTenantConfig(scanInfo.Account, scanInfo.ClusterName, k8s)
// Set submit behavior AFTER loading tenant config
setSubmitBehavior(scanInfo, tenantConfig)
+9 -12
View File
@@ -16,17 +16,18 @@ import (
// reporterv2 "github.com/armosec/kubescape/resultshandling/reporter/v2"
)
// getKubernetesApi
func getKubernetesApi() *k8sinterface.KubernetesApi {
if !k8sinterface.IsConnectedToCluster() {
return nil
}
return k8sinterface.NewKubernetesApi()
}
func getTenantConfig(Account string, k8s *k8sinterface.KubernetesApi) cautils.ITenantConfig {
if !k8sinterface.IsConnectedToCluster() {
return cautils.NewLocalConfig(getter.GetArmoAPIConnector(), Account)
func getTenantConfig(Account, clusterName string, k8s *k8sinterface.KubernetesApi) cautils.ITenantConfig {
if !k8sinterface.IsConnectedToCluster() || k8s == nil {
return cautils.NewLocalConfig(getter.GetArmoAPIConnector(), Account, clusterName)
}
return cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), Account)
return cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector(), Account, clusterName)
}
func getExceptionsGetter(useExceptions string) getter.IExceptionsGetter {
@@ -51,8 +52,9 @@ func getReporter(tenantConfig cautils.ITenantConfig, submit bool) reporter.IRepo
}
return reporterv1.NewReportMock()
}
func getResourceHandler(scanInfo *cautils.ScanInfo, tenantConfig cautils.ITenantConfig, k8s *k8sinterface.KubernetesApi, hostSensorHandler hostsensorutils.IHostSensor) resourcehandler.IResourceHandler {
if scanInfo.GetScanningEnvironment() == cautils.ScanLocalFiles {
if len(scanInfo.InputPatterns) > 0 || k8s == nil {
return resourcehandler.NewFileResourceHandler(scanInfo.InputPatterns)
}
rbacObjects := getRBACHandler(tenantConfig, k8s, scanInfo.Submit)
@@ -60,9 +62,10 @@ func getResourceHandler(scanInfo *cautils.ScanInfo, tenantConfig cautils.ITenant
}
func getHostSensorHandler(scanInfo *cautils.ScanInfo, k8s *k8sinterface.KubernetesApi) hostsensorutils.IHostSensor {
if scanInfo.GetScanningEnvironment() == cautils.ScanLocalFiles {
if !k8sinterface.IsConnectedToCluster() || k8s == nil {
return &hostsensorutils.HostSensorHandlerMock{}
}
hasHostSensorControls := true
// we need to determined which controls needs host sensor
if scanInfo.HostSensor.Get() == nil && hasHostSensorControls {
@@ -125,12 +128,6 @@ func setSubmitBehavior(scanInfo *cautils.ScanInfo, tenantConfig cautils.ITenantC
return
}
// do not submit yaml/url scanning
if scanInfo.GetScanningEnvironment() == cautils.ScanLocalFiles {
scanInfo.Submit = false
return
}
if tenantConfig.IsConfigFound() { // config found in cache (submitted)
if !scanInfo.Local {
// Submit report
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.17
require (
github.com/armosec/armoapi-go v0.0.40
github.com/armosec/k8s-interface v0.0.50
github.com/armosec/k8s-interface v0.0.54
github.com/armosec/opa-utils v0.0.92
github.com/armosec/rbac-utils v0.0.11
github.com/armosec/utils-go v0.0.3
+2 -1
View File
@@ -89,8 +89,9 @@ github.com/armosec/armoapi-go v0.0.40 h1:KQRJXFqw95s6cV7HoGgw1x8qrRZ9eNVze//yQbo
github.com/armosec/armoapi-go v0.0.40/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw=
github.com/armosec/k8s-interface v0.0.8/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM=
github.com/armosec/k8s-interface v0.0.37/go.mod h1:vHxGWqD/uh6+GQb9Sqv7OGMs+Rvc2dsFVc0XtgRh1ZU=
github.com/armosec/k8s-interface v0.0.50 h1:iLPGI0j85vwKANr9QDAnba4Efjg3DyIJg15jRJdvOnc=
github.com/armosec/k8s-interface v0.0.50/go.mod h1:vHxGWqD/uh6+GQb9Sqv7OGMs+Rvc2dsFVc0XtgRh1ZU=
github.com/armosec/k8s-interface v0.0.54 h1:1sQeoEZA5bgpXVibXhEiTSeLd3GKY5NkTOeewdgR0Bs=
github.com/armosec/k8s-interface v0.0.54/go.mod h1:vHxGWqD/uh6+GQb9Sqv7OGMs+Rvc2dsFVc0XtgRh1ZU=
github.com/armosec/opa-utils v0.0.64/go.mod h1:6tQP8UDq2EvEfSqh8vrUdr/9QVSCG4sJfju1SXQOn4c=
github.com/armosec/opa-utils v0.0.92 h1:RzzORhfLx9Evc2ceFtNRoehxUFzwlvK5iMtR6fLWzZc=
github.com/armosec/opa-utils v0.0.92/go.mod h1:ZOXYVTtuyrV4TldcfbzgRqP6F9Drlf4hB0zr210OXgM=
@@ -122,10 +122,17 @@ func (prettyPrinter *PrettyPrinter) printGroupedResource(indent string, title st
indent += indent
}
resources := []string{}
for r := range rsc {
relatedObjectsStr := generateRelatedObjectsStr(rsc[r])
cautils.SimpleDisplay(prettyPrinter.writer, fmt.Sprintf("%s%s - %s %s\n", indent, rsc[r].resource.GetKind(), rsc[r].resource.GetName(), relatedObjectsStr))
relatedObjectsStr := generateRelatedObjectsStr(rsc[r]) // TODO -
resources = append(resources, fmt.Sprintf("%s%s - %s %s", indent, rsc[r].resource.GetKind(), rsc[r].resource.GetName(), relatedObjectsStr))
}
sort.Strings(resources)
for i := range resources {
cautils.SimpleDisplay(prettyPrinter.writer, resources[i]+"\n")
}
indent = preIndent
}
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"github.com/armosec/k8s-interface/workloadinterface"
"github.com/armosec/kubescape/cautils"
@@ -23,6 +24,7 @@ type ReportEventReceiver struct {
eventReceiverURL *url.URL
token string
customerAdminEMail string
message string
}
func NewReportEventReceiver(tenantConfig *cautils.ConfigObj) *ReportEventReceiver {
@@ -38,15 +40,22 @@ func NewReportEventReceiver(tenantConfig *cautils.ConfigObj) *ReportEventReceive
func (report *ReportEventReceiver) ActionSendReport(opaSessionObj *cautils.OPASessionObj) error {
cautils.ReportV2ToV1(opaSessionObj)
if report.customerGUID == "" || report.clusterName == "" {
return fmt.Errorf("missing account ID or cluster name. AccountID: '%s', Cluster name: '%s'", report.customerGUID, report.clusterName)
if report.customerGUID == "" {
report.message = "WARNING: Failed to publish results. Reason: Unknown accout ID. Run kubescape with the '--account <account ID>' flag. Contact ARMO team for more details"
return nil
}
if report.clusterName == "" {
report.message = "WARNING: Failed to publish results. Reason: Unknown cluster name. Run kubescape with the '--cluster <cluster name>' flag"
return nil
}
opaSessionObj.PostureReport.ReportID = uuid.NewV4().String()
opaSessionObj.PostureReport.CustomerGUID = report.clusterName
opaSessionObj.PostureReport.ClusterName = report.customerGUID
if err := report.prepareReport(opaSessionObj.PostureReport, opaSessionObj.AllResources); err != nil {
return err
report.message = err.Error()
return nil
}
return nil
}
@@ -75,6 +84,8 @@ func (report *ReportEventReceiver) prepareReport(postureReport *reporthandling.P
if err := report.sendResources(host, postureReport, allResources); err != nil {
return err
}
report.generateMessage()
return nil
}
@@ -120,7 +131,7 @@ func (report *ReportEventReceiver) sendReport(host string, postureReport *report
return err
}
func (report *ReportEventReceiver) DisplayReportURL() {
func (report *ReportEventReceiver) generateMessage() {
message := "You can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more by registering here:"
u := url.URL{}
@@ -128,7 +139,7 @@ func (report *ReportEventReceiver) DisplayReportURL() {
u.Host = getter.GetArmoAPIConnector().GetFrontendURL()
if report.customerAdminEMail != "" {
cautils.InfoTextDisplay(os.Stderr, fmt.Sprintf("\n\n%s %s/risk/%s\n(Account: %s)\n\n", message, u.String(), report.clusterName, report.customerGUID))
report.message = fmt.Sprintf("%s %s/risk/%s\n(Account: %s)", message, u.String(), report.clusterName, maskID(report.customerGUID))
return
}
u.Path = "account/sign-up"
@@ -137,5 +148,27 @@ func (report *ReportEventReceiver) DisplayReportURL() {
q.Add("customerGUID", report.customerGUID)
u.RawQuery = q.Encode()
cautils.InfoTextDisplay(os.Stderr, fmt.Sprintf("\n\n%s %s\n\n", message, u.String()))
report.message = fmt.Sprintf("%s %s", message, u.String())
}
func (report *ReportEventReceiver) DisplayReportURL() {
cautils.InfoTextDisplay(os.Stderr, fmt.Sprintf("\n\n%s\n\n", report.message))
}
func maskID(id string) string {
sep := "-"
splitted := strings.Split(id, sep)
if len(splitted) != 5 {
return ""
}
str := splitted[0][:4]
splitted[0] = splitted[0][4:]
for i := range splitted {
for j := 0; j < len(splitted[i]); j++ {
str += "X"
}
str += sep
}
return strings.TrimSuffix(str, sep)
}
@@ -24,6 +24,7 @@ type ReportEventReceiver struct {
eventReceiverURL *url.URL
token string
customerAdminEMail string
message string
}
func NewReportEventReceiver(tenantConfig *cautils.ConfigObj) *ReportEventReceiver {
@@ -39,15 +40,20 @@ func NewReportEventReceiver(tenantConfig *cautils.ConfigObj) *ReportEventReceive
func (report *ReportEventReceiver) ActionSendReport(opaSessionObj *cautils.OPASessionObj) error {
finalizeReport(opaSessionObj)
if report.customerGUID == "" || report.clusterName == "" {
return fmt.Errorf("missing accout ID or cluster name. AccountID: '%s', Cluster name: '%s'", report.customerGUID, report.clusterName)
if report.customerGUID == "" {
report.message = "WARNING: Failed to publish results. Reason: Unknown accout ID. Run kubescape with the '--account <account ID>' flag. Contact ARMO team for more details"
return nil
}
if report.clusterName == "" {
report.message = "WARNING: Failed to publish results. Reason: Unknown cluster name. Run kubescape with the '--cluster <cluster name>' flag"
return nil
}
opaSessionObj.Report.ReportID = uuid.NewV4().String()
opaSessionObj.Report.CustomerGUID = report.clusterName
opaSessionObj.Report.ClusterName = report.customerGUID
if err := report.prepareReport(opaSessionObj.Report); err != nil {
return err
report.message = err.Error()
}
return nil
}
@@ -160,7 +166,7 @@ func (report *ReportEventReceiver) sendReport(host string, postureReport *report
return err
}
func (report *ReportEventReceiver) DisplayReportURL() {
func (report *ReportEventReceiver) generateMessage() {
message := "You can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more by registering here:"
u := url.URL{}
@@ -168,7 +174,7 @@ func (report *ReportEventReceiver) DisplayReportURL() {
u.Host = getter.GetArmoAPIConnector().GetFrontendURL()
if report.customerAdminEMail != "" {
cautils.InfoTextDisplay(os.Stderr, fmt.Sprintf("\n\n%s %s/risk/%s\n(Account: %s)\n\n", message, u.String(), report.clusterName, report.customerGUID))
report.message = fmt.Sprintf("%s %s/risk/%s\n(Account: %s)", message, u.String(), report.clusterName, maskID(report.customerGUID))
return
}
u.Path = "account/sign-up"
@@ -177,5 +183,9 @@ func (report *ReportEventReceiver) DisplayReportURL() {
q.Add("customerGUID", report.customerGUID)
u.RawQuery = q.Encode()
cautils.InfoTextDisplay(os.Stderr, fmt.Sprintf("\n\n%s %s\n\n", message, u.String()))
report.message = fmt.Sprintf("%s %s", message, u.String())
}
func (report *ReportEventReceiver) DisplayReportURL() {
cautils.InfoTextDisplay(os.Stderr, fmt.Sprintf("\n\n%s\n\n", report.message))
}
+20
View File
@@ -1,6 +1,8 @@
package v2
import (
"strings"
"github.com/armosec/k8s-interface/workloadinterface"
"github.com/armosec/kubescape/cautils"
"github.com/armosec/opa-utils/reporthandling/results/v1/resourcesresults"
@@ -41,3 +43,21 @@ func finalizeResources(resources []reporthandlingv2.Resource, allResources map[s
index++
}
}
func maskID(id string) string {
sep := "-"
splitted := strings.Split(id, sep)
if len(splitted) != 5 {
return ""
}
str := splitted[0][:4]
splitted[0] = splitted[0][4:]
for i := range splitted {
for j := 0; j < len(splitted[i]); j++ {
str += "X"
}
str += sep
}
return strings.TrimSuffix(str, sep)
}