Compare commits

..

12 Commits

31 changed files with 1139 additions and 1455 deletions

4
.github/FUNDING.yml vendored
View File

@ -1,3 +1,3 @@
github: dstotijn # These are supported funding model platforms
patreon: dstotijn patreon: dstotijn
custom: "https://www.paypal.com/paypalme/dstotijn"

View File

@ -35,7 +35,7 @@ linters-settings:
godot: godot:
capital: true capital: true
ireturn: ireturn:
allow: "error,empty,anon,stdlib,.*(or|er)$,github.com/99designs/gqlgen/graphql.Marshaler,github.com/dstotijn/hetty/pkg/api.QueryResolver,github.com/dstotijn/hetty/pkg/filter.Expression" allow: "error,empty,anon,stdlib,.*(or|er)$,github.com/99designs/gqlgen/graphql.Marshaler,github.com/dstotijn/hetty/pkg/api.QueryResolver,github.com/dstotijn/hetty/pkg/search.Expression"
issues: issues:
exclude-rules: exclude-rules:

View File

@ -55,8 +55,13 @@ snapcrafts:
license: MIT license: MIT
apps: apps:
hetty: hetty:
command: hetty plugs: ["home", "network", "network-bind", "personal-files"]
plugs: ["network", "network-bind"] plugs:
personal-files:
read:
- $HOME/.hetty
write:
- $HOME/.hetty
scoop: scoop:
bucket: bucket:
@ -69,31 +74,6 @@ scoop:
description: An HTTP toolkit for security research. description: An HTTP toolkit for security research.
license: MIT license: MIT
dockers:
- extra_files:
- go.mod
- go.sum
- pkg
- cmd
- admin
image_templates:
- "ghcr.io/dstotijn/hetty:{{ .Version }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "ghcr.io/dstotijn/hetty:latest"
- "dstotijn/hetty:{{ .Version }}"
- "dstotijn/hetty:{{ .Major }}"
- "dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "dstotijn/hetty:latest"
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source=https://github.com/dstotijn/hetty"
- "--build-arg=HETTY_VERSION={{.Version}}"
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"

View File

