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
+
+
>
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")
}