Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions internal/cloud/blocked_packages/blocked_packages.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package blocked_packages

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"

"github.com/AikidoSec/safechain-internals/internal/config"
)

const (
defaultTimeout = 30 * time.Second
)

var httpClient *http.Client

func Init() {
httpClient = &http.Client{
Timeout: defaultTimeout,
}
}

func doRequest(ctx context.Context, method, path string, config *config.ConfigInfo, bodyObject any) (*http.Response, error) {
if config.Token == "" {
return nil, fmt.Errorf("token is not set")
}
if config.DeviceID == "" {
return nil, fmt.Errorf("device ID is not set")
}

url, err := url.JoinPath(config.GetBaseURL(), path)
if err != nil {
return nil, fmt.Errorf("failed to build URL for %s: %w", path, err)
}

var bodyReader *bytes.Reader
if bodyObject != nil {
body, err := json.MarshalIndent(bodyObject, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal object: %w", err)
}
bodyReader = bytes.NewReader(body)
}

var req *http.Request
if bodyReader != nil {
req, err = http.NewRequestWithContext(ctx, method, url, bodyReader)
} else {
req, err = http.NewRequestWithContext(ctx, method, url, nil)
}
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if bodyReader != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", config.Token)
req.Header.Set("X-Device-Id", config.DeviceID)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be useful to start doing this & the http client building in a helper that's re-usable?


return httpClient.Do(req)
}

type FetchPermissionsResponse struct {
PermissionGroup PermissionGroup `json:"permission_group"`
Ecosystems map[string]EcosystemInfo `json:"ecosystems"`
}

type PermissionGroup struct {
ID int `json:"id"`
Name string `json:"name"`
}

type EcosystemInfo struct {
Exceptions Exceptions `json:"exceptions"`
}

type Exceptions struct {
RejectedPackages []string `json:"rejected_packages"`
}

func GetBlockedPackages(ctx context.Context, config *config.ConfigInfo) (*FetchPermissionsResponse, error) {
resp, err := doRequest(ctx, http.MethodGet, "api/endpoint_protection/callbacks/fetchPermissions", config, nil)
if err != nil {
return nil, fmt.Errorf("failed to fetch permissions: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch permissions failed with status %d", resp.StatusCode)
}

var result FetchPermissionsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode permissions response: %w", err)
}

return &result, nil
}
3 changes: 2 additions & 1 deletion internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ const (
HeartbeatReportInterval = 10 * time.Minute
SBOMReportInterval = 24 * time.Hour
ProxyStartMaxRetries = 20
ProxyStartRetryInterval = 3 * time.Minute
ProxyStartRetryInterval = 3 * time.Minute
ExtensionUninstallCheckInterval = 5 * time.Minute
)
28 changes: 26 additions & 2 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/AikidoSec/safechain-internals/internal/cloud"
"github.com/AikidoSec/safechain-internals/internal/cloud/blocked_packages"
"github.com/AikidoSec/safechain-internals/internal/config"
"github.com/AikidoSec/safechain-internals/internal/constants"
"github.com/AikidoSec/safechain-internals/internal/device"
Expand Down Expand Up @@ -48,8 +49,9 @@ type Daemon struct {
proxyRetryCount int
proxyLastRetryTime time.Time

daemonLastStatusLogTime time.Time
daemonLastSBOMReportTime time.Time
daemonLastStatusLogTime time.Time
daemonLastSBOMReportTime time.Time
daemonLastExtensionUninstallTime time.Time
}

func New(ctx context.Context, cancel context.CancelFunc) (*Daemon, error) {
Expand All @@ -72,6 +74,7 @@ func New(ctx context.Context, cancel context.CancelFunc) (*Daemon, error) {
}

cloud.Init()
blocked_packages.Init()

d.deviceInfo = device.NewDeviceInfo()
if d.deviceInfo == nil {
Expand Down Expand Up @@ -378,6 +381,27 @@ func (d *Daemon) heartbeat() error {
}
return nil
})
d.runIfIntervalExceeded(&d.daemonLastExtensionUninstallTime, constants.ExtensionUninstallCheckInterval, d.uninstallBlockedExtensions)
return nil
}

func (d *Daemon) uninstallBlockedExtensions() error {
resp, err := blocked_packages.GetBlockedPackages(d.ctx, d.config)
if err != nil {
return err
}

vscodeExceptions, vscodeOk := resp.Ecosystems["vscode"]
openVsxExceptions, openVsxOk := resp.Ecosystems["open_vsx"]
if vscodeOk && openVsxOk {
vscode.UninstallBlockedExtensions(d.ctx, vscodeExceptions.Exceptions.RejectedPackages, openVsxExceptions.Exceptions.RejectedPackages)
}

chromeExceptions, chromeOk := resp.Ecosystems["chrome"]
if chromeOk {
chrome.UninstallBlockedExtensions(d.ctx, chromeExceptions.Exceptions.RejectedPackages)
}

return nil
}

Expand Down
130 changes: 130 additions & 0 deletions internal/sbom/chrome/uninstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package chrome

import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"os"
"path/filepath"

"github.com/AikidoSec/safechain-internals/internal/platform"
)

type extensionToUninstall struct {
extensionId string
profileDir string
}