@ -17,7 +17,6 @@ features tailored to the needs of the infosec and bug bounty community.
- Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search - Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search
- HTTP client for manually creating/editing requests, and replay proxied requests - HTTP client for manually creating/editing requests, and replay proxied requests
- Intercept requests and responses for manual review (edit, send/receive, cancel)
- Scope support, to help keep work organized - Scope support, to help keep work organized
- Easy-to-use web based admin interface - Easy-to-use web based admin interface
- Project based database storage, to help keep work organized - Project based database storage, to help keep work organized
@ -51,6 +50,11 @@ brew install hettysoft/tap/hetty
sudo snap install hetty sudo snap install hetty
``` ```
⚠️ As of Sun 6 Mar 2022, we're awaiting Canonical to approve the necessary
Snapcraft privileges to allow Hetty to bind on a port. In the meantime, please
use one of the Linux [releases](https://github.com/dstotijn/hetty/releases) on
Github.
#### Windows #### Windows
```sh ```sh
@ -64,18 +68,8 @@ Alternatively, you can [download the latest release from
GitHub](https://github.com/dstotijn/hetty/releases/latest) for your OS and GitHub](https://github.com/dstotijn/hetty/releases/latest) for your OS and
architecture, and move the binary to a directory in your `$PATH`. If your OS is architecture, and move the binary to a directory in your `$PATH`. If your OS is
not available for one of the package managers or not listed in the GitHub not available for one of the package managers or not listed in the GitHub
releases, you can compile from source _(link coming soon)_. releases, you can compile from source _(link coming soon)_ or use a Docker image
_(link coming soon)_.
#### Docker
Docker images are distributed via [GitHub's Container registry](https://github.com/dstotijn/hetty/pkgs/container/hetty)
and [Docker Hub](https://hub.docker.com/r/dstotijn/hetty). To run Hetty via with a volume for database and certificate
storage, and port 8080 forwarded:
```
docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 \
ghcr.io/dstotijn/hetty:latest
```
### Usage ### Usage
@ -146,11 +140,9 @@ Guidelines](CONTRIBUTING.md) for details.
## Sponsors ## Sponsors
<p><a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty"> <a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
<img src="https://hetty.xyz/img/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines"> <img src="https://hetty.xyz/img/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
</a></p> </a>
💖 Are you enjoying Hetty? You can [sponsor me](https://github.com/sponsors/dstotijn)!
## License ## License

View File

@ -7,7 +7,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext"; import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { KeyValuePair } from "lib/components/KeyValuePair"; import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import Link from "lib/components/Link"; import Link from "lib/components/Link";
import RequestTabs from "lib/components/RequestTabs"; import RequestTabs from "lib/components/RequestTabs";
import ResponseStatus from "lib/components/ResponseStatus"; import ResponseStatus from "lib/components/ResponseStatus";
@ -112,11 +112,11 @@ function EditRequest(): JSX.Element {
newQueryParams.push({ key: "", value: "" }); newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams); setQueryParams(newQueryParams);
const newReqHeaders = interceptedRequest.headers || []; const newReqHeaders = sortKeyValuePairs(interceptedRequest.headers || []);
setReqHeaders([...newReqHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]); setReqHeaders([...newReqHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResBody(interceptedRequest.response?.body || ""); setResBody(interceptedRequest.response?.body || "");
const newResHeaders = interceptedRequest.response?.headers || []; const newResHeaders = sortKeyValuePairs(interceptedRequest.response?.headers || []);
setResHeaders([...newResHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]); setResHeaders([...newResHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
}, },
}); });

View File

@ -76,7 +76,6 @@ function Search(): JSX.Element {
<ClickAwayListener onClickAway={handleClickAway}> <ClickAwayListener onClickAway={handleClickAway}>
<Paper <Paper
component="form" component="form"
autoComplete="off"
onSubmit={handleSubmit} onSubmit={handleSubmit}
ref={filterRef} ref={filterRef}
sx={{ sx={{
@ -110,8 +109,6 @@ function Search(): JSX.Element {
value={searchExpr} value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)} onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)} onFocus={() => setFilterOpen(true)}
autoCorrect="false"
spellCheck="false"
/> />
<Tooltip title="Search"> <Tooltip title="Search">
<IconButton type="submit" sx={{ padding: 1.25 }}> <IconButton type="submit" sx={{ padding: 1.25 }}>

View File

@ -1,9 +1,8 @@
import AddIcon from "@mui/icons-material/Add"; import { Alert, Box, Button, Typography } from "@mui/material";
import { Alert, Box, Button, Fab, Tooltip, Typography, useTheme } from "@mui/material";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useState } from "react"; import React, { useState } from "react";
import { KeyValuePair } from "lib/components/KeyValuePair"; import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs"; import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response"; import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane"; import SplitPane from "lib/components/SplitPane";
@ -18,21 +17,15 @@ import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem"; import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams"; import updateURLQueryParams from "lib/updateURLQueryParams";
const defaultMethod = HttpMethod.Get;
const defaultProto = HttpProto.Http20;
const emptyKeyPair = [{ key: "", value: "" }];
function EditRequest(): JSX.Element { function EditRequest(): JSX.Element {
const router = useRouter(); const router = useRouter();
const reqId = router.query.id as string | undefined; const reqId = router.query.id as string | undefined;
const theme = useTheme(); const [method, setMethod] = useState(HttpMethod.Get);
const [method, setMethod] = useState(defaultMethod);
const [url, setURL] = useState(""); const [url, setURL] = useState("");
const [proto, setProto] = useState(defaultProto); const [proto, setProto] = useState(HttpProto.Http20);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>(emptyKeyPair); const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [headers, setHeaders] = useState<KeyValuePair[]>(emptyKeyPair); const [headers, setHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => { const handleQueryParamChange = (key: string, value: string, idx: number) => {
@ -90,7 +83,7 @@ function EditRequest(): JSX.Element {
newQueryParams.push({ key: "", value: "" }); newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams); setQueryParams(newQueryParams);
const newHeaders = senderRequest.headers || []; const newHeaders = sortKeyValuePairs(senderRequest.headers || []);
setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]); setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResponse(senderRequest.response); setResponse(senderRequest.response);
}, },
@ -138,26 +131,8 @@ function EditRequest(): JSX.Element {
createOrUpdateRequestAndSend(); createOrUpdateRequestAndSend();
}; };
const handleNewRequest = () => {
setURL("");
setMethod(defaultMethod);
setProto(defaultProto);
setQueryParams(emptyKeyPair);
setHeaders(emptyKeyPair);
setBody("");
setResponse(null);
router.push(`/sender`);
};
return ( return (
<Box display="flex" flexDirection="column" height="100%" gap={2}> <Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box sx={{ position: "absolute", bottom: theme.spacing(2), right: theme.spacing(2) }}>
<Tooltip title="New request">
<Fab color="primary" onClick={handleNewRequest}>
<AddIcon />
</Fab>
</Tooltip>
</Box>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}> <Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar <UrlBar

View File

@ -16,7 +16,6 @@ import {
TextFieldProps, TextFieldProps,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import MaterialLink from "@mui/material/Link";
import { SwitchBaseProps } from "@mui/material/internal/SwitchBase"; import { SwitchBaseProps } from "@mui/material/internal/SwitchBase";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -218,13 +217,7 @@ export default function Settings(): JSX.Element {
onChange={(e) => setInterceptReqFilter(e.target.value)} onChange={(e) => setInterceptReqFilter(e.target.value)}
/> />
<FormHelperText> <FormHelperText>
Filter expression to match incoming requests on. When set, only matching requests are intercepted.{" "} Filter expression to match incoming requests on. When set, only matching requests are intercepted.
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept?utm_source=hettyapp#request-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
<Button <Button
@ -273,13 +266,7 @@ export default function Settings(): JSX.Element {
onChange={(e) => setInterceptResFilter(e.target.value)} onChange={(e) => setInterceptResFilter(e.target.value)}
/> />
<FormHelperText> <FormHelperText>
Filter expression to match received responses on. When set, only matching responses are intercepted.{" "} Filter expression to match received responses on. When set, only matching responses are intercepted.
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept/?utm_source=hettyapp#response-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
<Button <Button

View File

@ -184,4 +184,20 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
); );
} }
export function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
const sorted = [...items];
sorted.sort((a, b) => {
if (a.key < b.key) {
return -1;
}
if (a.key > b.key) {
return 1;
}
return 0;
});
return sorted;
}
export default KeyValuePairTable; export default KeyValuePairTable;

View File

@ -1,5 +1,6 @@
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { sortKeyValuePairs } from "./KeyValuePair";
import ResponseTabs from "./ResponseTabs"; import ResponseTabs from "./ResponseTabs";
import ResponseStatus from "lib/components/ResponseStatus"; import ResponseStatus from "lib/components/ResponseStatus";
@ -28,7 +29,7 @@ function Response({ response }: ResponseProps): JSX.Element {
</Box> </Box>
<ResponseTabs <ResponseTabs
body={response?.body} body={response?.body}
headers={response?.headers || []} headers={sortKeyValuePairs(response?.headers || [])}
hasResponse={response !== undefined && response !== null} hasResponse={response !== undefined && response !== null}
/> />
</Box> </Box>

File diff suppressed because it is too large Load Diff

View File

@ -49,17 +49,3 @@ func UnmarshalURL(v interface{}) (*url.URL, error) {
return u, nil return u, nil
} }
type HTTPHeaders []HTTPHeader
func (h HTTPHeaders) Len() int {
return len(h)
}
func (h HTTPHeaders) Less(i, j int) bool {
return h[i].Key < h[j].Key
}
func (h HTTPHeaders) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}

View File

@ -11,19 +11,18 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"regexp" "regexp"
"sort"
"strings" "strings"
"github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql"
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/vektah/gqlparser/v2/gqlerror" "github.com/vektah/gqlparser/v2/gqlerror"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/proj" "github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy" "github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/proxy/intercept" "github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
"github.com/dstotijn/hetty/pkg/sender" "github.com/dstotijn/hetty/pkg/sender"
) )
@ -125,8 +124,6 @@ func parseRequestLog(reqLog reqlog.RequestLog) (HTTPRequestLog, error) {
}) })
} }
} }
sort.Sort(HTTPHeaders(log.Headers))
} }
if reqLog.Response != nil { if reqLog.Response != nil {
@ -175,8 +172,6 @@ func parseResponseLog(resLog reqlog.ResponseLog) (HTTPResponseLog, error) {
}) })
} }
} }
sort.Sort(HTTPHeaders(httpResLog.Headers))
} }
return httpResLog, nil return httpResLog, nil
@ -639,7 +634,7 @@ func (r *mutationResolver) UpdateInterceptSettings(
} }
if input.RequestFilter != nil && *input.RequestFilter != "" { if input.RequestFilter != nil && *input.RequestFilter != "" {
expr, err := filter.ParseQuery(*input.RequestFilter) expr, err := search.ParseQuery(*input.RequestFilter)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse request filter: %w", err) return nil, fmt.Errorf("could not parse request filter: %w", err)
} }
@ -648,7 +643,7 @@ func (r *mutationResolver) UpdateInterceptSettings(
} }
if input.ResponseFilter != nil && *input.ResponseFilter != "" { if input.ResponseFilter != nil && *input.ResponseFilter != "" {
expr, err := filter.ParseQuery(*input.ResponseFilter) expr, err := search.ParseQuery(*input.ResponseFilter)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not parse response filter: %w", err) return nil, fmt.Errorf("could not parse response filter: %w", err)
} }
@ -715,8 +710,6 @@ func parseSenderRequest(req sender.Request) (SenderRequest, error) {
}) })
} }
} }
sort.Sort(HTTPHeaders(senderReq.Headers))
} }
if len(req.Body) > 0 { if len(req.Body) > 0 {
@ -772,8 +765,6 @@ func parseHTTPRequest(req *http.Request) (HTTPRequest, error) {
}) })
} }
} }
sort.Sort(HTTPHeaders(httpReq.Headers))
} }
if req.Body != nil { if req.Body != nil {
@ -824,8 +815,6 @@ func parseHTTPResponse(res *http.Response) (HTTPResponse, error) {
}) })
} }
} }
sort.Sort(HTTPHeaders(httpRes.Headers))
} }
if res.Body != nil { if res.Body != nil {
@ -916,43 +905,43 @@ func scopeToScopeRules(rules []scope.Rule) []ScopeRule {
return scopeRules return scopeRules
} }
func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (findFilter reqlog.FindRequestsFilter, err error) { func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter, err error) {
if input == nil { if input == nil {
return return
} }
if input.OnlyInScope != nil { if input.OnlyInScope != nil {
findFilter.OnlyInScope = *input.OnlyInScope filter.OnlyInScope = *input.OnlyInScope
} }
if input.SearchExpression != nil && *input.SearchExpression != "" { if input.SearchExpression != nil && *input.SearchExpression != "" {
expr, err := filter.ParseQuery(*input.SearchExpression) expr, err := search.ParseQuery(*input.SearchExpression)
if err != nil { if err != nil {
return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err) return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err)
} }
findFilter.SearchExpr = expr filter.SearchExpr = expr
} }
return return
} }
func findSenderRequestsFilterFromInput(input *SenderRequestFilterInput) (findFilter sender.FindRequestsFilter, err error) { func findSenderRequestsFilterFromInput(input *SenderRequestFilterInput) (filter sender.FindRequestsFilter, err error) {
if input == nil { if input == nil {
return return
} }
if input.OnlyInScope != nil { if input.OnlyInScope != nil {
findFilter.OnlyInScope = *input.OnlyInScope filter.OnlyInScope = *input.OnlyInScope
} }
if input.SearchExpression != nil && *input.SearchExpression != "" { if input.SearchExpression != nil && *input.SearchExpression != "" {
expr, err := filter.ParseQuery(*input.SearchExpression) expr, err := search.ParseQuery(*input.SearchExpression)
if err != nil { if err != nil {
return sender.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err) return sender.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %w", err)
} }
findFilter.SearchExpr = expr filter.SearchExpr = expr
} }
return return

View File

@ -15,9 +15,9 @@ import (
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/proj" "github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
) )
//nolint:gosec //nolint:gosec
@ -45,7 +45,7 @@ func TestUpsertProject(t *testing.T) {
database := DatabaseFromBadgerDB(badgerDB) database := DatabaseFromBadgerDB(badgerDB)
defer database.Close() defer database.Close()
searchExpr, err := filter.ParseQuery("foo AND bar OR NOT baz") searchExpr, err := search.ParseQuery("foo AND bar OR NOT baz")
if err != nil { if err != nil {
t.Fatalf("unexpected error (expected: nil, got: %v)", err) t.Fatalf("unexpected error (expected: nil, got: %v)", err)
} }

View File

@ -1,212 +0,0 @@
package filter_test
import (
"regexp"
"testing"
"github.com/dstotijn/hetty/pkg/filter"
)
func TestExpressionString(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expression filter.Expression
expected string
}{
{
name: "string literal expression",
expression: filter.StringLiteral{Value: "foobar"},
expected: `"foobar"`,
},
{
name: "boolean expression with equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" = "bar")`,
},
{
name: "boolean expression with not equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpNotEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" != "bar")`,
},
{
name: "boolean expression with greater than operator",
expression: filter.InfixExpression{
Operator: filter.TokOpGt,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" > "bar")`,
},
{
name: "boolean expression with less than operator",
expression: filter.InfixExpression{
Operator: filter.TokOpLt,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" < "bar")`,
},
{
name: "boolean expression with greater than or equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpGtEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" >= "bar")`,
},
{
name: "boolean expression with less than or equal operator",
expression: filter.InfixExpression{
Operator: filter.TokOpLtEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" <= "bar")`,
},
{
name: "boolean expression with regular expression operator",
expression: filter.InfixExpression{
Operator: filter.TokOpRe,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.RegexpLiteral{regexp.MustCompile("bar")},
},
expected: `("foo" =~ "bar")`,
},
{
name: "boolean expression with not regular expression operator",
expression: filter.InfixExpression{
Operator: filter.TokOpNotRe,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.RegexpLiteral{regexp.MustCompile("bar")},
},
expected: `("foo" !~ "bar")`,
},
{
name: "boolean expression with AND, OR and NOT operators",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.StringLiteral{Value: "bar"},
Right: filter.PrefixExpression{
Operator: filter.TokOpNot,
Right: filter.StringLiteral{Value: "baz"},
},
},
},
expected: `("foo" AND ("bar" OR (NOT "baz")))`,
},
{
name: "boolean expression with nested group",
expression: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.PrefixExpression{
Operator: filter.TokOpNot,
Right: filter.StringLiteral{Value: "baz"},
},
},
expected: `(("foo" AND "bar") OR (NOT "baz"))`,
},
{
name: "implicit boolean expression with string literal operands",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.StringLiteral{Value: "baz"},
},
expected: `(("foo" AND "bar") AND "baz")`,
},
{
name: "implicit boolean expression nested in group",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
expected: `("foo" AND "bar")`,
},
{
name: "implicit and explicit boolean expression with string literal operands",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.StringLiteral{Value: "bar"},
Right: filter.StringLiteral{Value: "baz"},
},
},
Right: filter.StringLiteral{Value: "yolo"},
},
expected: `(("foo" AND ("bar" OR "baz")) AND "yolo")`,
},
{
name: "implicit boolean expression with comparison operands",
expression: filter.InfixExpression{
Operator: filter.TokOpAnd,
Left: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.InfixExpression{
Operator: filter.TokOpRe,
Left: filter.StringLiteral{Value: "baz"},
Right: filter.RegexpLiteral{regexp.MustCompile("yolo")},
},
},
expected: `(("foo" = "bar") AND ("baz" =~ "yolo"))`,
},
{
name: "eq operator takes precedence over boolean ops",
expression: filter.InfixExpression{
Operator: filter.TokOpOr,
Left: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "foo"},
Right: filter.StringLiteral{Value: "bar"},
},
Right: filter.InfixExpression{
Operator: filter.TokOpEq,
Left: filter.StringLiteral{Value: "baz"},
Right: filter.StringLiteral{Value: "yolo"},
},
},
expected: `(("foo" = "bar") OR ("baz" = "yolo"))`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.expression.String()
if tt.expected != got {
t.Errorf("expected: %v, got: %v", tt.expected, got)
}
})
}
}

