From f4074a806003fbe6e6e51176af58d1796f969dff Mon Sep 17 00:00:00 2001 From: David Stotijn Date: Tue, 15 Mar 2022 19:32:29 +0100 Subject: [PATCH] Add request filter for intercept --- .../projects/graphql/activeProject.graphql | 1 + .../features/settings/components/Settings.tsx | 114 +++++++-- .../graphql/updateInterceptSettings.graphql | 1 + admin/src/lib/graphql/generated.tsx | 8 +- admin/src/lib/graphql/useApollo.ts | 3 + pkg/api/generated.go | 54 ++++- pkg/api/models_gen.go | 6 +- pkg/api/resolvers.go | 75 +++--- pkg/api/schema.graphql | 2 + pkg/proj/proj.go | 10 +- pkg/proxy/intercept/filter.go | 229 ++++++++++++++++++ pkg/proxy/intercept/intercept.go | 40 ++- pkg/proxy/intercept/settings.go | 5 +- pkg/reqlog/search.go | 3 +- pkg/search/ast.go | 7 +- pkg/search/parser.go | 2 +- pkg/search/parser_test.go | 6 +- pkg/sender/search.go | 3 +- 18 files changed, 500 insertions(+), 69 deletions(-) create mode 100644 pkg/proxy/intercept/filter.go diff --git a/admin/src/features/projects/graphql/activeProject.graphql b/admin/src/features/projects/graphql/activeProject.graphql index 7350999..4327af0 100644 --- a/admin/src/features/projects/graphql/activeProject.graphql +++ b/admin/src/features/projects/graphql/activeProject.graphql @@ -6,6 +6,7 @@ query ActiveProject { settings { intercept { enabled + requestFilter } } } diff --git a/admin/src/features/settings/components/Settings.tsx b/admin/src/features/settings/components/Settings.tsx index f9210e5..abd6eb8 100644 --- a/admin/src/features/settings/components/Settings.tsx +++ b/admin/src/features/settings/components/Settings.tsx @@ -1,13 +1,25 @@ import { useApolloClient } from "@apollo/client"; import { TabContext, TabPanel } from "@mui/lab"; import TabList from "@mui/lab/TabList"; -import { Box, FormControl, FormControlLabel, FormHelperText, Switch, Tab, Typography } from "@mui/material"; +import { + Box, + Button, + CircularProgress, + FormControl, + FormControlLabel, + FormHelperText, + Switch, + Tab, + TextField, + Typography, +} from "@mui/material"; import { SwitchBaseProps } from "@mui/material/internal/SwitchBase"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useActiveProject } from "lib/ActiveProjectContext"; import Link from "lib/components/Link"; import { ActiveProjectDocument, useUpdateInterceptSettingsMutation } from "lib/graphql/generated"; +import { withoutTypename } from "lib/graphql/omitTypename"; enum TabValue { Intercept = "intercept", @@ -16,24 +28,55 @@ enum TabValue { export default function Settings(): JSX.Element { const client = useApolloClient(); const activeProject = useActiveProject(); - const [updateInterceptSettings, updateIntercepSettingsResult] = useUpdateInterceptSettingsMutation(); + const [updateInterceptSettings, updateIntercepSettingsResult] = useUpdateInterceptSettingsMutation({ + onCompleted(data) { + client.cache.updateQuery({ query: ActiveProjectDocument }, (cachedData) => ({ + activeProject: { + ...cachedData.activeProject, + settings: { + ...cachedData.activeProject.settings, + intercept: data.updateInterceptSettings, + }, + }, + })); + + setInterceptReqFilter(data.updateInterceptSettings.requestFilter || ""); + }, + }); + + const [interceptReqFilter, setInterceptReqFilter] = useState(""); + + useEffect(() => { + setInterceptReqFilter(activeProject?.settings.intercept.requestFilter || ""); + }, [activeProject?.settings.intercept.requestFilter]); + + const handleInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => { + if (!activeProject) { + e.preventDefault(); + return; + } - const handleInterceptEnabled: SwitchBaseProps["onChange"] = (_, checked) => { updateInterceptSettings({ variables: { input: { + ...withoutTypename(activeProject.settings.intercept), enabled: checked, }, }, - onCompleted(data) { - client.cache.updateQuery({ query: ActiveProjectDocument }, (cachedData) => ({ - activeProject: { - ...cachedData.activeProject, - settings: { - intercept: data.updateInterceptSettings, - }, - }, - })); + }); + }; + + const handleInterceptReqFilter = () => { + if (!activeProject) { + return; + } + + updateInterceptSettings({ + variables: { + input: { + ...withoutTypename(activeProject.settings.intercept), + requestFilter: interceptReqFilter, + }, }, }); }; @@ -52,7 +95,7 @@ export default function Settings(): JSX.Element { Settings allow you to tweak the behaviour of Hetty’s features. - + Project settings {!activeProject && ( @@ -86,6 +129,49 @@ export default function Settings(): JSX.Element { manual review. + + Rules + +
+ + setInterceptReqFilter(e.target.value)} + InputProps={{ + sx: { fontFamily: "'JetBrains Mono', monospace" }, + autoCorrect: "false", + spellCheck: "false", + }} + InputLabelProps={{ + shrink: true, + }} + margin="normal" + sx={{ mr: 1 }} + /> + + Filter expression to match incoming requests on. When set, only matching requests are intercepted. + + + +
diff --git a/admin/src/features/settings/graphql/updateInterceptSettings.graphql b/admin/src/features/settings/graphql/updateInterceptSettings.graphql index 0896ade..0bcf99f 100644 --- a/admin/src/features/settings/graphql/updateInterceptSettings.graphql +++ b/admin/src/features/settings/graphql/updateInterceptSettings.graphql @@ -1,5 +1,6 @@ mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) { updateInterceptSettings(input: $input) { enabled + requestFilter } } diff --git a/admin/src/lib/graphql/generated.tsx b/admin/src/lib/graphql/generated.tsx index 29865f4..ceebcf1 100644 --- a/admin/src/lib/graphql/generated.tsx +++ b/admin/src/lib/graphql/generated.tsx @@ -119,6 +119,7 @@ export type HttpResponseLog = { export type InterceptSettings = { __typename?: 'InterceptSettings'; enabled: Scalars['Boolean']; + requestFilter?: Maybe; }; export type ModifyRequestInput = { @@ -315,6 +316,7 @@ export type SenderRequestInput = { export type UpdateInterceptSettingsInput = { enabled: Scalars['Boolean']; + requestFilter?: InputMaybe; }; export type CancelRequestMutationVariables = Exact<{ @@ -341,7 +343,7 @@ export type ModifyRequestMutation = { __typename?: 'Mutation', modifyRequest: { export type ActiveProjectQueryVariables = Exact<{ [key: string]: never; }>; -export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', enabled: boolean } } } | null }; +export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', enabled: boolean, requestFilter?: string | null } } } | null }; export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>; @@ -453,7 +455,7 @@ export type UpdateInterceptSettingsMutationVariables = Exact<{ }>; -export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', enabled: boolean } }; +export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', enabled: boolean, requestFilter?: string | null } }; export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>; @@ -579,6 +581,7 @@ export const ActiveProjectDocument = gql` settings { intercept { enabled + requestFilter } } } @@ -1244,6 +1247,7 @@ export const UpdateInterceptSettingsDocument = gql` mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) { updateInterceptSettings(input: $input) { enabled + requestFilter } } `; diff --git a/admin/src/lib/graphql/useApollo.ts b/admin/src/lib/graphql/useApollo.ts index 7576624..b809fab 100644 --- a/admin/src/lib/graphql/useApollo.ts +++ b/admin/src/lib/graphql/useApollo.ts @@ -19,6 +19,9 @@ function createApolloClient() { }, }, }, + ProjectSettings: { + merge: true, + }, }, }), }); diff --git a/pkg/api/generated.go b/pkg/api/generated.go index bb693df..479ce2b 100644 --- a/pkg/api/generated.go +++ b/pkg/api/generated.go @@ -105,7 +105,8 @@ type ComplexityRoot struct { } InterceptSettings struct { - Enabled func(childComplexity int) int + Enabled func(childComplexity int) int + RequestFilter func(childComplexity int) int } ModifyRequestResult struct { @@ -438,6 +439,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.InterceptSettings.Enabled(childComplexity), true + case "InterceptSettings.requestFilter": + if e.complexity.InterceptSettings.RequestFilter == nil { + break + } + + return e.complexity.InterceptSettings.RequestFilter(childComplexity), true + case "ModifyRequestResult.success": if e.complexity.ModifyRequestResult.Success == nil { break @@ -1057,10 +1065,12 @@ type CancelRequestResult { input UpdateInterceptSettingsInput { enabled: Boolean! + requestFilter: String } type InterceptSettings { enabled: Boolean! + requestFilter: String } type Query { @@ -2440,6 +2450,38 @@ func (ec *executionContext) _InterceptSettings_enabled(ctx context.Context, fiel return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _InterceptSettings_requestFilter(ctx context.Context, field graphql.CollectedField, obj *InterceptSettings) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InterceptSettings", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.RequestFilter, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + func (ec *executionContext) _ModifyRequestResult_success(ctx context.Context, field graphql.CollectedField, obj *ModifyRequestResult) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5632,6 +5674,14 @@ func (ec *executionContext) unmarshalInputUpdateInterceptSettingsInput(ctx conte if err != nil { return it, err } + case "requestFilter": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("requestFilter")) + it.RequestFilter, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } } } @@ -6012,6 +6062,8 @@ func (ec *executionContext) _InterceptSettings(ctx context.Context, sel ast.Sele if out.Values[i] == graphql.Null { invalids++ } + case "requestFilter": + out.Values[i] = ec._InterceptSettings_requestFilter(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/pkg/api/models_gen.go b/pkg/api/models_gen.go index 8e5c0fb..203cb94 100644 --- a/pkg/api/models_gen.go +++ b/pkg/api/models_gen.go @@ -83,7 +83,8 @@ type HTTPResponseLog struct { } type InterceptSettings struct { - Enabled bool `json:"enabled"` + Enabled bool `json:"enabled"` + RequestFilter *string `json:"requestFilter"` } type ModifyRequestInput struct { @@ -164,7 +165,8 @@ type SenderRequestInput struct { } type UpdateInterceptSettingsInput struct { - Enabled bool `json:"enabled"` + Enabled bool `json:"enabled"` + RequestFilter *string `json:"requestFilter"` } type HTTPMethod string diff --git a/pkg/api/resolvers.go b/pkg/api/resolvers.go index 06a9d8b..577e7ea 100644 --- a/pkg/api/resolvers.go +++ b/pkg/api/resolvers.go @@ -184,11 +184,9 @@ func (r *mutationResolver) CreateProject(ctx context.Context, name string) (*Pro return nil, fmt.Errorf("could not open project: %w", err) } - return &Project{ - ID: p.ID, - Name: p.Name, - IsActive: r.ProjectService.IsProjectActive(p.ID), - }, nil + project := parseProject(r.ProjectService, p) + + return &project, nil } func (r *mutationResolver) OpenProject(ctx context.Context, id ulid.ULID) (*Project, error) { @@ -199,11 +197,9 @@ func (r *mutationResolver) OpenProject(ctx context.Context, id ulid.ULID) (*Proj return nil, fmt.Errorf("could not open project: %w", err) } - return &Project{ - ID: p.ID, - Name: p.Name, - IsActive: r.ProjectService.IsProjectActive(p.ID), - }, nil + project := parseProject(r.ProjectService, p) + + return &project, nil } func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) { @@ -214,16 +210,9 @@ func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) { return nil, fmt.Errorf("could not open project: %w", err) } - return &Project{ - ID: p.ID, - Name: p.Name, - IsActive: r.ProjectService.IsProjectActive(p.ID), - Settings: &ProjectSettings{ - Intercept: &InterceptSettings{ - Enabled: p.Settings.InterceptEnabled, - }, - }, - }, nil + project := parseProject(r.ProjectService, p) + + return &project, nil } func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) { @@ -234,11 +223,7 @@ func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) { projects := make([]Project, len(p)) for i, proj := range p { - projects[i] = Project{ - ID: proj.ID, - Name: proj.Name, - IsActive: r.ProjectService.IsProjectActive(proj.ID), - } + projects[i] = parseProject(r.ProjectService, proj) } return projects, nil @@ -603,6 +588,15 @@ func (r *mutationResolver) UpdateInterceptSettings( Enabled: input.Enabled, } + if input.RequestFilter != nil && *input.RequestFilter != "" { + expr, err := search.ParseQuery(*input.RequestFilter) + if err != nil { + return nil, fmt.Errorf("could not parse search query: %w", err) + } + + settings.RequestFilter = expr + } + err := r.ProjectService.UpdateInterceptSettings(ctx, settings) if errors.Is(err, proj.ErrNoProject) { return nil, noActiveProjectErr(ctx) @@ -610,9 +604,16 @@ func (r *mutationResolver) UpdateInterceptSettings( return nil, fmt.Errorf("could not update intercept settings: %w", err) } - return &InterceptSettings{ + updated := &InterceptSettings{ Enabled: settings.Enabled, - }, nil + } + + if settings.RequestFilter != nil { + reqFilter := settings.RequestFilter.String() + updated.RequestFilter = &reqFilter + } + + return updated, nil } func parseSenderRequest(req sender.Request) (SenderRequest, error) { @@ -720,6 +721,26 @@ func parseHTTPRequest(req *http.Request) (HTTPRequest, error) { return httpReq, nil } +func parseProject(projSvc proj.Service, p proj.Project) Project { + project := Project{ + ID: p.ID, + Name: p.Name, + IsActive: projSvc.IsProjectActive(p.ID), + Settings: &ProjectSettings{ + Intercept: &InterceptSettings{ + Enabled: p.Settings.InterceptEnabled, + }, + }, + } + + if p.Settings.InterceptRequestFilter != nil { + interceptReqFilter := p.Settings.InterceptRequestFilter.String() + project.Settings.Intercept.RequestFilter = &interceptReqFilter + } + + return project +} + func stringPtrToRegexp(s *string) (*regexp.Regexp, error) { if s == nil { return nil, nil diff --git a/pkg/api/schema.graphql b/pkg/api/schema.graphql index 046e42d..c8ba293 100644 --- a/pkg/api/schema.graphql +++ b/pkg/api/schema.graphql @@ -149,10 +149,12 @@ type CancelRequestResult { input UpdateInterceptSettingsInput { enabled: Boolean! + requestFilter: String } type InterceptSettings { enabled: Boolean! + requestFilter: String } type Query { diff --git a/pkg/proj/proj.go b/pkg/proj/proj.go index e6ba850..55a4823 100644 --- a/pkg/proj/proj.go +++ b/pkg/proj/proj.go @@ -62,7 +62,8 @@ type Settings struct { ReqLogSearchExpr search.Expression // Intercept settings - InterceptEnabled bool + InterceptEnabled bool + InterceptRequestFilter search.Expression // Sender settings SenderOnlyFindInScope bool @@ -132,7 +133,8 @@ func (svc *service) CloseProject() error { svc.reqLogSvc.SetBypassOutOfScopeRequests(false) svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{}) svc.interceptSvc.UpdateSettings(intercept.Settings{ - Enabled: false, + Enabled: false, + RequestFilter: nil, }) svc.senderSvc.SetActiveProjectID(ulid.ULID{}) svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{}) @@ -177,7 +179,8 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje // Intercept settings. svc.interceptSvc.UpdateSettings(intercept.Settings{ - Enabled: project.Settings.InterceptEnabled, + Enabled: project.Settings.InterceptEnabled, + RequestFilter: project.Settings.InterceptRequestFilter, }) // Sender settings. @@ -294,6 +297,7 @@ func (svc *service) UpdateInterceptSettings(ctx context.Context, settings interc } project.Settings.InterceptEnabled = settings.Enabled + project.Settings.InterceptRequestFilter = settings.RequestFilter err = svc.repo.UpsertProject(ctx, project) if err != nil { diff --git a/pkg/proxy/intercept/filter.go b/pkg/proxy/intercept/filter.go new file mode 100644 index 0000000..f208a60 --- /dev/null +++ b/pkg/proxy/intercept/filter.go @@ -0,0 +1,229 @@ +package intercept + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "github.com/dstotijn/hetty/pkg/scope" + "github.com/dstotijn/hetty/pkg/search" +) + +var reqFilterKeyFns = map[string]func(req *http.Request) (string, error){ + "proto": func(req *http.Request) (string, error) { return req.Proto, nil }, + "url": func(req *http.Request) (string, error) { + if req.URL == nil { + return "", nil + } + return req.URL.String(), nil + }, + "method": func(req *http.Request) (string, error) { return req.Method, nil }, + "body": func(req *http.Request) (string, error) { + if req.Body == nil { + return "", nil + } + + body, err := io.ReadAll(req.Body) + if err != nil { + return "", err + } + + req.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + return string(body), nil + }, +} + +// MatchRequestFilter returns true if an HTTP request matches the request filter expression. +func MatchRequestFilter(req *http.Request, expr search.Expression) (bool, error) { + switch e := expr.(type) { + case search.PrefixExpression: + return matchReqPrefixExpr(req, e) + case search.InfixExpression: + return matchReqInfixExpr(req, e) + case search.StringLiteral: + return matchReqStringLiteral(req, e) + default: + return false, fmt.Errorf("expression type (%T) not supported", expr) + } +} + +func matchReqPrefixExpr(req *http.Request, expr search.PrefixExpression) (bool, error) { + switch expr.Operator { + case search.TokOpNot: + match, err := MatchRequestFilter(req, expr.Right) + if err != nil { + return false, err + } + + return !match, nil + default: + return false, errors.New("operator is not supported") + } +} + +func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, error) { + switch expr.Operator { + case search.TokOpAnd: + left, err := MatchRequestFilter(req, expr.Left) + if err != nil { + return false, err + } + + right, err := MatchRequestFilter(req, expr.Right) + if err != nil { + return false, err + } + + return left && right, nil + case search.TokOpOr: + left, err := MatchRequestFilter(req, expr.Left) + if err != nil { + return false, err + } + + right, err := MatchRequestFilter(req, expr.Right) + if err != nil { + return false, err + } + + return left || right, nil + } + + left, ok := expr.Left.(search.StringLiteral) + if !ok { + return false, errors.New("left operand must be a string literal") + } + + leftVal, err := getMappedStringLiteralFromReq(req, left.Value) + if err != nil { + return false, fmt.Errorf("failed to get string literal from request for left operand: %w", err) + } + + if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe { + right, ok := expr.Right.(search.RegexpLiteral) + if !ok { + return false, errors.New("right operand must be a regular expression") + } + + switch expr.Operator { + case search.TokOpRe: + return right.MatchString(leftVal), nil + case search.TokOpNotRe: + return !right.MatchString(leftVal), nil + } + } + + right, ok := expr.Right.(search.StringLiteral) + if !ok { + return false, errors.New("right operand must be a string literal") + } + + rightVal, err := getMappedStringLiteralFromReq(req, right.Value) + if err != nil { + return false, fmt.Errorf("failed to get string literal from request for right operand: %w", err) + } + + switch expr.Operator { + case search.TokOpEq: + return leftVal == rightVal, nil + case search.TokOpNotEq: + return leftVal != rightVal, nil + case search.TokOpGt: + // TODO(?) attempt to parse as int. + return leftVal > rightVal, nil + case search.TokOpLt: + // TODO(?) attempt to parse as int. + return leftVal < rightVal, nil + case search.TokOpGtEq: + // TODO(?) attempt to parse as int. + return leftVal >= rightVal, nil + case search.TokOpLtEq: + // TODO(?) attempt to parse as int. + return leftVal <= rightVal, nil + default: + return false, errors.New("unsupported operator") + } +} + +func getMappedStringLiteralFromReq(req *http.Request, s string) (string, error) { + fn, ok := reqFilterKeyFns[s] + if ok { + return fn(req) + } + + return s, nil +} + +func matchReqStringLiteral(req *http.Request, strLiteral search.StringLiteral) (bool, error) { + for _, fn := range reqFilterKeyFns { + value, err := fn(req) + if err != nil { + return false, err + } + + if strings.Contains(strings.ToLower(value), strings.ToLower(strLiteral.Value)) { + return true, nil + } + } + + return false, nil +} + +func MatchRequestScope(req *http.Request, s *scope.Scope) (bool, error) { + for _, rule := range s.Rules() { + if rule.URL != nil && req.URL != nil { + if matches := rule.URL.MatchString(req.URL.String()); matches { + return true, nil + } + } + + for key, values := range req.Header { + var keyMatches, valueMatches bool + + if rule.Header.Key != nil { + if matches := rule.Header.Key.MatchString(key); matches { + keyMatches = true + } + } + + if rule.Header.Value != nil { + for _, value := range values { + if matches := rule.Header.Value.MatchString(value); matches { + valueMatches = true + break + } + } + } + + // When only key or value is set, match on whatever is set. + // When both are set, both must match. + switch { + case rule.Header.Key != nil && rule.Header.Value == nil && keyMatches: + return true, nil + case rule.Header.Key == nil && rule.Header.Value != nil && valueMatches: + return true, nil + case rule.Header.Key != nil && rule.Header.Value != nil && keyMatches && valueMatches: + return true, nil + } + } + + if rule.Body != nil { + body, err := io.ReadAll(req.Body) + if err != nil { + return false, fmt.Errorf("failed to read request body: %w", err) + } + + req.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + + if matches := rule.Body.Match(body); matches { + return true, nil + } + } + } + + return false, nil +} diff --git a/pkg/proxy/intercept/intercept.go b/pkg/proxy/intercept/intercept.go index 89e82e5..ac0b563 100644 --- a/pkg/proxy/intercept/intercept.go +++ b/pkg/proxy/intercept/intercept.go @@ -3,6 +3,7 @@ package intercept import ( "context" "errors" + "fmt" "net/http" "sort" "sync" @@ -11,6 +12,7 @@ import ( "github.com/dstotijn/hetty/pkg/log" "github.com/dstotijn/hetty/pkg/proxy" + "github.com/dstotijn/hetty/pkg/search" ) var ( @@ -28,15 +30,17 @@ type Request struct { } type Service struct { - mu *sync.RWMutex - requests map[ulid.ULID]Request - logger log.Logger - enabled bool + mu *sync.RWMutex + requests map[ulid.ULID]Request + logger log.Logger + enabled bool + reqFilter search.Expression } type Config struct { - Logger log.Logger - Enabled bool + Logger log.Logger + Enabled bool + RequestFilter search.Expression } // RequestIDs implements sort.Interface. @@ -44,10 +48,11 @@ type RequestIDs []ulid.ULID func NewService(cfg Config) *Service { s := &Service{ - mu: &sync.RWMutex{}, - requests: make(map[ulid.ULID]Request), - logger: cfg.Logger, - enabled: cfg.Enabled, + mu: &sync.RWMutex{}, + requests: make(map[ulid.ULID]Request), + logger: cfg.Logger, + enabled: cfg.Enabled, + reqFilter: cfg.RequestFilter, } if s.logger == nil { @@ -102,6 +107,20 @@ func (svc *Service) Intercept(ctx context.Context, req *http.Request) (*http.Req return req, nil } + if svc.reqFilter != nil { + match, err := MatchRequestFilter(req, svc.reqFilter) + if err != nil { + return nil, fmt.Errorf("intercept: failed to match request rules for request (id: %v): %w", + reqID.String(), err, + ) + } + + if !match { + svc.logger.Debugw("Bypassed interception: request rules don't match.") + return req, nil + } + } + ch := make(chan *http.Request) done := make(chan struct{}) @@ -197,6 +216,7 @@ func (svc *Service) UpdateSettings(settings Settings) { } svc.enabled = settings.Enabled + svc.reqFilter = settings.RequestFilter } // Request returns an intercepted request by ID. It's safe for concurrent use. diff --git a/pkg/proxy/intercept/settings.go b/pkg/proxy/intercept/settings.go index 35ac2fc..084b35d 100644 --- a/pkg/proxy/intercept/settings.go +++ b/pkg/proxy/intercept/settings.go @@ -1,5 +1,8 @@ package intercept +import "github.com/dstotijn/hetty/pkg/search" + type Settings struct { - Enabled bool + Enabled bool + RequestFilter search.Expression } diff --git a/pkg/reqlog/search.go b/pkg/reqlog/search.go index 7ad4f1c..9fa6053 100644 --- a/pkg/reqlog/search.go +++ b/pkg/reqlog/search.go @@ -3,7 +3,6 @@ package reqlog import ( "errors" "fmt" - "regexp" "strconv" "strings" @@ -100,7 +99,7 @@ func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, erro leftVal := reqLog.getMappedStringLiteral(left.Value) if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe { - right, ok := expr.Right.(*regexp.Regexp) + right, ok := expr.Right.(search.RegexpLiteral) if !ok { return false, errors.New("right operand must be a regular expression") } diff --git a/pkg/search/ast.go b/pkg/search/ast.go index afa01a6..0e0d048 100644 --- a/pkg/search/ast.go +++ b/pkg/search/ast.go @@ -3,6 +3,7 @@ package search import ( "encoding/gob" "regexp" + "strconv" "strings" ) @@ -50,13 +51,17 @@ type StringLiteral struct { } func (sl StringLiteral) String() string { - return sl.Value + return strconv.Quote(sl.Value) } type RegexpLiteral struct { *regexp.Regexp } +func (rl RegexpLiteral) String() string { + return strconv.Quote(rl.Regexp.String()) +} + func (rl RegexpLiteral) MarshalBinary() ([]byte, error) { return []byte(rl.Regexp.String()), nil } diff --git a/pkg/search/parser.go b/pkg/search/parser.go index f95bde9..85a5bd3 100644 --- a/pkg/search/parser.go +++ b/pkg/search/parser.go @@ -208,7 +208,7 @@ func parseInfixExpression(p *Parser, left Expression) (Expression, error) { return nil, fmt.Errorf("could not compile regular expression %q: %w", rightStr.Value, err) } - right = re + right = RegexpLiteral{re} } } diff --git a/pkg/search/parser_test.go b/pkg/search/parser_test.go index 1598ef8..6b538ad 100644 --- a/pkg/search/parser_test.go +++ b/pkg/search/parser_test.go @@ -94,7 +94,7 @@ func TestParseQuery(t *testing.T) { expectedExpression: InfixExpression{ Operator: TokOpRe, Left: StringLiteral{Value: "foo"}, - Right: regexp.MustCompile("bar"), + Right: RegexpLiteral{regexp.MustCompile("bar")}, }, expectedError: nil, }, @@ -104,7 +104,7 @@ func TestParseQuery(t *testing.T) { expectedExpression: InfixExpression{ Operator: TokOpNotRe, Left: StringLiteral{Value: "foo"}, - Right: regexp.MustCompile("bar"), + Right: RegexpLiteral{regexp.MustCompile("bar")}, }, expectedError: nil, }, @@ -197,7 +197,7 @@ func TestParseQuery(t *testing.T) { Right: InfixExpression{ Operator: TokOpRe, Left: StringLiteral{Value: "baz"}, - Right: regexp.MustCompile("yolo"), + Right: RegexpLiteral{regexp.MustCompile("yolo")}, }, }, expectedError: nil, diff --git a/pkg/sender/search.go b/pkg/sender/search.go index 2fa1e29..950ae80 100644 --- a/pkg/sender/search.go +++ b/pkg/sender/search.go @@ -3,7 +3,6 @@ package sender import ( "errors" "fmt" - "regexp" "strings" "github.com/oklog/ulid" @@ -93,7 +92,7 @@ func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) { leftVal := req.getMappedStringLiteral(left.Value) if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe { - right, ok := expr.Right.(*regexp.Regexp) + right, ok := expr.Right.(search.RegexpLiteral) if !ok { return false, errors.New("right operand must be a regular expression") }