Skip to content

Commit 7f819ba

Browse files
authored
Sigma Detection Engine Support PoC (#11)
* Dockerfile * readme * dockerfile * adding Sigma support with test rules * dev dockerfile * make command for docker test * fix engine test * adding sigma test with simple site and rule * readme on Make commands * clean cli * run all tests * dots not lines * readme
1 parent f41a69d commit 7f819ba

16 files changed

Lines changed: 628 additions & 115 deletions

File tree

Dockerfile

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
FROM golang:1.24-bullseye AS builder
2+
3+
WORKDIR /src
4+
5+
COPY go.mod go.sum ./
6+
RUN go mod download
7+
8+
COPY . .
9+
10+
ARG PISCES_VERSION=dev
11+
12+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
13+
go build \
14+
-ldflags="-s -w -X main.version=${PISCES_VERSION}" \
15+
-o /app/pisces \
16+
./cmd/cli
17+
18+
FROM chromedp/headless-shell:latest
19+
20+
ENV PATH="/headless-shell:${PATH}"
21+
22+
COPY --from=builder /app/pisces /usr/local/bin/pisces
23+
24+
COPY . /app
25+
26+
WORKDIR /app
27+
28+
ENTRYPOINT ["pisces"]
29+
# CMD ["--help"]

Dockerfile.dev

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Development / test image for Pisces.
2+
# Includes:
3+
# - Go toolchain
4+
# - Chromium browser for chromedp-based tests
5+
#
6+
# Usage:
7+
# docker build -f Dockerfile.dev -t pisces-dev .
8+
# docker run --rm -it -v "$PWD":/app -w /app pisces-dev go test ./...
9+
10+
FROM golang:1.24.4-bookworm
11+
12+
RUN apt update && \
13+
apt install -y --no-install-recommends \
14+
chromium \
15+
ca-certificates \
16+
fonts-liberation \
17+
tzdata \
18+
git \
19+
&& rm -rf /var/lib/apt/lists/*
20+
21+
ENV CHROME_PATH=/usr/bin/chromium
22+
23+
WORKDIR /app
24+
25+
CMD ["/bin/bash"]

Makefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ chromedp.run:
2424

2525
.PHONY: release
2626

27+
test.docker:
28+
docker build -f Dockerfile.dev -t pisces-dev . && \
29+
docker run --rm -it -v "$$PWD":/app -w /app pisces-dev go test -v ./...
30+
31+
run.docker:
32+
docker build -t pisces . && \
33+
docker run --rm -it \
34+
-v "$$PWD":/app \
35+
-w /app \
36+
pisces $(ARGS)
37+
2738
release:
2839
@echo "Creating new release..."
2940
@# Get the latest tag, default to v0.0.0 if no tags exist

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@ make build.cli
1616
./build/pisces -h
1717
```
1818

19+
### Dockerfile
20+
21+
The Dockerfile supports running Pisces as a container. The container adds the built binary to a `chromedp` headless container to run the scanner.
22+
23+
Example:
24+
```
25+
docker run --rm \
26+
-v "$(pwd)":/app \
27+
pisces:latest \
28+
analyze https://google.com
29+
```
30+
31+
This also works with the Make command `make run.docker ARGS="[args]"`.
32+
33+
### Dockerfile.dev
34+
35+
`Dockerfile.dev` works along with the `make test.docker` command to run the integration tests within a container.
36+
1937
## Usage
2038

2139
```

cmd/cli/main.go

Lines changed: 120 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import (
1818
"github.com/urfave/cli/v3"
1919
)
2020

21-
var logger *zerolog.Logger
22-
var version string
21+
var (
22+
logger *zerolog.Logger
23+
version string
24+
)
2325

2426
type taskCallbackFn = func(*cli.Command, *engine.Engine) error
2527

@@ -29,23 +31,28 @@ func main() {
2931
}
3032

3133
baseFlags := []cli.Flag{
32-
&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}},
33-
&cli.BoolFlag{Name: "remote", Aliases: []string{"r"}},
34-
&cli.BoolFlag{Name: "headfull", Aliases: []string{"H"}},
35-
&cli.IntFlag{Name: "concurrency", Aliases: []string{"c"}},
36-
&cli.IntFlag{Name: "port", Value: 9222},
37-
&cli.StringFlag{Name: "device-type", Value: "desktop", Action: validDeviceType},
38-
&cli.StringFlag{Name: "device-size", Value: "large", Action: validDeviceSize},
39-
&cli.StringFlag{Name: "host", Value: "127.0.0.1"},
40-
&cli.StringFlag{Name: "user-agent", Value: "chrome"},
34+
&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, Usage: "enable debug logging"},
35+
&cli.BoolFlag{Name: "remote", Aliases: []string{"r"}, Usage: "use a remote Chrome DevTools instance"},
36+
&cli.BoolFlag{Name: "headfull", Aliases: []string{"H"}, Usage: "run browser in headfull mode"},
37+
&cli.IntFlag{Name: "concurrency", Aliases: []string{"c"}, Usage: "number of concurrent workers"},
38+
&cli.IntFlag{Name: "port", Value: 9222, Usage: "remote DevTools port"},
39+
&cli.StringFlag{Name: "device-type", Value: "desktop", Usage: "device type (desktop/mobile/tablet)", Action: validDeviceType},
40+
&cli.StringFlag{Name: "device-size", Value: "large", Usage: "device size preset", Action: validDeviceSize},
41+
&cli.StringFlag{Name: "host", Value: "127.0.0.1", Usage: "remote DevTools host"},
42+
&cli.StringFlag{Name: "user-agent", Value: "chrome", Usage: "browser user-agent preset"},
43+
&cli.StringFlag{
44+
Name: "rules-dir",
45+
Value: "rules",
46+
Usage: "directory containing Sigma rules (default: rules/)",
47+
},
4148
}
4249

4350
withOutputFlags := append([]cli.Flag{
44-
&cli.StringFlag{Name: "output", Value: "pisces.ndjson", Aliases: []string{"o"}},
51+
&cli.StringFlag{Name: "output", Value: "pisces.ndjson", Aliases: []string{"o"}, Usage: "output NDJSON file"},
4552
}, baseFlags...)
4653

4754
withWaitFlags := append([]cli.Flag{
48-
&cli.IntFlag{Name: "wait", Value: 300, Aliases: []string{"w"}},
55+
&cli.IntFlag{Name: "wait", Value: 300, Aliases: []string{"w"}, Usage: "wait time (seconds) after load before analysis"},
4956
}, withOutputFlags...)
5057

5158
ver := version
@@ -64,9 +71,9 @@ func main() {
6471
Arguments: baseArgs,
6572
Flags: withWaitFlags,
6673
Action: func(ctx context.Context, cmd *cli.Command) error {
67-
params := map[string]any{}
68-
params["wait"] = cmd.Int("wait")
69-
74+
params := map[string]any{
75+
"wait": cmd.Int("wait"),
76+
}
7077
return runTask(ctx, cmd, "analyze", params, outputResultJson)
7178
},
7279
},
@@ -84,46 +91,10 @@ func main() {
8491
Usage: "Screenshot one or more URLs",
8592
Arguments: baseArgs,
8693
Flags: append([]cli.Flag{
87-
&cli.StringFlag{Name: "output-dir", Value: "tmp/", Aliases: []string{"o"}},
94+
&cli.StringFlag{Name: "output-dir", Value: "tmp/", Aliases: []string{"o"}, Usage: "directory for screenshots"},
8895
}, baseFlags...),
8996
Action: func(ctx context.Context, cmd *cli.Command) error {
90-
return runTask(ctx, cmd, "screenshot", map[string]any{}, func(cmd *cli.Command, e *engine.Engine) error {
91-
outputDir := cmd.String("output-dir")
92-
err := os.MkdirAll(outputDir, 0755)
93-
if err != nil {
94-
return err
95-
}
96-
97-
for r := range e.Results() {
98-
if r.Error != nil {
99-
logger.Warn().Msgf("result error: %v", r.Error)
100-
continue
101-
}
102-
103-
logger.Info().Msgf("result for %s (duration %s)", r.URL, r.Elapsed.String())
104-
105-
sr, ok := r.Result.(*engine.ScreenshotResult)
106-
if !ok {
107-
return errors.New("screenshot result error")
108-
}
109-
110-
fileName, err := urlToFilename(r.URL)
111-
if err != nil {
112-
return err
113-
}
114-
115-
out, err := os.Create(filepath.Join(outputDir, fmt.Sprintf("%s.png", fileName)))
116-
if err != nil {
117-
return err
118-
}
119-
120-
if _, err = out.Write(*sr.Buffer); err != nil {
121-
return err
122-
}
123-
}
124-
125-
return nil
126-
})
97+
return runTask(ctx, cmd, "screenshot", map[string]any{}, screenshotCallback)
12798
},
12899
},
129100
},
@@ -176,38 +147,117 @@ func outputResultJson(cmd *cli.Command, e *engine.Engine) error {
176147
output := cmd.String("output")
177148
out, err := os.Create(output)
178149
if err != nil {
179-
panic(err)
150+
return fmt.Errorf("create output file: %w", err)
151+
}
152+
defer func() {
153+
if cerr := out.Close(); cerr != nil {
154+
logger.Warn().Err(cerr).Msg("file close error")
155+
}
156+
}()
157+
158+
rulesDir := cmd.String("rules-dir")
159+
if rulesDir == "" {
160+
rulesDir = "rules"
180161
}
181162

163+
if rulesDir != "rules" {
164+
logger.Info().Str("rules_dir", rulesDir).Msg("using custom Sigma rules directory")
165+
}
166+
167+
if err := engine.InitSigmaEngine(rulesDir, logger); err != nil {
168+
logger.Warn().Err(err).Msg("failed to initialize Sigma engine")
169+
}
170+
171+
ctx := context.Background()
172+
182173
for r := range e.Results() {
183174
if r.Error != nil {
184175
logger.Warn().Msgf("result error: %v", r.Error)
185176
continue
186177
}
187178

188-
logger.Info().Msgf("result for %s (duration %s)", r.URL, r.Elapsed.String())
179+
matches, err := engine.EvaluateSigmaResult(ctx, r, logger)
180+
if err != nil {
181+
logger.Warn().Err(err).Msg("sigma evaluation error")
182+
} else {
183+
for _, m := range matches {
184+
if !m.Match {
185+
continue
186+
}
187+
logger.Info().
188+
Str("rule_id", m.Rule.ID).
189+
Str("title", m.Rule.Title).
190+
Str("url", r.URL).
191+
Msg("[+] SIGMA ALERT: rule matched for URL")
192+
}
193+
}
194+
195+
logger.Info().
196+
Str("url", r.URL).
197+
Str("duration", r.Elapsed.String()).
198+
Msg("result")
189199

190200
line, err := json.Marshal(r.Result)
191201
if err != nil {
192-
logger.Warn().Msgf("result json marshal error: %v", r.Error)
202+
logger.Warn().Err(err).Msg("result JSON marshal error")
203+
continue
193204
}
194-
195205
if _, err = out.Write(line); err != nil {
196-
logger.Warn().Msgf("result json write error: %v", r.Error)
206+
logger.Warn().Err(err).Msg("result JSON write error")
207+
continue
197208
}
198-
199209
if _, err = out.Write([]byte("\n")); err != nil {
200-
logger.Warn().Msgf("result json write error: %v", r.Error)
210+
logger.Warn().Err(err).Msg("result JSON newline write error")
211+
continue
201212
}
202-
203213
logger.Debug().Msgf("wrote to file %s", output)
204214
}
205215

206-
if err = out.Close(); err != nil {
207-
logger.Warn().Msgf("file close error: %v", err)
216+
logger.Info().Msg("done")
217+
return nil
218+
}
219+
220+
func screenshotCallback(cmd *cli.Command, e *engine.Engine) error {
221+
outputDir := cmd.String("output-dir")
222+
if err := os.MkdirAll(outputDir, 0o755); err != nil {
223+
return fmt.Errorf("create output dir: %w", err)
208224
}
209225

210-
logger.Info().Msg("done")
226+
for r := range e.Results() {
227+
if r.Error != nil {
228+
logger.Warn().Msgf("result error: %v", r.Error)
229+
continue
230+
}
231+
232+
logger.Info().
233+
Str("url", r.URL).
234+
Str("duration", r.Elapsed.String()).
235+
Msg("screenshot result")
236+
237+
sr, ok := r.Result.(*engine.ScreenshotResult)
238+
if !ok {
239+
return errors.New("screenshot result error: unexpected type")
240+
}
241+
242+
fileName, err := urlToFilename(r.URL)
243+
if err != nil {
244+
return fmt.Errorf("build screenshot filename: %w", err)
245+
}
246+
247+
outPath := filepath.Join(outputDir, fmt.Sprintf("%s.png", fileName))
248+
out, err := os.Create(outPath)
249+
if err != nil {
250+
return fmt.Errorf("create screenshot file: %w", err)
251+
}
252+
253+
if _, err = out.Write(*sr.Buffer); err != nil {
254+
out.Close()
255+
return fmt.Errorf("write screenshot file: %w", err)
256+
}
257+
if err := out.Close(); err != nil {
258+
return fmt.Errorf("close screenshot file: %w", err)
259+
}
260+
}
211261

212262
return nil
213263
}
@@ -216,15 +266,13 @@ func validDeviceType(ctx context.Context, cmd *cli.Command, v string) error {
216266
if !browser.IsValidDeviceType(v) {
217267
return fmt.Errorf("invalid device type: %v", v)
218268
}
219-
220269
return nil
221270
}
222271

223272
func validDeviceSize(ctx context.Context, cmd *cli.Command, v string) error {
224273
if !browser.IsValidDeviceSize(v) {
225274
return fmt.Errorf("invalid device size: %v", v)
226275
}
227-
228276
return nil
229277
}
230278

@@ -237,14 +285,15 @@ func urlToFilename(taskURL string) (string, error) {
237285
domain := u.Host
238286
path := u.Path
239287

240-
combined := domain + path
241-
combined = strings.Trim(combined, "/")
288+
combined := strings.Trim(domain+path, "/")
242289

243290
safe := strings.Map(func(r rune) rune {
244-
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' || r == '.' {
291+
switch r {
292+
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', '.':
245293
return '_'
294+
default:
295+
return r
246296
}
247-
return r
248297
}, combined)
249298

250299
if safe == "" {

0 commit comments

Comments
 (0)