From f4ba4e0f64dc906fdb9ac0bc828af509204d6800 Mon Sep 17 00:00:00 2001 From: jkoberg Date: Wed, 15 Mar 2023 15:21:45 +0100 Subject: [PATCH] backport antivirus from experimental Signed-off-by: jkoberg --- Makefile | 1 + ocis-pkg/config/config.go | 2 + ocis-pkg/config/defaultconfig.go | 2 + ocis/pkg/command/antivirus.go | 30 +++ services/antivirus/Makefile | 37 ++++ services/antivirus/README.md | 0 services/antivirus/cmd/antivirus/main.go | 14 ++ services/antivirus/pkg/command/health.go | 61 ++++++ services/antivirus/pkg/command/root.go | 54 +++++ services/antivirus/pkg/command/server.go | 107 +++++++++ services/antivirus/pkg/command/version.go | 26 +++ services/antivirus/pkg/config/config.go | 72 ++++++ .../pkg/config/defaults/defaultconfig.go | 54 +++++ services/antivirus/pkg/config/parser/parse.go | 38 ++++ services/antivirus/pkg/scanners/clamav.go | 35 +++ services/antivirus/pkg/scanners/icap.go | 68 ++++++ services/antivirus/pkg/scanners/scanners.go | 34 +++ services/antivirus/pkg/service/service.go | 205 ++++++++++++++++++ .../policies/pkg/service/event/service.go | 3 +- .../postprocessing/pkg/service/service.go | 1 - 20 files changed, 842 insertions(+), 2 deletions(-) create mode 100644 ocis/pkg/command/antivirus.go create mode 100644 services/antivirus/Makefile create mode 100644 services/antivirus/README.md create mode 100644 services/antivirus/cmd/antivirus/main.go create mode 100644 services/antivirus/pkg/command/health.go create mode 100644 services/antivirus/pkg/command/root.go create mode 100644 services/antivirus/pkg/command/server.go create mode 100644 services/antivirus/pkg/command/version.go create mode 100644 services/antivirus/pkg/config/config.go create mode 100644 services/antivirus/pkg/config/defaults/defaultconfig.go create mode 100644 services/antivirus/pkg/config/parser/parse.go create mode 100644 services/antivirus/pkg/scanners/clamav.go create mode 100644 services/antivirus/pkg/scanners/icap.go create mode 100644 services/antivirus/pkg/scanners/scanners.go create mode 100644 services/antivirus/pkg/service/service.go diff --git a/Makefile b/Makefile index 63eed85996..056c7c57a5 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ L10N_MODULES := \ # if you add a module here please also add it to the .drone.star file OCIS_MODULES = \ + services/antivirus \ services/app-provider \ services/app-registry \ services/audit \ diff --git a/ocis-pkg/config/config.go b/ocis-pkg/config/config.go index 3d368f832c..1b10a1e741 100644 --- a/ocis-pkg/config/config.go +++ b/ocis-pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "github.com/owncloud/ocis/v2/ocis-pkg/shared" + antivirus "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" appProvider "github.com/owncloud/ocis/v2/services/app-provider/pkg/config" appRegistry "github.com/owncloud/ocis/v2/services/app-registry/pkg/config" audit "github.com/owncloud/ocis/v2/services/audit/pkg/config" @@ -72,6 +73,7 @@ type Config struct { AdminUserID string `yaml:"admin_user_id" env:"OCIS_ADMIN_USER_ID" desc:"ID of a user, that should receive admin privileges. Consider that the UUID can be encoded in some LDAP deployment configurations like in .ldif files. These need to be decoded beforehand."` Runtime Runtime `yaml:"runtime"` + Antivirus *antivirus.Config `yaml:"antivirus"` AppProvider *appProvider.Config `yaml:"app_provider"` AppRegistry *appRegistry.Config `yaml:"app_registry"` Audit *audit.Config `yaml:"audit"` diff --git a/ocis-pkg/config/defaultconfig.go b/ocis-pkg/config/defaultconfig.go index d59571d115..89579f7d8f 100644 --- a/ocis-pkg/config/defaultconfig.go +++ b/ocis-pkg/config/defaultconfig.go @@ -1,6 +1,7 @@ package config import ( + antivirus "github.com/owncloud/ocis/v2/services/antivirus/pkg/config/defaults" appProvider "github.com/owncloud/ocis/v2/services/app-provider/pkg/config/defaults" appRegistry "github.com/owncloud/ocis/v2/services/app-registry/pkg/config/defaults" audit "github.com/owncloud/ocis/v2/services/audit/pkg/config/defaults" @@ -45,6 +46,7 @@ func DefaultConfig() *Config { Host: "localhost", }, + Antivirus: antivirus.DefaultConfig(), AppProvider: appProvider.DefaultConfig(), AppRegistry: appRegistry.DefaultConfig(), Audit: audit.DefaultConfig(), diff --git a/ocis/pkg/command/antivirus.go b/ocis/pkg/command/antivirus.go new file mode 100644 index 0000000000..5671930a9d --- /dev/null +++ b/ocis/pkg/command/antivirus.go @@ -0,0 +1,30 @@ +package command + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/config" + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/ocis-pkg/config/parser" + "github.com/owncloud/ocis/v2/ocis/pkg/command/helper" + "github.com/owncloud/ocis/v2/ocis/pkg/register" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/command" + "github.com/urfave/cli/v2" +) + +// AntivirusCommand is the entrypoint for the antivirus command. +func AntivirusCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: cfg.Antivirus.Service.Name, + Usage: helper.SubcommandDescription(cfg.Antivirus.Service.Name), + Category: "services", + Before: func(c *cli.Context) error { + configlog.Error(parser.ParseConfig(cfg, true)) + //cfg.Antivirus.Commons = cfg.Commons + return nil + }, + Subcommands: command.GetCommands(cfg.Antivirus), + } +} + +func init() { + register.AddCommand(AntivirusCommand) +} diff --git a/services/antivirus/Makefile b/services/antivirus/Makefile new file mode 100644 index 0000000000..2220cb7c1c --- /dev/null +++ b/services/antivirus/Makefile @@ -0,0 +1,37 @@ +SHELL := bash +NAME := antivirus + +include ../../.make/recursion.mk + +############ tooling ############ +ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI +include ../../.bingo/Variables.mk +endif + +############ go tooling ############ +include ../../.make/go.mk + +############ release ############ +include ../../.make/release.mk + +############ docs generate ############ +include ../../.make/docs.mk + +.PHONY: docs-generate +docs-generate: config-docs-generate + +############ generate ############ +include ../../.make/generate.mk + +.PHONY: ci-go-generate +ci-go-generate: # CI runs ci-node-generate automatically before this target + +.PHONY: ci-node-generate +ci-node-generate: + +############ licenses ############ +.PHONY: ci-node-check-licenses +ci-node-check-licenses: + +.PHONY: ci-node-save-licenses +ci-node-save-licenses: diff --git a/services/antivirus/README.md b/services/antivirus/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/antivirus/cmd/antivirus/main.go b/services/antivirus/cmd/antivirus/main.go new file mode 100644 index 0000000000..b37a1ca3ed --- /dev/null +++ b/services/antivirus/cmd/antivirus/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "os" + + "github.com/owncloud/ocis/v2/services/antivirus/pkg/command" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config/defaults" +) + +func main() { + if err := command.Execute(defaults.DefaultConfig()); err != nil { + os.Exit(1) + } +} diff --git a/services/antivirus/pkg/command/health.go b/services/antivirus/pkg/command/health.go new file mode 100644 index 0000000000..d348184caf --- /dev/null +++ b/services/antivirus/pkg/command/health.go @@ -0,0 +1,61 @@ +package command + +import ( + "fmt" + "net/http" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config/parser" + "github.com/urfave/cli/v2" +) + +// Health is the entrypoint for the health command. +func Health(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "health", + Usage: "check health status", + Category: "info", + Before: func(c *cli.Context) error { + return configlog.ReturnError(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + logger := log.NewLogger( + log.Name(cfg.Service.Name), + log.Level(cfg.Log.Level), + log.Pretty(cfg.Log.Pretty), + log.Color(cfg.Log.Color), + log.File(cfg.Log.File), + ) + + resp, err := http.Get( + fmt.Sprintf( + "http://%s/healthz", + cfg.Debug.Addr, + ), + ) + + if err != nil { + logger.Fatal(). + Err(err). + Msg("Failed to request health check") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Fatal(). + Int("code", resp.StatusCode). + Msg("Health seems to be in bad state") + } + + logger.Debug(). + Int("code", resp.StatusCode). + Msg("Health got a good state") + + return nil + }, + } +} diff --git a/services/antivirus/pkg/command/root.go b/services/antivirus/pkg/command/root.go new file mode 100644 index 0000000000..5f35285c75 --- /dev/null +++ b/services/antivirus/pkg/command/root.go @@ -0,0 +1,54 @@ +package command + +import ( + "context" + "os" + + "github.com/owncloud/ocis/v2/ocis-pkg/clihelper" + ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" + "github.com/thejerf/suture/v4" + "github.com/urfave/cli/v2" +) + +// GetCommands provides all commands for this service +func GetCommands(cfg *config.Config) cli.Commands { + return []*cli.Command{ + Server(cfg), + Health(cfg), + Version(cfg), + } +} + +// Execute is the entry point for the antivirus command. +func Execute(cfg *config.Config) error { + app := clihelper.DefaultApp(&cli.App{ + Name: "antivirus", + Usage: "Serve ownCloud antivirus for oCIS", + Commands: GetCommands(cfg), + }) + + return app.Run(os.Args) +} + +// SutureService allows for the web command to be embedded and supervised by a suture supervisor tree. +type SutureService struct { + cfg *config.Config +} + +// NewSutureService creates a new web.SutureService +func NewSutureService(cfg *ociscfg.Config) suture.Service { + cfg.Policies.Commons = cfg.Commons + return SutureService{ + cfg: cfg.Antivirus, + } +} + +func (s SutureService) Serve(ctx context.Context) error { + s.cfg.Context = ctx + if err := Execute(s.cfg); err != nil { + return err + } + + return nil +} diff --git a/services/antivirus/pkg/command/server.go b/services/antivirus/pkg/command/server.go new file mode 100644 index 0000000000..d1de27fbdf --- /dev/null +++ b/services/antivirus/pkg/command/server.go @@ -0,0 +1,107 @@ +package command + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/oklog/run" + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/ocis-pkg/service/debug" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/service" + "github.com/urfave/cli/v2" +) + +// Server is the entrypoint for the server command. +func Server(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "server", + Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", "authz"), + Category: "server", + Before: func(c *cli.Context) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + var ( + gr = run.Group{} + ctx, cancel = func() (context.Context, context.CancelFunc) { + if cfg.Context == nil { + return context.WithCancel(context.Background()) + } + return context.WithCancel(cfg.Context) + }() + logger = log.NewLogger( + log.Name(cfg.Service.Name), + log.Level(cfg.Log.Level), + log.Pretty(cfg.Log.Pretty), + log.Color(cfg.Log.Color), + log.File(cfg.Log.File), + ) + ) + defer cancel() + + { + svc, err := service.NewAntivirus(cfg, logger) + if err != nil { + return err + } + + gr.Add(svc.Run, func(_ error) { + cancel() + }) + } + + { + server := debug.NewService( + debug.Logger(logger), + debug.Name(cfg.Service.Name), + debug.Version(version.GetString()), + debug.Address(cfg.Debug.Addr), + debug.Token(cfg.Debug.Token), + debug.Pprof(cfg.Debug.Pprof), + debug.Zpages(cfg.Debug.Zpages), + debug.Health( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + // TODO: check if services are up and running + + _, err := io.WriteString(w, http.StatusText(http.StatusOK)) + // io.WriteString should not fail but if it does we want to know. + if err != nil { + panic(err) + } + }, + ), + debug.Ready( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + // TODO: check if services are up and running + + _, err := io.WriteString(w, http.StatusText(http.StatusOK)) + // io.WriteString should not fail but if it does we want to know. + if err != nil { + panic(err) + } + }, + ), + ) + + gr.Add(server.ListenAndServe, func(_ error) { + _ = server.Shutdown(ctx) + cancel() + }) + } + + return gr.Run() + }, + } +} diff --git a/services/antivirus/pkg/command/version.go b/services/antivirus/pkg/command/version.go new file mode 100644 index 0000000000..3286c932c9 --- /dev/null +++ b/services/antivirus/pkg/command/version.go @@ -0,0 +1,26 @@ +package command + +import ( + "fmt" + + "github.com/owncloud/ocis/v2/ocis-pkg/version" + + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" + "github.com/urfave/cli/v2" +) + +// Version prints the service versions of all running instances. +func Version(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "version", + Usage: "print the version of this binary and the running service instances", + Category: "info", + Action: func(c *cli.Context) error { + fmt.Println("Version: " + version.GetString()) + fmt.Printf("Compiled: %s\n", version.Compiled()) + fmt.Println("") + + return nil + }, + } +} diff --git a/services/antivirus/pkg/config/config.go b/services/antivirus/pkg/config/config.go new file mode 100644 index 0000000000..f351027eb9 --- /dev/null +++ b/services/antivirus/pkg/config/config.go @@ -0,0 +1,72 @@ +package config + +import ( + "context" +) + +// Config combines all available configuration parts. +type Config struct { + File string + Log *Log + + Debug Debug `mask:"struct" yaml:"debug"` + + Service Service `yaml:"-"` + + InfectedFileHandling string `yaml:"infected-file-handling" env:"ANTIVIRUS_INFECTED_FILE_HANDLING" desc:"Defines the behaviour when a virus has been found. Options are: 'delete', 'continue' and 'abort '. Delete will delete the file. Continue will mark the file as infected but continues further processing. Abort will keep the file in the upload folder for further admin inspection and will not move it to its target space."` + Events Events + Scanner Scanner + MaxScanSize string `yaml:"max-scan-size" env:"ANTIVIRUS_MAX_SCAN_SIZE" desc:"The maximum scan size the virusscanner can handle. Only that much bytes of a file will be scanned. 0 means unlimited and is the default. Usable common abbreviations: [KB, KiB, GB, GiB, TB, TiB, PB, PiB, EB, EiB], example: 2GB."` + + Context context.Context `yaml:"-" json:"-"` +} + +// Service defines the available service configuration. +type Service struct { + Name string `yaml:"-"` +} + +// Log defines the available log configuration. +type Log struct { + Level string `mapstructure:"level" env:"OCIS_LOG_LEVEL;POLICIES_LOG_LEVEL" desc:"The log level. Valid values are: \"panic\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\"."` + Pretty bool `mapstructure:"pretty" env:"OCIS_LOG_PRETTY;POLICIES_LOG_PRETTY" desc:"Activates pretty log output."` + Color bool `mapstructure:"color" env:"OCIS_LOG_COLOR;POLICIES_LOG_COLOR" desc:"Activates colorized log output."` + File string `mapstructure:"file" env:"OCIS_LOG_FILE;POLICIES_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."` +} + +// Debug defines the available debug configuration. +type Debug struct { + Addr string `yaml:"addr" env:"POLICIES_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed."` + Token string `yaml:"token" env:"POLICIES_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint."` + Pprof bool `yaml:"pprof" env:"POLICIES_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling."` + Zpages bool `yaml:"zpages" env:"POLICIES_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."` +} + +// Events combines the configuration options for the event bus. +type Events struct { + Endpoint string `yaml:"endpoint" env:"USERLOG_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture."` + Cluster string `yaml:"cluster" env:"USERLOG_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Mandatory when using NATS as event system."` + TLSInsecure bool `yaml:"tls_insecure" env:"OCIS_INSECURE;USERLOG_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates."` + TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"USERLOG_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided NOTIFICATIONS_EVENTS_TLS_INSECURE will be seen as false."` + EnableTLS bool `yaml:"enable_tls" env:"OCIS_EVENTS_ENABLE_TLS;USERLOG_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the ocis service which receives and delivers events between the services.."` +} + +// Scanner provides configuration options for the antivirusscanner +type Scanner struct { + Type string `yaml:"type" env:"ANTIVIRUS_SCANNER_TYPE" desc:"The scanner to use. Must be one of: clamav, icap"` + + ClamAV ClamAV // only if Type == clamav + ICAP ICAP // only if Type == icap +} + +// ClamAV provides configuration option for clamav +type ClamAV struct { + Socket string `yaml:"socket" env:"ANTIVIRUS_CLAMAV_SOCKET" desc:"The socket clamav is running on. Note the default value is an example which needs adaption according your OS."` +} + +// ICAP provides configuration option for ICAP +type ICAP struct { + Timeout int64 `yaml:"timeout" env:"ANTIVIRUS_ICAP_TIMEOUT" desc:"Timeout for the ICAP client."` + URL string `yaml:"url" env:"ANTIVIRUS_ICAP_URL" desc:"URL of the ICAP server."` + Service string `yaml:"service" env:"ANTIVIRUS_ICAP_SERVICE" desc:"Name of the ICAP server."` +} diff --git a/services/antivirus/pkg/config/defaults/defaultconfig.go b/services/antivirus/pkg/config/defaults/defaultconfig.go new file mode 100644 index 0000000000..90bc649a34 --- /dev/null +++ b/services/antivirus/pkg/config/defaults/defaultconfig.go @@ -0,0 +1,54 @@ +package defaults + +import ( + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" +) + +// FullDefaultConfig returns a fully initialized default configuration which is needed for doc generation. +func FullDefaultConfig() *config.Config { + cfg := DefaultConfig() + EnsureDefaults(cfg) + Sanitize(cfg) + return cfg +} + +// DefaultConfig returns the services default config +func DefaultConfig() *config.Config { + return &config.Config{ + Debug: config.Debug{ + Addr: "127.0.0.1:9277", + Token: "", + }, + Service: config.Service{ + Name: "antivirus", + }, + Events: config.Events{ + Endpoint: "127.0.0.1:9233", + Cluster: "ocis-cluster", + }, + InfectedFileHandling: "delete", + Scanner: config.Scanner{ + Type: "clamav", + ClamAV: config.ClamAV{ + Socket: "/run/clamav/clamd.ctl", + }, + ICAP: config.ICAP{ + URL: "icap://127.0.0.1:1344", + Service: "avscan", + Timeout: 300, + }, + }, + } +} + +// EnsureDefaults adds default values to the configuration if they are not set yet +func EnsureDefaults(cfg *config.Config) { + if cfg.Log == nil { + cfg.Log = &config.Log{} + } +} + +// Sanitize sanitizes the configuration +func Sanitize(cfg *config.Config) { + +} diff --git a/services/antivirus/pkg/config/parser/parse.go b/services/antivirus/pkg/config/parser/parse.go new file mode 100644 index 0000000000..a8ab37073d --- /dev/null +++ b/services/antivirus/pkg/config/parser/parse.go @@ -0,0 +1,38 @@ +package parser + +import ( + "errors" + + ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config/defaults" + + "github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode" +) + +// ParseConfig loads configuration from known paths. +func ParseConfig(cfg *config.Config) error { + _, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg) + if err != nil { + return err + } + + defaults.EnsureDefaults(cfg) + + // load all env variables relevant to the config in the current context. + if err := envdecode.Decode(cfg); err != nil { + // no environment variable set for this config is an expected "error" + if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { + return err + } + } + + defaults.Sanitize(cfg) + + return Validate(cfg) +} + +// Validate validates our little config +func Validate(cfg *config.Config) error { + return nil +} diff --git a/services/antivirus/pkg/scanners/clamav.go b/services/antivirus/pkg/scanners/clamav.go new file mode 100644 index 0000000000..4f5b8bddb3 --- /dev/null +++ b/services/antivirus/pkg/scanners/clamav.go @@ -0,0 +1,35 @@ +package scanners + +import ( + "io" + "time" + + "github.com/dutchcoders/go-clamd" +) + +// NewClamAV returns an Scanner talking to clamAV via socket +func NewClamAV(socket string) *ClamAV { + return &ClamAV{ + clamd: clamd.NewClamd(socket), + } +} + +// ClamAV is a Scanner based on clamav +type ClamAV struct { + clamd *clamd.Clamd +} + +// Scan to fulfill Scanner interface +func (s ClamAV) Scan(file io.Reader) (ScanResult, error) { + ch, err := s.clamd.ScanStream(file, make(chan bool)) + if err != nil { + return ScanResult{}, err + } + + r := <-ch + return ScanResult{ + Infected: r.Status == clamd.RES_FOUND, + Description: r.Description, + Scantime: time.Now(), + }, nil +} diff --git a/services/antivirus/pkg/scanners/icap.go b/services/antivirus/pkg/scanners/icap.go new file mode 100644 index 0000000000..7b426c0326 --- /dev/null +++ b/services/antivirus/pkg/scanners/icap.go @@ -0,0 +1,68 @@ +package scanners + +import ( + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "time" + + ic "github.com/egirna/icap-client" +) + +// NewICAP returns a Scanner talking to an ICAP server +func NewICAP(icapURL string, icapService string, timeout time.Duration) (ICAP, error) { + endpoint, err := url.Parse(icapURL) + if err != nil { + return ICAP{}, err + } + + endpoint.Scheme = "icap" + endpoint.Path = icapService + + return ICAP{ + client: &ic.Client{ + Timeout: timeout, + }, + endpoint: endpoint.String(), + }, nil +} + +// ICAP is a Scanner talking to an ICAP server +type ICAP struct { + client *ic.Client + endpoint string +} + +// Scan to fulfill Scanner interface +func (s ICAP) Scan(file io.Reader) (ScanResult, error) { + sr := ScanResult{} + + httpReq, err := http.NewRequest(http.MethodGet, "http://localhost", file) + if err != nil { + return sr, err + } + + req, err := ic.NewRequest(ic.MethodREQMOD, s.endpoint, httpReq, nil) + if err != nil { + return sr, err + } + + resp, err := s.client.Do(req) + if err != nil { + return sr, err + } + + if data, infected := resp.Header["X-Infection-Found"]; infected { + sr.Infected = infected + re := regexp.MustCompile(`Threat=(.*);`) + match := re.FindStringSubmatch(fmt.Sprint(data)) + + if len(match) > 1 { + sr.Description = match[1] + } + } + + return sr, nil +} diff --git a/services/antivirus/pkg/scanners/scanners.go b/services/antivirus/pkg/scanners/scanners.go new file mode 100644 index 0000000000..ef2fe46481 --- /dev/null +++ b/services/antivirus/pkg/scanners/scanners.go @@ -0,0 +1,34 @@ +package scanners + +import ( + "fmt" + "io" + "time" + + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" +) + +// ScanResult is the common scan result to all scanners +type ScanResult struct { + Infected bool + Scantime time.Time + Description string +} + +// Scanner is an abstraction for the actual virus scan +type Scanner interface { + Scan(file io.Reader) (ScanResult, error) +} + +// New returns a new scanner from config +func New(c config.Scanner) (Scanner, error) { + switch c.Type { + default: + return nil, fmt.Errorf("unknown av scanner: '%s'", c.Type) + case "clamav": + return NewClamAV(c.ClamAV.Socket), nil + case "icap": + return NewICAP(c.ICAP.URL, c.ICAP.Service, time.Duration(c.ICAP.Timeout)*time.Second) + } + +} diff --git a/services/antivirus/pkg/service/service.go b/services/antivirus/pkg/service/service.go new file mode 100644 index 0000000000..a56793c18f --- /dev/null +++ b/services/antivirus/pkg/service/service.go @@ -0,0 +1,205 @@ +package service + +import ( + "bytes" + "context" + "crypto/x509" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/cs3org/reva/v2/pkg/bytesize" + ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/cs3org/reva/v2/pkg/events" + "github.com/cs3org/reva/v2/pkg/events/stream" + "github.com/cs3org/reva/v2/pkg/rhttp" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/config" + "github.com/owncloud/ocis/v2/services/antivirus/pkg/scanners" +) + +// Scanner is an abstraction for the actual virus scan +type Scanner interface { + Scan(file io.Reader) (scanners.ScanResult, error) +} + +// NewAntivirus returns a service implementation for Service. +func NewAntivirus(c *config.Config, l log.Logger) (Antivirus, error) { + av := Antivirus{c: c, l: l, client: rhttp.GetHTTPClient(rhttp.Insecure(true))} + + var err error + av.s, err = scanners.New(c.Scanner) + if err != nil { + return av, err + } + + switch o := events.PostprocessingOutcome(c.InfectedFileHandling); o { + case events.PPOutcomeContinue, events.PPOutcomeAbort, events.PPOutcomeDelete: + av.o = o + default: + return av, fmt.Errorf("unknown infected file handling '%s'", o) + } + + if c.MaxScanSize != "" { + b, err := bytesize.Parse(c.MaxScanSize) + if err != nil { + return av, err + } + + av.m = b.Bytes() + } + + return av, nil +} + +// Antivirus defines implements the business logic for Service. +type Antivirus struct { + c *config.Config + l log.Logger + s Scanner + o events.PostprocessingOutcome + m uint64 + + client *http.Client +} + +// Run runs the service +func (av Antivirus) Run() error { + evtsCfg := av.c.Events + + var rootCAPool *x509.CertPool + if evtsCfg.TLSRootCACertificate != "" { + rootCrtFile, err := os.Open(evtsCfg.TLSRootCACertificate) + if err != nil { + return err + } + + var certBytes bytes.Buffer + if _, err := io.Copy(&certBytes, rootCrtFile); err != nil { + return err + } + + rootCAPool = x509.NewCertPool() + rootCAPool.AppendCertsFromPEM(certBytes.Bytes()) + evtsCfg.TLSInsecure = false + } + + stream, err := stream.NatsFromConfig(stream.NatsConfig(av.c.Events)) + if err != nil { + return err + } + + ch, err := events.Consume(stream, "antivirus", events.StartPostprocessingStep{}) + if err != nil { + return err + } + + for e := range ch { + ev := e.Event.(events.StartPostprocessingStep) + if ev.StepToStart != events.PPStepAntivirus { + continue + } + + var errmsg string + res, err := av.process(ev) + if err != nil { + errmsg = err.Error() + } + + outcome := events.PPOutcomeContinue + if res.Infected { + outcome = av.o + } + + av.l.Info().Str("uploadid", ev.UploadID).Interface("resourceID", ev.ResourceID).Str("virus", res.Description).Str("outcome", string(outcome)).Str("filename", ev.Filename).Str("user", ev.ExecutingUser.GetId().GetOpaqueId()).Bool("infected", res.Infected).Msg("File scanned") + if err := events.Publish(stream, events.PostprocessingStepFinished{ + Outcome: outcome, + UploadID: ev.UploadID, + ExecutingUser: ev.ExecutingUser, + Filename: ev.Filename, + Result: events.VirusscanResult{ + Infected: res.Infected, + Description: res.Description, + Scandate: time.Now(), + ResourceID: ev.ResourceID, + ErrorMsg: errmsg, + }, + }); err != nil { + av.l.Fatal().Err(err).Str("uploadid", ev.UploadID).Interface("resourceID", ev.ResourceID).Msg("cannot publish events - exiting") + return err + } + } + + return nil +} + +// process the scan +func (av Antivirus) process(ev events.StartPostprocessingStep) (scanners.ScanResult, error) { + if ev.Filesize == 0 || (0 < av.m && av.m < ev.Filesize) { + return scanners.ScanResult{ + Scantime: time.Now(), + }, nil + } + + var err error + var rrc io.ReadCloser + + switch ev.UploadID { + default: + rrc, err = av.downloadViaToken(ev.URL) + case "": + rrc, err = av.downloadViaReva(ev.URL, ev.Token, ev.RevaToken) + } + if err != nil { + av.l.Error().Err(err).Str("uploadid", ev.UploadID).Msg("error downloading file") + return scanners.ScanResult{}, err + } + defer rrc.Close() + + res, err := av.s.Scan(rrc) + if err != nil { + av.l.Error().Err(err).Str("uploadid", ev.UploadID).Msg("error scanning file") + } + + return res, err + +} + +// download will download the file +func (av Antivirus) downloadViaToken(url string) (io.ReadCloser, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + return av.doDownload(req) +} + +// download will download the file +func (av Antivirus) downloadViaReva(url string, dltoken string, revatoken string) (io.ReadCloser, error) { + ctx := ctxpkg.ContextSetToken(context.Background(), revatoken) + + req, err := rhttp.NewRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Reva-Transfer", dltoken) + + return av.doDownload(req) +} + +func (av Antivirus) doDownload(req *http.Request) (io.ReadCloser, error) { + res, err := av.client.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusOK { + res.Body.Close() + return nil, fmt.Errorf("unexpected status code from Download %v", res.StatusCode) + } + + return res.Body, nil +} diff --git a/services/policies/pkg/service/event/service.go b/services/policies/pkg/service/event/service.go index 054a539798..40d455e8b7 100644 --- a/services/policies/pkg/service/event/service.go +++ b/services/policies/pkg/service/event/service.go @@ -2,6 +2,7 @@ package eventSVC import ( "context" + "github.com/cs3org/reva/v2/pkg/events" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/policies/pkg/engine" @@ -37,7 +38,7 @@ func (s Service) Run() error { for e := range ch { switch ev := e.Event.(type) { case events.StartPostprocessingStep: - if ev.StepToStart != "policies" { + if ev.StepToStart != events.PPStepPolicies { continue } diff --git a/services/postprocessing/pkg/service/service.go b/services/postprocessing/pkg/service/service.go index f8a8af0f06..020d454410 100644 --- a/services/postprocessing/pkg/service/service.go +++ b/services/postprocessing/pkg/service/service.go @@ -21,7 +21,6 @@ func NewPostprocessingService(stream events.Stream, logger log.Logger, c config. evs, err := events.Consume(stream, "postprocessing", events.BytesReceived{}, events.StartPostprocessingStep{}, - events.VirusscanFinished{}, events.UploadReady{}, events.PostprocessingStepFinished{}, )