func UninstallBlockedExtensions(ctx context.Context, extensionsToUninstall []string) {
Comment thread
SanderDeclerck marked this conversation as resolved.
log.Printf("Chrome extension uninstall check (homeDir=%s)", platform.GetConfig().HomeDir)

installations, err := findInstallations(ctx)
if err != nil {
log.Printf("Failed to find Chrome installations for extension uninstall check: %v", err)
return
}
if len(installations) == 0 {
log.Printf("Chrome extension uninstall check: no installations found")
}

extensionBlockSet := make(map[string]struct{}, len(extensionsToUninstall))
for _, id := range extensionsToUninstall {
extensionBlockSet[id] = struct{}{}
}
var toUninstall []extensionToUninstall

for _, inst := range installations {
Comment thread
SanderDeclerck marked this conversation as resolved.
profiles := findProfilesWithExtensions(inst.DataPath)
for _, profile := range profiles {
profileDir := filepath.Join(inst.DataPath, profile)
extDir := filepath.Join(profileDir, "Extensions")
entries, err := os.ReadDir(extDir)
if err != nil {
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, blocked := extensionBlockSet[entry.Name()]; blocked {
toUninstall = append(toUninstall, extensionToUninstall{
extensionId: entry.Name(),
profileDir: profileDir,
})
}
}
}
}

if len(toUninstall) > 0 {
for _, ext := range toUninstall {
log.Printf("Uninstalling blocked Chrome extension %s from %s", ext.extensionId, ext.profileDir)
if err := removeExtension(ext.profileDir, ext.extensionId); err != nil {
log.Printf("Failed to remove Chrome extension %s: %v", ext.extensionId, err)
} else {
log.Printf("Successfully removed Chrome extension %s", ext.extensionId)
}

}
}
}

func removeExtension(profileDir, extensionId string) error {
if err := os.RemoveAll(filepath.Join(profileDir, "Extensions", extensionId)); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(profileDir, "Sync Extension Settings", extensionId)); err != nil {
return err
}
if err := removeFromPrefsFile(filepath.Join(profileDir, "Preferences"), extensionId); err != nil {
return err
}
return removeFromPrefsFile(filepath.Join(profileDir, "Secure Preferences"), extensionId)
}

func removeFromPrefsFile(prefsPath, extensionId string) error {
Comment thread
SanderDeclerck marked this conversation as resolved.
data, err := os.ReadFile(prefsPath)
Comment thread
SanderDeclerck marked this conversation as resolved.
Comment thread
SanderDeclerck marked this conversation as resolved.
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}

var prefs map[string]interface{}
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&prefs); err != nil {
return err
}

// Delete prefs["extensions"]["settings"][extensionId]
if ext, ok := prefs["extensions"].(map[string]interface{}); ok {
if settings, ok := ext["settings"].(map[string]interface{}); ok {
delete(settings, extensionId)
}
}

// Delete prefs["protection"]["macs"]["extensions"]["settings"][extensionId]
if protection, ok := prefs["protection"].(map[string]interface{}); ok {
if macs, ok := protection["macs"].(map[string]interface{}); ok {
if ext, ok := macs["extensions"].(map[string]interface{}); ok {
if settings, ok := ext["settings"].(map[string]interface{}); ok {
delete(settings, extensionId)
}
if settings, ok := ext["settings_encrypted_hash"].(map[string]interface{}); ok {
delete(settings, extensionId)
}
}
}
}

Comment thread
SanderDeclerck marked this conversation as resolved.
out, err := json.Marshal(prefs)
if err != nil {
return err
}

return os.WriteFile(prefsPath, out, 0600)
}
80 changes: 80 additions & 0 deletions internal/sbom/vscode/uninstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package vscode

import (
"context"
"log"

"github.com/AikidoSec/safechain-internals/internal/platform"
)

type extensionToUninstall struct {
binaryPath string
extensionId string
}

func UninstallBlockedExtensions(ctx context.Context, vsCodeBlockList []string, openVsxBlockList []string) {
// Step 1: Collect SBOM - discover all editor installations and their extensions
installations, err := findInstallations(ctx)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doing this could be very expensive, we usuallly only collect sboms once every 24 hours

if err != nil {
log.Printf("Failed to find VSCode installations for extension uninstall check: %v", err)
return
}

if len(installations) == 0 {
return
}

vsCodeBlockSet := make(map[string]struct{}, len(vsCodeBlockList))
for _, id := range vsCodeBlockList {
vsCodeBlockSet[id] = struct{}{}
}
openVsxBlockSet := make(map[string]struct{}, len(openVsxBlockList))
for _, id := range openVsxBlockList {
openVsxBlockSet[id] = struct{}{}
}

scanner := &VSCodeExtensions{}
var toUninstall []extensionToUninstall

// Step 2: Cross-check all installed extensions against the blocklist
for _, inst := range installations {
packages, err := scanner.SBOM(ctx, inst)
if err != nil {
log.Printf("Failed to collect extensions for %s: %v", inst.Variant, err)
continue
}

var blockSet map[string]struct{}

switch inst.Ecosystem {
case "vscode":
blockSet = vsCodeBlockSet
case "open_vsx":
blockSet = openVsxBlockSet
}

for _, pkg := range packages {
if _, blocked := blockSet[pkg.Id]; blocked {
toUninstall = append(toUninstall, extensionToUninstall{
binaryPath: inst.Path,
extensionId: pkg.Id,
})
}
}
}

// Step 3: Uninstall blocked extensions one by one
for _, ext := range toUninstall {
log.Printf("Uninstalling blocked extension %s using %s", ext.extensionId, ext.binaryPath)
if err := uninstallExtension(ctx, ext.binaryPath, ext.extensionId); err != nil {
log.Printf("Failed to uninstall extension %s: %v", ext.extensionId, err)
continue
}
log.Printf("Successfully uninstalled extension %s", ext.extensionId)
}
}

func uninstallExtension(ctx context.Context, binaryPath string, extensionId string) error {
_, err := platform.RunAsCurrentUserWithPathEnv(ctx, binaryPath, "--uninstall-extension", extensionId)
return err
}
Loading