Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,51 @@
package scanners
import (
"fmt"
"time"
"github.com/dutchcoders/go-clamd"
)
// NewClamAV returns a Scanner talking to clamAV via socket
func NewClamAV(socket string, timeout time.Duration) (*ClamAV, error) {
c := clamd.NewClamd(socket)
if err := c.Ping(); err != nil {
return nil, fmt.Errorf("%w: %w", ErrScannerNotReachable, err)
}
return &ClamAV{
clamd: clamd.NewClamd(socket),
timeout: timeout,
}, nil
}
// ClamAV is a Scanner based on clamav
type ClamAV struct {
clamd *clamd.Clamd
timeout time.Duration
}
// Scan to fulfill Scanner interface
func (s ClamAV) Scan(in Input) (Result, error) {
abort := make(chan bool, 1)
defer close(abort)
ch, err := s.clamd.ScanStream(in.Body, abort)
if err != nil {
return Result{}, err
}
select {
case <-time.After(s.timeout):
abort <- true
return Result{}, fmt.Errorf("%w: %s", ErrScanTimeout, in.Url)
case s := <-ch:
return Result{
Infected: s.Status == clamd.RES_FOUND,
Description: s.Description,
ScanTime: time.Now(),
}, nil
}
}
@@ -0,0 +1,120 @@
package scanners_test
import (
"context"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/services/antivirus/pkg/scanners"
)
func newUnixListener(t testing.TB, lc net.ListenConfig, v ...string) net.Listener {
d, err := os.MkdirTemp("", "")
assert.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, os.RemoveAll(d))
})
nl, err := lc.Listen(context.Background(), "unix", filepath.Join(d, "sock"))
require.NoError(t, err)
go func() {
i := 0
for {
if len(v) == i {
break
}
conn, err := nl.Accept()
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
_, err = conn.Write([]byte(v[i]))
require.NoError(t, err)
require.NoError(t, conn.Close())
i++
}
}()
return nl
}
func TestNewClamAV(t *testing.T) {
t.Run("returns a scanner", func(t *testing.T) {
ul := newUnixListener(t, net.ListenConfig{}, "PONG\n")
defer func() {
assert.NoError(t, ul.Close())
}()
done := make(chan bool, 1)
go func() {
_, err := scanners.NewClamAV(ul.Addr().String(), 10*time.Second)
assert.NoError(t, err)
done <- true
}()
assert.True(t, <-done)
})
t.Run("fails if scanner is not pingable", func(t *testing.T) {
_, err := scanners.NewClamAV("", 0)
assert.ErrorIs(t, err, scanners.ErrScannerNotReachable)
})
}
func TestNewClamAV_Scan(t *testing.T) {
t.Run("returns a result", func(t *testing.T) {
ul := newUnixListener(t, net.ListenConfig{}, "PONG\n", "stream: Win.Test.EICAR_HDB-1 FOUND\n")
defer func() {
assert.NoError(t, ul.Close())
}()
done := make(chan bool, 1)
go func() {
scanner, err := scanners.NewClamAV(ul.Addr().String(), 10*time.Second)
assert.NoError(t, err)
result, err := scanner.Scan(scanners.Input{Body: strings.NewReader("DATA")})
assert.NoError(t, err)
assert.Equal(t, result.Description, "Win.Test.EICAR_HDB-1")
assert.True(t, result.Infected)
done <- true
}()
assert.True(t, <-done)
})
t.Run("aborts after a certain time", func(t *testing.T) {
ul := newUnixListener(t, net.ListenConfig{}, "PONG\n", "stream: Win.Test.EICAR_HDB-1 FOUND\n")
defer func() {
assert.NoError(t, ul.Close())
}()
done := make(chan bool, 1)
go func() {
scanner, err := scanners.NewClamAV(ul.Addr().String(), 10*time.Second)
assert.NoError(t, err)
result, err := scanner.Scan(scanners.Input{Body: strings.NewReader("DATA")})
assert.NoError(t, err)
assert.Equal(t, result.Description, "Win.Test.EICAR_HDB-1")
assert.True(t, result.Infected)
done <- true
}()
assert.True(t, <-done)
})
}
@@ -0,0 +1,112 @@
package scanners
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"time"
"github.com/opencloud-eu/reva/v2/pkg/mime"
ic "github.com/opencloud-eu/icap-client"
)
// Scanner is the interface that wraps the basic Do method
type Scanner interface {
Do(req ic.Request) (ic.Response, error)
}
// 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
client, err := ic.NewClient(
ic.WithICAPConnectionTimeout(timeout),
)
if err != nil {
return ICAP{}, err
}
return ICAP{Client: &client, URL: endpoint.String()}, nil
}
// ICAP is responsible for scanning files using an ICAP server
type ICAP struct {
Client Scanner
URL string
}
// Scan scans a file using the ICAP server
func (s ICAP) Scan(in Input) (Result, error) {
ctx := context.TODO()
result := Result{}
optReq, err := ic.NewRequest(ctx, ic.MethodOPTIONS, s.URL, nil, nil)
if err != nil {
return result, err
}
optRes, err := s.Client.Do(optReq)
if err != nil {
return result, err
}
httpReq, err := http.NewRequest(http.MethodPost, in.Url, in.Body)
if err != nil {
return result, err
}
httpReq.ContentLength = in.Size
if mt := mime.Detect(path.Ext(in.Name) == "", in.Name); mt != "" {
httpReq.Header.Set("Content-Type", mt)
}
req, err := ic.NewRequest(ctx, ic.MethodREQMOD, s.URL, httpReq, nil)
if err != nil {
return result, err
}
if optRes.PreviewBytes > 0 {
err = req.SetPreview(optRes.PreviewBytes)
if err != nil {
return result, err
}
}
res, err := s.Client.Do(req)
if err != nil {
return result, err
}
result.ScanTime = time.Now()
// TODO: make header configurable
if data, infected := res.Header["X-Infection-Found"]; infected {
result.Infected = infected
match := regexp.MustCompile(`Threat=(.*);`).FindStringSubmatch(fmt.Sprint(data))
if len(match) > 1 {
result.Description = match[1]
}
}
if result.Infected || res.ContentResponse == nil {
return result, nil
}
// mcafee forwards the scan result as HTML in the content response;
// status 403 indicates that the file is infected
result.Infected = res.ContentResponse.StatusCode == http.StatusForbidden
result.Description = res.ContentResponse.Status
return result, nil
}
@@ -0,0 +1,187 @@
package scanners_test
import (
"bytes"
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
ic "github.com/opencloud-eu/icap-client"
"github.com/qsfera/server/services/antivirus/pkg/scanners"
"github.com/qsfera/server/services/antivirus/pkg/scanners/mocks"
)
func TestICAP_Scan(t *testing.T) {
var (
earlyExitErr = errors.New("stop here")
testUrl = "icap://test"
client = mocks.NewScanner(t)
scanner = &scanners.ICAP{Client: client, URL: testUrl}
)
t.Run("it sends a OPTIONS request to determine details", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, ic.MethodOPTIONS, request.Method)
assert.Equal(t, testUrl, request.URL.String())
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{})
assert.ErrorIs(t, earlyExitErr, err) // we can exit early, just in case check the error to be identical to the early exit error
})
t.Run("it sends a REQMOD request with all the details", func(t *testing.T) {
t.Run("request with ContentLength", func(t *testing.T) {
t.Run("with size", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, ic.MethodREQMOD, request.Method)
assert.Equal(t, testUrl, request.URL.String())
assert.EqualValues(t, 999, request.HTTPRequest.ContentLength)
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{Size: 999})
assert.ErrorIs(t, earlyExitErr, err)
})
t.Run("without size", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, ic.MethodREQMOD, request.Method)
assert.Equal(t, testUrl, request.URL.String())
assert.EqualValues(t, 0, request.HTTPRequest.ContentLength)
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{})
assert.ErrorIs(t, earlyExitErr, err)
})
})
t.Run("request with Content-Type header", func(t *testing.T) {
t.Run("name contains known extension", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, "application/pdf", request.HTTPRequest.Header.Get("Content-Type"))
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{Name: "report.pdf"})
assert.ErrorIs(t, earlyExitErr, err)
})
t.Run("name with unknown extension", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, "application/octet-stream", request.HTTPRequest.Header.Get("Content-Type"))
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{Name: "report.unknown"})
assert.ErrorIs(t, earlyExitErr, err)
})
t.Run("name without extension", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, "httpd/unix-directory", request.HTTPRequest.Header.Get("Content-Type"))
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{Name: "report"})
assert.ErrorIs(t, earlyExitErr, err)
})
})
t.Run("request with the OPTIONS response preview size ", func(t *testing.T) {
t.Run("with PreviewBytes set", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{PreviewBytes: 444}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, 444, request.PreviewBytes)
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{Body: bytes.NewReader(make([]byte, 888))})
assert.ErrorIs(t, earlyExitErr, err)
})
t.Run("without PreviewBytes set", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, 0, request.PreviewBytes)
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{Body: bytes.NewReader(make([]byte, 888))})
assert.ErrorIs(t, earlyExitErr, err)
})
})
})
t.Run("request with the OPTIONS response preview size ", func(t *testing.T) {
t.Run("with PreviewBytes set", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{PreviewBytes: 444}, nil).Once()
client.EXPECT().Do(mock.Anything).RunAndReturn(func(request ic.Request) (ic.Response, error) {
assert.Equal(t, 444, request.PreviewBytes)
return ic.Response{}, earlyExitErr
}).Once()
_, err := scanner.Scan(scanners.Input{Body: bytes.NewReader(make([]byte, 888))})
assert.ErrorIs(t, earlyExitErr, err)
})
t.Run("it handles virus scan results", func(t *testing.T) {
t.Run("no virus", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
result, err := scanner.Scan(scanners.Input{})
assert.Nil(t, err)
assert.False(t, result.Infected)
})
// clamav returns an X-Infection-Found header with the threat description
t.Run("X-Infection-Found header ", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).Return(ic.Response{Header: http.Header{"X-Infection-Found": []string{"Threat=bad threat;"}}}, nil).Once()
result, err := scanner.Scan(scanners.Input{})
assert.Nil(t, err)
assert.True(t, result.Infected)
assert.Equal(t, "bad threat", result.Description)
})
// skyhigh returns the information via the content response
t.Run("X-Infection-Found header", func(t *testing.T) {
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).Return(ic.Response{ContentResponse: &http.Response{StatusCode: http.StatusForbidden, Status: "some status"}}, nil).Once()
result, err := scanner.Scan(scanners.Input{})
assert.Nil(t, err)
assert.True(t, result.Infected)
assert.Equal(t, "some status", result.Description)
client.EXPECT().Do(mock.Anything).Return(ic.Response{}, nil).Once()
client.EXPECT().Do(mock.Anything).Return(ic.Response{ContentResponse: &http.Response{StatusCode: http.StatusOK}}, nil).Once()
result, err = scanner.Scan(scanners.Input{})
assert.Nil(t, err)
assert.False(t, result.Infected)
})
})
})
}
@@ -0,0 +1,97 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"github.com/opencloud-eu/icap-client"
mock "github.com/stretchr/testify/mock"
)
// NewScanner creates a new instance of Scanner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewScanner(t interface {
mock.TestingT
Cleanup(func())
}) *Scanner {
mock := &Scanner{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Scanner is an autogenerated mock type for the Scanner type
type Scanner struct {
mock.Mock
}
type Scanner_Expecter struct {
mock *mock.Mock
}
func (_m *Scanner) EXPECT() *Scanner_Expecter {
return &Scanner_Expecter{mock: &_m.Mock}
}
// Do provides a mock function for the type Scanner
func (_mock *Scanner) Do(req icapclient.Request) (icapclient.Response, error) {
ret := _mock.Called(req)
if len(ret) == 0 {
panic("no return value specified for Do")
}
var r0 icapclient.Response
var r1 error
if returnFunc, ok := ret.Get(0).(func(icapclient.Request) (icapclient.Response, error)); ok {
return returnFunc(req)
}
if returnFunc, ok := ret.Get(0).(func(icapclient.Request) icapclient.Response); ok {
r0 = returnFunc(req)
} else {
r0 = ret.Get(0).(icapclient.Response)
}
if returnFunc, ok := ret.Get(1).(func(icapclient.Request) error); ok {
r1 = returnFunc(req)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Scanner_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do'
type Scanner_Do_Call struct {
*mock.Call
}
// Do is a helper method to define mock.On call
// - req icapclient.Request
func (_e *Scanner_Expecter) Do(req interface{}) *Scanner_Do_Call {
return &Scanner_Do_Call{Call: _e.mock.On("Do", req)}
}
func (_c *Scanner_Do_Call) Run(run func(req icapclient.Request)) *Scanner_Do_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 icapclient.Request
if args[0] != nil {
arg0 = args[0].(icapclient.Request)
}
run(
arg0,
)
})
return _c
}
func (_c *Scanner_Do_Call) Return(response icapclient.Response, err error) *Scanner_Do_Call {
_c.Call.Return(response, err)
return _c
}
func (_c *Scanner_Do_Call) RunAndReturn(run func(req icapclient.Request) (icapclient.Response, error)) *Scanner_Do_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,31 @@
package scanners
import (
"errors"
"io"
"time"
)
var (
// ErrScanTimeout is returned when a scan times out
ErrScanTimeout = errors.New("time out waiting for clamav to respond while scanning")
// ErrScannerNotReachable is returned when the scanner is not reachable
ErrScannerNotReachable = errors.New("failed to reach the scanner")
)
type (
// The Result is the common scan result to all scanners
Result struct {
Infected bool
ScanTime time.Time
Description string
}
// The Input is the common input to all scanners
Input struct {
Body io.Reader
Size int64
Url string
Name string
}
)