View File

@ -1,82 +0,0 @@
package filter
import (
"errors"
"fmt"
"net/http"
)
func MatchHTTPHeaders(op TokenType, expr Expression, headers http.Header) (bool, error) {
if headers == nil {
return false, nil
}
switch op {
case TokOpEq:
strLiteral, ok := expr.(StringLiteral)
if !ok {
return false, errors.New("filter: expression must be a string literal")
}
// Return `true` if at least one header (<key>: <value>) is equal to the string literal.
for key, values := range headers {
for _, value := range values {
if strLiteral.Value == fmt.Sprintf("%v: %v", key, value) {
return true, nil
}
}
}
return false, nil
case TokOpNotEq:
strLiteral, ok := expr.(StringLiteral)
if !ok {
return false, errors.New("filter: expression must be a string literal")
}
// Return `true` if none of the headers (<key>: <value>) are equal to the string literal.
for key, values := range headers {
for _, value := range values {
if strLiteral.Value == fmt.Sprintf("%v: %v", key, value) {
return false, nil
}
}
}
return true, nil
case TokOpRe:
re, ok := expr.(RegexpLiteral)
if !ok {
return false, errors.New("filter: expression must be a regular expression")
}
// Return `true` if at least one header (<key>: <value>) matches the regular expression.
for key, values := range headers {
for _, value := range values {
if re.MatchString(fmt.Sprintf("%v: %v", key, value)) {
return true, nil
}
}
}
return false, nil
case TokOpNotRe:
re, ok := expr.(RegexpLiteral)
if !ok {
return false, errors.New("filter: expression must be a regular expression")
}
// Return `true` if none of the headers (<key>: <value>) match the regular expression.
for key, values := range headers {
for _, value := range values {
if re.MatchString(fmt.Sprintf("%v: %v", key, value)) {
return false, nil
}
}
}
return true, nil
default:
return false, fmt.Errorf("filter: unsupported operator %q", op.String())
}
}

View File

@ -11,10 +11,10 @@ import (
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/proxy/intercept" "github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
"github.com/dstotijn/hetty/pkg/sender" "github.com/dstotijn/hetty/pkg/sender"
) )
@ -59,17 +59,17 @@ type Settings struct {
// Request log settings // Request log settings
ReqLogBypassOutOfScope bool ReqLogBypassOutOfScope bool
ReqLogOnlyFindInScope bool ReqLogOnlyFindInScope bool
ReqLogSearchExpr filter.Expression ReqLogSearchExpr search.Expression
// Intercept settings // Intercept settings
InterceptRequests bool InterceptRequests bool
InterceptResponses bool InterceptResponses bool
InterceptRequestFilter filter.Expression InterceptRequestFilter search.Expression
InterceptResponseFilter filter.Expression InterceptResponseFilter search.Expression
// Sender settings // Sender settings
SenderOnlyFindInScope bool SenderOnlyFindInScope bool
SenderSearchExpr filter.Expression SenderSearchExpr search.Expression
// Scope settings // Scope settings
ScopeRules []scope.Rule ScopeRules []scope.Rule

View File

@ -10,8 +10,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
) )
//nolint:unparam //nolint:unparam
@ -68,22 +68,22 @@ var resFilterKeyFns = map[string]func(res *http.Response) (string, error){
} }
// MatchRequestFilter returns true if an HTTP request matches the request filter expression. // MatchRequestFilter returns true if an HTTP request matches the request filter expression.
func MatchRequestFilter(req *http.Request, expr filter.Expression) (bool, error) { func MatchRequestFilter(req *http.Request, expr search.Expression) (bool, error) {
switch e := expr.(type) { switch e := expr.(type) {
case filter.PrefixExpression: case search.PrefixExpression:
return matchReqPrefixExpr(req, e) return matchReqPrefixExpr(req, e)
case filter.InfixExpression: case search.InfixExpression:
return matchReqInfixExpr(req, e) return matchReqInfixExpr(req, e)
case filter.StringLiteral: case search.StringLiteral:
return matchReqStringLiteral(req, e) return matchReqStringLiteral(req, e)
default: default:
return false, fmt.Errorf("expression type (%T) not supported", expr) return false, fmt.Errorf("expression type (%T) not supported", expr)
} }
} }
func matchReqPrefixExpr(req *http.Request, expr filter.PrefixExpression) (bool, error) { func matchReqPrefixExpr(req *http.Request, expr search.PrefixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpNot: case search.TokOpNot:
match, err := MatchRequestFilter(req, expr.Right) match, err := MatchRequestFilter(req, expr.Right)
if err != nil { if err != nil {
return false, err return false, err
@ -95,9 +95,9 @@ func matchReqPrefixExpr(req *http.Request, expr filter.PrefixExpression) (bool,
} }
} }
func matchReqInfixExpr(req *http.Request, expr filter.InfixExpression) (bool, error) { func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpAnd: case search.TokOpAnd:
left, err := MatchRequestFilter(req, expr.Left) left, err := MatchRequestFilter(req, expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -109,7 +109,7 @@ func matchReqInfixExpr(req *http.Request, expr filter.InfixExpression) (bool, er
} }
return left && right, nil return left && right, nil
case filter.TokOpOr: case search.TokOpOr:
left, err := MatchRequestFilter(req, expr.Left) left, err := MatchRequestFilter(req, expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -123,7 +123,7 @@ func matchReqInfixExpr(req *http.Request, expr filter.InfixExpression) (bool, er
return left || right, nil return left || right, nil
} }
left, ok := expr.Left.(filter.StringLiteral) left, ok := expr.Left.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("left operand must be a string literal") return false, errors.New("left operand must be a string literal")
} }
@ -133,30 +133,21 @@ func matchReqInfixExpr(req *http.Request, expr filter.InfixExpression) (bool, er
return false, fmt.Errorf("failed to get string literal from request for left operand: %w", err) return false, fmt.Errorf("failed to get string literal from request for left operand: %w", err)
} }
if leftVal == "headers" { if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, req.Header) right, ok := expr.Right.(search.RegexpLiteral)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a regular expression") return false, errors.New("right operand must be a regular expression")
} }
switch expr.Operator { switch expr.Operator {
case filter.TokOpRe: case search.TokOpRe:
return right.MatchString(leftVal), nil return right.MatchString(leftVal), nil
case filter.TokOpNotRe: case search.TokOpNotRe:
return !right.MatchString(leftVal), nil return !right.MatchString(leftVal), nil
} }
} }
right, ok := expr.Right.(filter.StringLiteral) right, ok := expr.Right.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a string literal") return false, errors.New("right operand must be a string literal")
} }
@ -167,20 +158,20 @@ func matchReqInfixExpr(req *http.Request, expr filter.InfixExpression) (bool, er
} }
switch expr.Operator { switch expr.Operator {
case filter.TokOpEq: case search.TokOpEq:
return leftVal == rightVal, nil return leftVal == rightVal, nil
case filter.TokOpNotEq: case search.TokOpNotEq:
return leftVal != rightVal, nil return leftVal != rightVal, nil
case filter.TokOpGt: case search.TokOpGt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal > rightVal, nil return leftVal > rightVal, nil
case filter.TokOpLt: case search.TokOpLt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal < rightVal, nil return leftVal < rightVal, nil
case filter.TokOpGtEq: case search.TokOpGtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil return leftVal >= rightVal, nil
case filter.TokOpLtEq: case search.TokOpLtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil return leftVal <= rightVal, nil
default: default:
@ -197,18 +188,7 @@ func getMappedStringLiteralFromReq(req *http.Request, s string) (string, error)
return s, nil return s, nil
} }
func matchReqStringLiteral(req *http.Request, strLiteral filter.StringLiteral) (bool, error) { func matchReqStringLiteral(req *http.Request, strLiteral search.StringLiteral) (bool, error) {
for key, values := range req.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range reqFilterKeyFns { for _, fn := range reqFilterKeyFns {
value, err := fn(req) value, err := fn(req)
if err != nil { if err != nil {
@ -279,22 +259,22 @@ func MatchRequestScope(req *http.Request, s *scope.Scope) (bool, error) {
} }
// MatchResponseFilter returns true if an HTTP response matches the response filter expression. // MatchResponseFilter returns true if an HTTP response matches the response filter expression.
func MatchResponseFilter(res *http.Response, expr filter.Expression) (bool, error) { func MatchResponseFilter(res *http.Response, expr search.Expression) (bool, error) {
switch e := expr.(type) { switch e := expr.(type) {
case filter.PrefixExpression: case search.PrefixExpression:
return matchResPrefixExpr(res, e) return matchResPrefixExpr(res, e)
case filter.InfixExpression: case search.InfixExpression:
return matchResInfixExpr(res, e) return matchResInfixExpr(res, e)
case filter.StringLiteral: case search.StringLiteral:
return matchResStringLiteral(res, e) return matchResStringLiteral(res, e)
default: default:
return false, fmt.Errorf("expression type (%T) not supported", expr) return false, fmt.Errorf("expression type (%T) not supported", expr)
} }
} }
func matchResPrefixExpr(res *http.Response, expr filter.PrefixExpression) (bool, error) { func matchResPrefixExpr(res *http.Response, expr search.PrefixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpNot: case search.TokOpNot:
match, err := MatchResponseFilter(res, expr.Right) match, err := MatchResponseFilter(res, expr.Right)
if err != nil { if err != nil {
return false, err return false, err
@ -306,9 +286,9 @@ func matchResPrefixExpr(res *http.Response, expr filter.PrefixExpression) (bool,
} }
} }
func matchResInfixExpr(res *http.Response, expr filter.InfixExpression) (bool, error) { func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpAnd: case search.TokOpAnd:
left, err := MatchResponseFilter(res, expr.Left) left, err := MatchResponseFilter(res, expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -320,7 +300,7 @@ func matchResInfixExpr(res *http.Response, expr filter.InfixExpression) (bool, e
} }
return left && right, nil return left && right, nil
case filter.TokOpOr: case search.TokOpOr:
left, err := MatchResponseFilter(res, expr.Left) left, err := MatchResponseFilter(res, expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -334,7 +314,7 @@ func matchResInfixExpr(res *http.Response, expr filter.InfixExpression) (bool, e
return left || right, nil return left || right, nil
} }
left, ok := expr.Left.(filter.StringLiteral) left, ok := expr.Left.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("left operand must be a string literal") return false, errors.New("left operand must be a string literal")
} }
@ -344,30 +324,21 @@ func matchResInfixExpr(res *http.Response, expr filter.InfixExpression) (bool, e
return false, fmt.Errorf("failed to get string literal from response for left operand: %w", err) return false, fmt.Errorf("failed to get string literal from response for left operand: %w", err)
} }
if leftVal == "headers" { if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, res.Header) right, ok := expr.Right.(search.RegexpLiteral)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a regular expression") return false, errors.New("right operand must be a regular expression")
} }
switch expr.Operator { switch expr.Operator {
case filter.TokOpRe: case search.TokOpRe:
return right.MatchString(leftVal), nil return right.MatchString(leftVal), nil
case filter.TokOpNotRe: case search.TokOpNotRe:
return !right.MatchString(leftVal), nil return !right.MatchString(leftVal), nil
} }
} }
right, ok := expr.Right.(filter.StringLiteral) right, ok := expr.Right.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a string literal") return false, errors.New("right operand must be a string literal")
} }
@ -378,20 +349,20 @@ func matchResInfixExpr(res *http.Response, expr filter.InfixExpression) (bool, e
} }
switch expr.Operator { switch expr.Operator {
case filter.TokOpEq: case search.TokOpEq:
return leftVal == rightVal, nil return leftVal == rightVal, nil
case filter.TokOpNotEq: case search.TokOpNotEq:
return leftVal != rightVal, nil return leftVal != rightVal, nil
case filter.TokOpGt: case search.TokOpGt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal > rightVal, nil return leftVal > rightVal, nil
case filter.TokOpLt: case search.TokOpLt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal < rightVal, nil return leftVal < rightVal, nil
case filter.TokOpGtEq: case search.TokOpGtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil return leftVal >= rightVal, nil
case filter.TokOpLtEq: case search.TokOpLtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil return leftVal <= rightVal, nil
default: default:
@ -408,18 +379,7 @@ func getMappedStringLiteralFromRes(res *http.Response, s string) (string, error)
return s, nil return s, nil
} }
func matchResStringLiteral(res *http.Response, strLiteral filter.StringLiteral) (bool, error) { func matchResStringLiteral(res *http.Response, strLiteral search.StringLiteral) (bool, error) {
for key, values := range res.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range resFilterKeyFns { for _, fn := range resFilterKeyFns {
value, err := fn(res) value, err := fn(res)
if err != nil { if err != nil {

View File

@ -10,9 +10,9 @@ import (
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/log" "github.com/dstotijn/hetty/pkg/log"
"github.com/dstotijn/hetty/pkg/proxy" "github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/search"
) )
var ( var (
@ -56,16 +56,16 @@ type Service struct {
requestsEnabled bool requestsEnabled bool
responsesEnabled bool responsesEnabled bool
reqFilter filter.Expression reqFilter search.Expression
resFilter filter.Expression resFilter search.Expression
} }
type Config struct { type Config struct {
Logger log.Logger Logger log.Logger
RequestsEnabled bool RequestsEnabled bool
ResponsesEnabled bool ResponsesEnabled bool
RequestFilter filter.Expression RequestFilter search.Expression
ResponseFilter filter.Expression ResponseFilter search.Expression
} }
// RequestIDs implements sort.Interface. // RequestIDs implements sort.Interface.

View File

@ -1,10 +1,10 @@
package intercept package intercept
import "github.com/dstotijn/hetty/pkg/filter" import "github.com/dstotijn/hetty/pkg/search"
type Settings struct { type Settings struct {
RequestsEnabled bool RequestsEnabled bool
ResponsesEnabled bool ResponsesEnabled bool
RequestFilter filter.Expression RequestFilter search.Expression
ResponseFilter filter.Expression ResponseFilter search.Expression
} }

View File

@ -12,10 +12,10 @@ import (
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/log" "github.com/dstotijn/hetty/pkg/log"
"github.com/dstotijn/hetty/pkg/proxy" "github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
) )
type contextKey int type contextKey int
@ -77,7 +77,7 @@ type service struct {
type FindRequestsFilter struct { type FindRequestsFilter struct {
ProjectID ulid.ULID ProjectID ulid.ULID
OnlyInScope bool OnlyInScope bool
SearchExpr filter.Expression SearchExpr search.Expression
} }
type Config struct { type Config struct {

View File

@ -8,8 +8,8 @@ import (
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
) )
var reqLogSearchKeyFns = map[string]func(rl RequestLog) string{ var reqLogSearchKeyFns = map[string]func(rl RequestLog) string{
@ -36,22 +36,22 @@ var ResLogSearchKeyFns = map[string]func(rl ResponseLog) string{
// TODO: Request and response headers search key functions. // TODO: Request and response headers search key functions.
// Matches returns true if the supplied search expression evaluates to true. // Matches returns true if the supplied search expression evaluates to true.
func (reqLog RequestLog) Matches(expr filter.Expression) (bool, error) { func (reqLog RequestLog) Matches(expr search.Expression) (bool, error) {
switch e := expr.(type) { switch e := expr.(type) {
case filter.PrefixExpression: case search.PrefixExpression:
return reqLog.matchPrefixExpr(e) return reqLog.matchPrefixExpr(e)
case filter.InfixExpression: case search.InfixExpression:
return reqLog.matchInfixExpr(e) return reqLog.matchInfixExpr(e)
case filter.StringLiteral: case search.StringLiteral:
return reqLog.matchStringLiteral(e) return reqLog.matchStringLiteral(e)
default: default:
return false, fmt.Errorf("expression type (%T) not supported", expr) return false, fmt.Errorf("expression type (%T) not supported", expr)
} }
} }
func (reqLog RequestLog) matchPrefixExpr(expr filter.PrefixExpression) (bool, error) { func (reqLog RequestLog) matchPrefixExpr(expr search.PrefixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpNot: case search.TokOpNot:
match, err := reqLog.Matches(expr.Right) match, err := reqLog.Matches(expr.Right)
if err != nil { if err != nil {
return false, err return false, err
@ -63,9 +63,9 @@ func (reqLog RequestLog) matchPrefixExpr(expr filter.PrefixExpression) (bool, er
} }
} }
func (reqLog RequestLog) matchInfixExpr(expr filter.InfixExpression) (bool, error) { func (reqLog RequestLog) matchInfixExpr(expr search.InfixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpAnd: case search.TokOpAnd:
left, err := reqLog.Matches(expr.Left) left, err := reqLog.Matches(expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -77,7 +77,7 @@ func (reqLog RequestLog) matchInfixExpr(expr filter.InfixExpression) (bool, erro
} }
return left && right, nil return left && right, nil
case filter.TokOpOr: case search.TokOpOr:
left, err := reqLog.Matches(expr.Left) left, err := reqLog.Matches(expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -91,46 +91,28 @@ func (reqLog RequestLog) matchInfixExpr(expr filter.InfixExpression) (bool, erro
return left || right, nil return left || right, nil
} }
left, ok := expr.Left.(filter.StringLiteral) left, ok := expr.Left.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("left operand must be a string literal") return false, errors.New("left operand must be a string literal")
} }
leftVal := reqLog.getMappedStringLiteral(left.Value) leftVal := reqLog.getMappedStringLiteral(left.Value)
if leftVal == "req.headers" { if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, reqLog.Header) right, ok := expr.Right.(search.RegexpLiteral)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if leftVal == "res.headers" && reqLog.Response != nil {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, reqLog.Response.Header)
if err != nil {
return false, fmt.Errorf("failed to match response HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a regular expression") return false, errors.New("right operand must be a regular expression")
} }
switch expr.Operator { switch expr.Operator {
case filter.TokOpRe: case search.TokOpRe:
return right.MatchString(leftVal), nil return right.MatchString(leftVal), nil
case filter.TokOpNotRe: case search.TokOpNotRe:
return !right.MatchString(leftVal), nil return !right.MatchString(leftVal), nil
} }
} }
right, ok := expr.Right.(filter.StringLiteral) right, ok := expr.Right.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a string literal") return false, errors.New("right operand must be a string literal")
} }
@ -138,20 +120,20 @@ func (reqLog RequestLog) matchInfixExpr(expr filter.InfixExpression) (bool, erro
rightVal := reqLog.getMappedStringLiteral(right.Value) rightVal := reqLog.getMappedStringLiteral(right.Value)
switch expr.Operator { switch expr.Operator {
case filter.TokOpEq: case search.TokOpEq:
return leftVal == rightVal, nil return leftVal == rightVal, nil
case filter.TokOpNotEq: case search.TokOpNotEq:
return leftVal != rightVal, nil return leftVal != rightVal, nil
case filter.TokOpGt: case search.TokOpGt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal > rightVal, nil return leftVal > rightVal, nil
case filter.TokOpLt: case search.TokOpLt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal < rightVal, nil return leftVal < rightVal, nil
case filter.TokOpGtEq: case search.TokOpGtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil return leftVal >= rightVal, nil
case filter.TokOpLtEq: case search.TokOpLtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil return leftVal <= rightVal, nil
default: default:
@ -180,18 +162,7 @@ func (reqLog RequestLog) getMappedStringLiteral(s string) string {
return s return s
} }
func (reqLog RequestLog) matchStringLiteral(strLiteral filter.StringLiteral) (bool, error) { func (reqLog RequestLog) matchStringLiteral(strLiteral search.StringLiteral) (bool, error) {
for key, values := range reqLog.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range reqLogSearchKeyFns { for _, fn := range reqLogSearchKeyFns {
if strings.Contains( if strings.Contains(
strings.ToLower(fn(reqLog)), strings.ToLower(fn(reqLog)),
@ -202,17 +173,6 @@ func (reqLog RequestLog) matchStringLiteral(strLiteral filter.StringLiteral) (bo
} }
if reqLog.Response != nil { if reqLog.Response != nil {
for key, values := range reqLog.Response.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range ResLogSearchKeyFns { for _, fn := range ResLogSearchKeyFns {
if strings.Contains( if strings.Contains(
strings.ToLower(fn(*reqLog.Response)), strings.ToLower(fn(*reqLog.Response)),

View File

@ -3,8 +3,8 @@ package reqlog_test
import ( import (
"testing" "testing"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/search"
) )
func TestRequestLogMatch(t *testing.T) { func TestRequestLogMatch(t *testing.T) {
@ -176,7 +176,7 @@ func TestRequestLogMatch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
searchExpr, err := filter.ParseQuery(tt.query) searchExpr, err := search.ParseQuery(tt.query)
assertError(t, nil, err) assertError(t, nil, err)
got, err := tt.requestLog.Matches(searchExpr) got, err := tt.requestLog.Matches(searchExpr)

View File

@ -1,4 +1,4 @@
package filter package search
import ( import (
"encoding/gob" "encoding/gob"
@ -78,10 +78,8 @@ func (rl *RegexpLiteral) UnmarshalBinary(data []byte) error {
} }
func init() { func init() {
// The `filter` package was previously named `search`. gob.Register(PrefixExpression{})
// We use the legacy names for backwards compatibility with existing database data. gob.Register(InfixExpression{})
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.PrefixExpression", PrefixExpression{}) gob.Register(StringLiteral{})
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.InfixExpression", InfixExpression{}) gob.Register(RegexpLiteral{})
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.StringLiteral", StringLiteral{})
gob.RegisterName("github.com/dstotijn/hetty/pkg/search.RegexpLiteral", RegexpLiteral{})
} }

View File

@ -1,4 +1,4 @@
package filter package search
import ( import (
"fmt" "fmt"

View File

@ -1,4 +1,4 @@
package filter package search
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package filter package search
import ( import (
"fmt" "fmt"
@ -88,7 +88,7 @@ func ParseQuery(input string) (expr Expression, err error) {
p.nextToken() p.nextToken()
if p.curTokenIs(TokEOF) { if p.curTokenIs(TokEOF) {
return nil, fmt.Errorf("filter: unexpected EOF") return nil, fmt.Errorf("search: unexpected EOF")
} }
for !p.curTokenIs(TokEOF) { for !p.curTokenIs(TokEOF) {
@ -96,7 +96,7 @@ func ParseQuery(input string) (expr Expression, err error) {
switch { switch {
case err != nil: case err != nil:
return nil, fmt.Errorf("filter: could not parse expression: %w", err) return nil, fmt.Errorf("search: could not parse expression: %w", err)
case expr == nil: case expr == nil:
expr = right expr = right
default: default:

View File

@ -1,4 +1,4 @@
package filter package search
import ( import (
"errors" "errors"
@ -20,7 +20,7 @@ func TestParseQuery(t *testing.T) {
name: "empty query", name: "empty query",
input: "", input: "",
expectedExpression: nil, expectedExpression: nil,
expectedError: errors.New("filter: unexpected EOF"), expectedError: errors.New("search: unexpected EOF"),
}, },
{ {
name: "string literal expression", name: "string literal expression",

View File

@ -7,9 +7,9 @@ import (
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
) )
var senderReqSearchKeyFns = map[string]func(req Request) string{ var senderReqSearchKeyFns = map[string]func(req Request) string{
@ -29,22 +29,22 @@ var senderReqSearchKeyFns = map[string]func(req Request) string{
// TODO: Request and response headers search key functions. // TODO: Request and response headers search key functions.
// Matches returns true if the supplied search expression evaluates to true. // Matches returns true if the supplied search expression evaluates to true.
func (req Request) Matches(expr filter.Expression) (bool, error) { func (req Request) Matches(expr search.Expression) (bool, error) {
switch e := expr.(type) { switch e := expr.(type) {
case filter.PrefixExpression: case search.PrefixExpression:
return req.matchPrefixExpr(e) return req.matchPrefixExpr(e)
case filter.InfixExpression: case search.InfixExpression:
return req.matchInfixExpr(e) return req.matchInfixExpr(e)
case filter.StringLiteral: case search.StringLiteral:
return req.matchStringLiteral(e) return req.matchStringLiteral(e)
default: default:
return false, fmt.Errorf("expression type (%T) not supported", expr) return false, fmt.Errorf("expression type (%T) not supported", expr)
} }
} }
func (req Request) matchPrefixExpr(expr filter.PrefixExpression) (bool, error) { func (req Request) matchPrefixExpr(expr search.PrefixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpNot: case search.TokOpNot:
match, err := req.Matches(expr.Right) match, err := req.Matches(expr.Right)
if err != nil { if err != nil {
return false, err return false, err
@ -56,9 +56,9 @@ func (req Request) matchPrefixExpr(expr filter.PrefixExpression) (bool, error) {
} }
} }
func (req Request) matchInfixExpr(expr filter.InfixExpression) (bool, error) { func (req Request) matchInfixExpr(expr search.InfixExpression) (bool, error) {
switch expr.Operator { switch expr.Operator {
case filter.TokOpAnd: case search.TokOpAnd:
left, err := req.Matches(expr.Left) left, err := req.Matches(expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -70,7 +70,7 @@ func (req Request) matchInfixExpr(expr filter.InfixExpression) (bool, error) {
} }
return left && right, nil return left && right, nil
case filter.TokOpOr: case search.TokOpOr:
left, err := req.Matches(expr.Left) left, err := req.Matches(expr.Left)
if err != nil { if err != nil {
return false, err return false, err
@ -84,46 +84,28 @@ func (req Request) matchInfixExpr(expr filter.InfixExpression) (bool, error) {
return left || right, nil return left || right, nil
} }
left, ok := expr.Left.(filter.StringLiteral) left, ok := expr.Left.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("left operand must be a string literal") return false, errors.New("left operand must be a string literal")
} }
leftVal := req.getMappedStringLiteral(left.Value) leftVal := req.getMappedStringLiteral(left.Value)
if leftVal == "req.headers" { if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, req.Header) right, ok := expr.Right.(search.RegexpLiteral)
if err != nil {
return false, fmt.Errorf("failed to match request HTTP headers: %w", err)
}
return match, nil
}
if leftVal == "res.headers" && req.Response != nil {
match, err := filter.MatchHTTPHeaders(expr.Operator, expr.Right, req.Response.Header)
if err != nil {
return false, fmt.Errorf("failed to match response HTTP headers: %w", err)
}
return match, nil
}
if expr.Operator == filter.TokOpRe || expr.Operator == filter.TokOpNotRe {
right, ok := expr.Right.(filter.RegexpLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a regular expression") return false, errors.New("right operand must be a regular expression")
} }
switch expr.Operator { switch expr.Operator {
case filter.TokOpRe: case search.TokOpRe:
return right.MatchString(leftVal), nil return right.MatchString(leftVal), nil
case filter.TokOpNotRe: case search.TokOpNotRe:
return !right.MatchString(leftVal), nil return !right.MatchString(leftVal), nil
} }
} }
right, ok := expr.Right.(filter.StringLiteral) right, ok := expr.Right.(search.StringLiteral)
if !ok { if !ok {
return false, errors.New("right operand must be a string literal") return false, errors.New("right operand must be a string literal")
} }
@ -131,20 +113,20 @@ func (req Request) matchInfixExpr(expr filter.InfixExpression) (bool, error) {
rightVal := req.getMappedStringLiteral(right.Value) rightVal := req.getMappedStringLiteral(right.Value)
switch expr.Operator { switch expr.Operator {
case filter.TokOpEq: case search.TokOpEq:
return leftVal == rightVal, nil return leftVal == rightVal, nil
case filter.TokOpNotEq: case search.TokOpNotEq:
return leftVal != rightVal, nil return leftVal != rightVal, nil
case filter.TokOpGt: case search.TokOpGt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal > rightVal, nil return leftVal > rightVal, nil
case filter.TokOpLt: case search.TokOpLt:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal < rightVal, nil return leftVal < rightVal, nil
case filter.TokOpGtEq: case search.TokOpGtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil return leftVal >= rightVal, nil
case filter.TokOpLtEq: case search.TokOpLtEq:
// TODO(?) attempt to parse as int. // TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil return leftVal <= rightVal, nil
default: default:
@ -173,18 +155,7 @@ func (req Request) getMappedStringLiteral(s string) string {
return s return s
} }
func (req Request) matchStringLiteral(strLiteral filter.StringLiteral) (bool, error) { func (req Request) matchStringLiteral(strLiteral search.StringLiteral) (bool, error) {
for key, values := range req.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range senderReqSearchKeyFns { for _, fn := range senderReqSearchKeyFns {
if strings.Contains( if strings.Contains(
strings.ToLower(fn(req)), strings.ToLower(fn(req)),
@ -195,17 +166,6 @@ func (req Request) matchStringLiteral(strLiteral filter.StringLiteral) (bool, er
} }
if req.Response != nil { if req.Response != nil {
for key, values := range req.Response.Header {
for _, value := range values {
if strings.Contains(
strings.ToLower(fmt.Sprintf("%v: %v", key, value)),
strings.ToLower(strLiteral.Value),
) {
return true, nil
}
}
}
for _, fn := range reqlog.ResLogSearchKeyFns { for _, fn := range reqlog.ResLogSearchKeyFns {
if strings.Contains( if strings.Contains(
strings.ToLower(fn(*req.Response)), strings.ToLower(fn(*req.Response)),

View File

@ -3,8 +3,8 @@ package sender_test
import ( import (
"testing" "testing"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/search"
"github.com/dstotijn/hetty/pkg/sender" "github.com/dstotijn/hetty/pkg/sender"
) )
@ -177,7 +177,7 @@ func TestRequestLogMatch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
searchExpr, err := filter.ParseQuery(tt.query) searchExpr, err := search.ParseQuery(tt.query)
assertError(t, nil, err) assertError(t, nil, err)
got, err := tt.senderReq.Matches(searchExpr) got, err := tt.senderReq.Matches(searchExpr)

View File

@ -12,9 +12,9 @@ import (
"github.com/oklog/ulid" "github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/filter"
"github.com/dstotijn/hetty/pkg/reqlog" "github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope" "github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
) )
//nolint:gosec //nolint:gosec
@ -54,7 +54,7 @@ type service struct {
type FindRequestsFilter struct { type FindRequestsFilter struct {
ProjectID ulid.ULID ProjectID ulid.ULID
OnlyInScope bool OnlyInScope bool
SearchExpr filter.Expression SearchExpr search.Expression
} }
type Config struct { type Config struct {