├── var ├── .gitkeep ├── http │ └── serve │ │ └── .gitkeep └── https │ ├── certs │ └── .gitkeep │ └── serve │ └── .gitkeep ├── docs ├── docs │ ├── use_cases.md │ ├── links.md │ ├── features.md │ ├── index.md │ ├── requirements.md │ ├── installation.md │ ├── meloctl.md │ ├── layers.md │ └── rules.md └── mkdocs.yml ├── rules ├── rules-enabled │ └── .gitkeep └── rules-available │ ├── web.yml │ ├── nas.yml │ ├── rdp.yml │ ├── microsoft.yml │ ├── cms.yml │ ├── vpn.yml │ ├── server.yml │ └── router.yml ├── filter.bpf ├── .dockerignore ├── readme ├── melody_demo.gif └── melody_demo_dash.png ├── internal ├── rules │ ├── test_resources │ │ ├── ip_values.pcap │ │ ├── http_values.pcap │ │ ├── logic_flow.pcap │ │ ├── port_values.pcap │ │ ├── tcp_values.pcap │ │ ├── udp_values.pcap │ │ ├── icmpv4_values.pcap │ │ ├── icmpv6_values.pcap │ │ ├── port_rules.yml │ │ ├── ip_rules.yml │ │ ├── icmpv4_rules.yml │ │ ├── icmpv6_rules.yml │ │ ├── udp_rules.yml │ │ ├── tcp_rules.yml │ │ ├── http_rules.yml │ │ └── logic_flow_rules.yml │ ├── rules.go │ ├── flags_conditions.go │ ├── tagparser.go │ ├── load.go │ ├── testing.go │ ├── conditions_test.go │ ├── conditions.go │ └── raw_rules.go ├── loggable │ └── loggable.go ├── meloctl │ └── prompt │ │ ├── validators.go │ │ └── prompt.go ├── fileutils │ └── fileutils.go ├── tagparser │ └── tagparser.go ├── logdata │ ├── udp.go │ ├── payload.go │ ├── tcp.go │ ├── icmpv6.go │ ├── icmpv4.go │ ├── http.go │ ├── ipv6.go │ ├── base.go │ └── ipv4.go ├── events │ ├── event.go │ ├── helpers │ │ └── layers.go │ ├── icmpv4.go │ ├── base.go │ ├── icmpv6.go │ ├── tcp.go │ ├── udp.go │ └── http.go ├── clihelper │ └── multistring.go ├── config │ └── tagparser.go ├── router │ ├── fs.go │ ├── router.go │ └── middlewares.go ├── sessions │ └── sessions.go ├── assembler │ └── stream.go ├── engine │ └── engine.go ├── filters │ ├── utils.go │ └── portrules.go ├── logging │ ├── control.go │ └── loggers.go ├── httpparser │ └── httpparser.go └── sensor │ └── sensor.go ├── .gitignore ├── cmd ├── meloctl │ ├── main.go │ ├── config.go │ ├── get.go │ ├── init.go │ ├── set.go │ └── root.go └── melody │ └── main.go ├── etc ├── melody.conf └── melody.service ├── .golangci.yml ├── docker-compose.yml ├── Dockerfile ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── .goreleaser.yml ├── go.mod ├── LICENSE ├── Makefile ├── config.yml └── README.md /var/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/docs/use_cases.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /var/http/serve/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rules/rules-enabled/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /var/https/certs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /var/https/serve/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /filter.bpf: -------------------------------------------------------------------------------- 1 | inbound and not net 127.0.0.0/24 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | logs/ 3 | readme/ 4 | .git/ 5 | .github/ 6 | -------------------------------------------------------------------------------- /readme/melody_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/readme/melody_demo.gif -------------------------------------------------------------------------------- /readme/melody_demo_dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/readme/melody_demo_dash.png -------------------------------------------------------------------------------- /docs/docs/links.md: -------------------------------------------------------------------------------- 1 | + The project is now archived in the [Github repository](https://github.com/ma111e/melody) 2 | -------------------------------------------------------------------------------- /internal/rules/test_resources/ip_values.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/ip_values.pcap -------------------------------------------------------------------------------- /internal/rules/test_resources/http_values.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/http_values.pcap -------------------------------------------------------------------------------- /internal/rules/test_resources/logic_flow.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/logic_flow.pcap -------------------------------------------------------------------------------- /internal/rules/test_resources/port_values.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/port_values.pcap -------------------------------------------------------------------------------- /internal/rules/test_resources/tcp_values.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/tcp_values.pcap -------------------------------------------------------------------------------- /internal/rules/test_resources/udp_values.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/udp_values.pcap -------------------------------------------------------------------------------- /internal/rules/test_resources/icmpv4_values.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/icmpv4_values.pcap -------------------------------------------------------------------------------- /internal/rules/test_resources/icmpv6_values.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma111e/melody/HEAD/internal/rules/test_resources/icmpv6_values.pcap -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | ./melody 3 | melody.exe 4 | logs/ 5 | docs/site 6 | !*/.gitkeep 7 | var/http/serve/* 8 | var/https/serve/* 9 | var/https/certs/* 10 | -------------------------------------------------------------------------------- /cmd/meloctl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | if err := RootCmd.Execute(); err != nil { 10 | fmt.Println(err) 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /etc/melody.conf: -------------------------------------------------------------------------------- 1 | [program:melody] 2 | command=/opt/melody/melody 3 | directory=/opt/melody 4 | stdout_logfile=/opt/melody/melody_supervisor.out 5 | stderr_logfile=/opt/melody/melody_supervisor.err 6 | autostart=true 7 | autorestart=true 8 | -------------------------------------------------------------------------------- /etc/melody.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Melody sensor 3 | After=network-online.target 4 | 5 | [Service] 6 | Type=simple 7 | WorkingDirectory=/opt/melody 8 | ExecStart=/opt/melody/melody 9 | Restart=on-failure 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /internal/loggable/loggable.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | // Loggable is an interface to allow mutual use of events.BaseEvent for logdata.BaseLogData 4 | type Loggable interface { 5 | GetSession() string 6 | GetTags() map[string][]string 7 | GetKind() string 8 | GetSourceIP() string 9 | GetDestPort() uint16 10 | } 11 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | skip-dirs: 3 | - docs 4 | - .github 5 | 6 | linters-settings: 7 | gocyclo: 8 | min-complexity: 15 9 | gofmt: 10 | simplify: true 11 | misspell: 12 | locale: US 13 | 14 | linters: 15 | enable: 16 | - gofmt 17 | - golint 18 | - misspell 19 | # - gocyclo 20 | disable: 21 | - errcheck 22 | disable-all: false 23 | fast: false 24 | 25 | issues: 26 | exclude-use-default: false 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | melody: 4 | build: . 5 | restart: always 6 | network_mode: "host" 7 | ports: 8 | - "10080:10080" 9 | - "10443:10443" 10 | environment: 11 | - MELODY_CLI=${MELODY_CLI} 12 | volumes: 13 | - ./filter.bpf:/app/filter.bpf:ro 14 | - ./config.yml:/app/config.yml:ro 15 | - ./var:/app/var:ro 16 | - ./rules:/app/rules:ro 17 | - ./logs:/app/logs 18 | -------------------------------------------------------------------------------- /rules/rules-available/web.yml: -------------------------------------------------------------------------------- 1 | Dropper: 2 | layer: tcp 3 | meta: 4 | id: 00831c4f-17ec-4221-a2e8-85e1b146c35e 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/11/07 10 | description: "Attempt to exploit a vulnerability to drop a file on the system" 11 | match: 12 | tcp.payload: 13 | contains: 14 | - "wget" 15 | tags: 16 | profile: "dropper" 17 | data: "drop_server" 18 | -------------------------------------------------------------------------------- /internal/meloctl/prompt/validators.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | var ( 8 | validatorsMap = map[string]func(candidate string) error{ 9 | "date": checkDate, 10 | } 11 | 12 | // DateFormat is the format used to validate dates inputs from the user 13 | DateFormat = "2006/01/02" 14 | ) 15 | 16 | func checkDate(candidate string) error { 17 | _, err := time.Parse(DateFormat, candidate) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/fileutils/fileutils.go: -------------------------------------------------------------------------------- 1 | package fileutils 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // Exists check if a given file exists 8 | func Exists(filepath string) (bool, error) { 9 | var err error 10 | ok := true 11 | 12 | if f, openError := os.Open(filepath); openError != nil { 13 | _ = f.Close() 14 | // Return an error only if it is not a "not exist" error 15 | if os.IsNotExist(openError) { 16 | ok = false 17 | } else { 18 | err = openError 19 | } 20 | 21 | } 22 | 23 | return ok, err 24 | } 25 | -------------------------------------------------------------------------------- /internal/tagparser/tagparser.go: -------------------------------------------------------------------------------- 1 | package tagparser 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/fatih/structtag" 7 | ) 8 | 9 | // ParseYamlTagValue parses a tag to retrieve the value of its yaml key 10 | func ParseYamlTagValue(tag reflect.StructTag) (string, error) { 11 | parsedRuleTags, err := structtag.Parse(string(tag)) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | yamlTag, err := parsedRuleTags.Get("yaml") 17 | if err != nil { 18 | return "", err 19 | } 20 | return yamlTag.Value(), nil 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as base 2 | 3 | # Install libpcap headers and the compilation tools needed 4 | RUN apk add --update --no-cache ca-certificates libpcap-dev build-base 5 | 6 | WORKDIR /app 7 | 8 | ENV GO111MODULE=on \ 9 | CGO_ENABLED=1 \ 10 | GOOS=linux \ 11 | GOARCH=amd64 12 | 13 | COPY go.mod . 14 | COPY go.sum . 15 | 16 | RUN go mod download 17 | 18 | COPY . . 19 | 20 | RUN go build -ldflags="-s -w -extldflags '-static'" -o /app/melody ./cmd/melody 21 | 22 | # Copy only what's needed 23 | FROM scratch 24 | COPY --from=base /app /app 25 | WORKDIR /app 26 | 27 | ENTRYPOINT ["/app/melody"] 28 | -------------------------------------------------------------------------------- /rules/rules-available/nas.yml: -------------------------------------------------------------------------------- 1 | CVE-2020-9054 (ZyXEL NAS): 2 | layer: http 3 | meta: 4 | id: d7943e6c-4348-48b1-b2c5-0c553474a7ac 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/11/07 10 | description: "Checking or trying to exploit CVE-2020-9054" 11 | references: 12 | - "https://nvd.nist.gov/vuln/detail/CVE-2020-9054" 13 | match: 14 | http.uri: 15 | startswith: 16 | - "/adv,/cgi-bin/weblogin.cgi?username=admin" 17 | tags: 18 | cve: "cve-2020-9054" 19 | impact: "rce" 20 | device: "nas" 21 | vendor: "zyxel" 22 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Melody Documentation 2 | theme: 3 | name: material 4 | palette: 5 | primary: cyan 6 | nav: 7 | - Features: features.md 8 | - Requirements: requirements.md 9 | - Quickstart: quickstart.md 10 | - Installation: installation.md 11 | - Rules: rules.md 12 | - Layers: layers.md 13 | #- Use cases: use_cases.md 14 | - Links: links.md 15 | - Meloctl: meloctl.md 16 | 17 | markdown_extensions: 18 | - admonition 19 | - pymdownx.superfences 20 | - footnotes 21 | - toc: 22 | permalink: "#" 23 | plugins: 24 | - search 25 | - awesome-pages 26 | 27 | repo_url: https://github.com/ma111e/melody 28 | repo_name: ma111e/melody 29 | -------------------------------------------------------------------------------- /rules/rules-available/rdp.yml: -------------------------------------------------------------------------------- 1 | RDP Login Attempt: 2 | layer: tcp 3 | meta: 4 | id: cbe12945-d9d1-4a9d-9138-e07c4d504eb7 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/11/07 10 | description: "RDP login attempt" 11 | references: 12 | - "https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/e78db616-689f-4b8a-8a99-525f7a433ee2" 13 | match: 14 | tcp.payload: 15 | startswith: 16 | - "Cookie: mstshash=" 17 | offset: 11 18 | tags: 19 | proto: "rdp" 20 | action: "login" 21 | data: "username" 22 | techno: "remote_desktop" 23 | -------------------------------------------------------------------------------- /internal/logdata/udp.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import "encoding/json" 4 | 5 | // UDPLogData is the struct describing the logged data for UDP packets 6 | type UDPLogData struct { 7 | Payload Payload `json:"payload"` 8 | Length uint16 `json:"length"` 9 | Checksum uint16 `json:"checksum"` 10 | } 11 | 12 | // UDPEventLog is the event log struct for UDP packets 13 | type UDPEventLog struct { 14 | UDP UDPLogData `json:"udp"` 15 | IP IPLogData `json:"ip"` 16 | BaseLogData 17 | } 18 | 19 | func (eventLog UDPEventLog) String() (string, error) { 20 | data, err := json.Marshal(eventLog) 21 | if err != nil { 22 | return "", err 23 | } 24 | return string(data), nil 25 | } 26 | -------------------------------------------------------------------------------- /rules/rules-available/microsoft.yml: -------------------------------------------------------------------------------- 1 | CVE-2020-0618 (Microsoft SSRS - SQL Server Reporting Services): 2 | layer: http 3 | meta: 4 | id: 1663bfba-5033-4886-88ac-bcd45629200c 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/11/07 10 | description: "Scan for Microsoft SSRS endpoint, potentially trying to exploit it" 11 | references: 12 | - "https://nvd.nist.gov/vuln/detail/CVE-2020-0618" 13 | match: 14 | http.uri: 15 | startswith|nocase: 16 | - "/ReportServer" 17 | tags: 18 | cve: "cve-2020-0618" 19 | product: "ssrs" 20 | vendor: "microsoft" 21 | impact: "rce" 22 | -------------------------------------------------------------------------------- /internal/logdata/payload.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import "encoding/base64" 4 | 5 | // Payload is the struct describing the logged data packets' payload when supported 6 | type Payload struct { 7 | Content string `json:"content"` 8 | Base64 string `json:"base64"` 9 | Truncated bool `json:"truncated"` 10 | } 11 | 12 | // NewPayloadLogData is used to create a new Payload struct 13 | func NewPayloadLogData(data []byte, maxLength uint64) Payload { 14 | var pl = Payload{} 15 | 16 | if uint64(len(data)) > maxLength { 17 | data = data[:maxLength] 18 | pl.Truncated = true 19 | } 20 | pl.Content = string(data) 21 | pl.Base64 = base64.StdEncoding.EncodeToString(data) 22 | return pl 23 | } 24 | -------------------------------------------------------------------------------- /rules/rules-available/cms.yml: -------------------------------------------------------------------------------- 1 | CVE-2020-7961 Liferay Portal RCE: 2 | layer: http 3 | meta: 4 | id: 30adb6ba-a23e-48ba-bb12-8e91b54817c4 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/11/07 10 | description: "Checking or trying to exploit CVE-2020-7961" 11 | references: 12 | - "https://nvd.nist.gov/vuln/detail/CVE-2020-7961" 13 | match: 14 | http.uri: 15 | startswith: 16 | - "/api/jsonws/" 17 | http.method: 18 | is: 19 | - "POST" 20 | tags: 21 | cve: "cve-2020-7961" 22 | vendor: "liferay" 23 | product: "liferay_portal" 24 | impact: "rce" 25 | techno: "cms" 26 | -------------------------------------------------------------------------------- /docs/docs/features.md: -------------------------------------------------------------------------------- 1 | Here are some key features of Melody : 2 | 3 | + Transparent capture 4 | + Write detection rules and tag specific packets to analyze them at scale 5 | + Mock vulnerable websites using the builtin HTTP/S server 6 | + Supports the main internet protocols over IPv4 and IPv6 7 | + Handles log rotation for you : Melody is designed to run forever on the smallest VPS 8 | + Minimal configuration required 9 | + Standalone mode : configure Melody using only the CLI 10 | + Easily scalable : 11 | + Statically compiled binary 12 | + Up-to-date Docker image 13 | 14 | Additional features on the roadmap include : 15 | 16 | + Dedicated helper program to create, test and manage rules 17 | + Centralized rules management 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 14 | - name: Install build dependencies 15 | run: sudo apt update && sudo apt install libpcap-dev gcc-multilib 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.16 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v2 22 | with: 23 | version: latest 24 | args: release --rm-dist 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /internal/rules/test_resources/port_rules.yml: -------------------------------------------------------------------------------- 1 | ok_dst_ports: 2 | layer: tcp 3 | id: 352e81f5-beaa-49c5-8306-87f7ba4092bd 4 | whitelist: 5 | ports: 6 | - 8080 7 | match: 8 | tcp.ack: 1335620269 9 | 10 | nok_dst_ports: 11 | layer: tcp 12 | id: dca1a1ac-3c44-40ae-a1c0-0b8881e9f23e 13 | whitelist: 14 | ports: 15 | - 0 16 | match: 17 | tcp.ack: 1335620269 18 | 19 | ok_dst_ports_range: 20 | layer: tcp 21 | id: 352e81f5-beaa-49c5-8306-87f7ba4092bd 22 | whitelist: 23 | ports: 24 | - 8000 - 9000 25 | match: 26 | tcp.ack: 1335620269 27 | 28 | nok_dst_ports_range: 29 | layer: tcp 30 | id: dca1a1ac-3c44-40ae-a1c0-0b8881e9f23e 31 | whitelist: 32 | ports: 33 | - 0 - 10 34 | match: 35 | tcp.ack: 1335620269 36 | -------------------------------------------------------------------------------- /internal/rules/test_resources/ip_rules.yml: -------------------------------------------------------------------------------- 1 | ok_src_ips: 2 | layer: tcp 3 | id: 352e81f5-beaa-49c5-8306-87f7ba4092bd 4 | whitelist: 5 | ips: 6 | - 127.0.0.1 7 | match: 8 | tcp.ack: 1335620269 9 | 10 | nok_src_ips: 11 | layer: tcp 12 | id: dca1a1ac-3c44-40ae-a1c0-0b8881e9f23e 13 | whitelist: 14 | ips: 15 | - 192.0.2.1 16 | match: 17 | tcp.ack: 1335620269 18 | 19 | ok_src_ips_range: 20 | layer: tcp 21 | id: 352e81f5-beaa-49c5-8306-87f7ba4092bd 22 | whitelist: 23 | ips: 24 | - 127.0.0.0/24 25 | match: 26 | tcp.ack: 1335620269 27 | 28 | nok_src_ips_range: 29 | layer: tcp 30 | id: dca1a1ac-3c44-40ae-a1c0-0b8881e9f23e 31 | whitelist: 32 | ips: 33 | - 192.0.2.0/24 34 | match: 35 | tcp.ack: 1335620269 36 | -------------------------------------------------------------------------------- /internal/events/event.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/google/gopacket/layers" 5 | "github.com/ma111e/melody/internal/loggable" 6 | ) 7 | 8 | // Event is the interface implementing common methods to generated events 9 | type Event interface { 10 | //Match(rule rules.Rule) bool 11 | ToLog() EventLog 12 | GetIPHeader() *layers.IPv4 13 | GetICMPv6Header() *layers.ICMPv6 14 | GetICMPv4Header() *layers.ICMPv4 15 | GetUDPHeader() *layers.UDP 16 | GetTCPHeader() *layers.TCP 17 | GetHTTPData() HTTPEvent 18 | 19 | AddTags(tags map[string]string) 20 | AddAdditional(add map[string]string) 21 | loggable.Loggable 22 | } 23 | 24 | // EventLog is the interface implementing common methods to generated events' log data 25 | type EventLog interface { 26 | String() (string, error) 27 | } 28 | -------------------------------------------------------------------------------- /internal/logdata/tcp.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // TCPLogData is the struct describing the logged data for TCP packets 8 | type TCPLogData struct { 9 | Window uint16 `json:"window"` 10 | Seq uint32 `json:"seq"` 11 | Ack uint32 `json:"ack"` 12 | DataOffset uint8 `json:"data_offset"` 13 | Flags string `json:"flags"` 14 | Urgent uint16 `json:"urgent"` 15 | Payload Payload `json:"payload"` 16 | } 17 | 18 | // TCPEventLog is the event log struct for TCP packets 19 | type TCPEventLog struct { 20 | TCP TCPLogData `json:"tcp"` 21 | IP IPLogData `json:"ip"` 22 | BaseLogData 23 | } 24 | 25 | func (eventLog TCPEventLog) String() (string, error) { 26 | data, err := json.Marshal(eventLog) 27 | if err != nil { 28 | return "", err 29 | } 30 | return string(data), nil 31 | } 32 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | - go mod download 5 | builds: 6 | - id: melody 7 | main: ./cmd/melody 8 | binary: melody 9 | env: 10 | - CGO_ENABLED=1 11 | goos: 12 | - linux 13 | - windows 14 | goarch: 15 | - amd64 16 | - id: meloctl 17 | main: ./cmd/meloctl 18 | binary: meloctl 19 | env: 20 | - CGO_ENABLED=1 21 | goos: 22 | - linux 23 | - windows 24 | goarch: 25 | - amd64 26 | archives: 27 | - 28 | files: 29 | - rules/* 30 | - etc/* 31 | - var/* 32 | - filter.bpf 33 | - config.yml 34 | - docker-compose.yml 35 | - Dockerfile 36 | - Makefile 37 | wrap_in_directory: true 38 | checksum: 39 | name_template: 'checksums.txt' 40 | snapshot: 41 | version_template: "{{ .Tag }}-next" 42 | changelog: 43 | sort: asc 44 | filters: 45 | exclude: 46 | - '^docs:' 47 | - '^test:' 48 | -------------------------------------------------------------------------------- /internal/clihelper/multistring.go: -------------------------------------------------------------------------------- 1 | package clihelper 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pborman/getopt/v2" 7 | ) 8 | 9 | // MultiString is a getopt Option to allows passing multiple of the same option and get an array of values 10 | // Example : -o "a" -o "b" -o "c" -> [a, b, c] 11 | type MultiString []string 12 | 13 | // Set is the method called when the parser encounters a matching switch 14 | func (h *MultiString) Set(str string, opt getopt.Option) error { 15 | *h = append(*h, str) 16 | _ = opt 17 | return nil 18 | } 19 | 20 | // String is an helper to get the string representation of the option 21 | func (h *MultiString) String() string { 22 | return strings.Join(h.Array(), ", ") 23 | } 24 | 25 | // Array returns the values as an array 26 | func (h *MultiString) Array() []string { 27 | return *h 28 | } 29 | 30 | // ParseMultipleOptions returns the values parsed as an array 31 | func (h *MultiString) ParseMultipleOptions() []string { 32 | return h.Array() 33 | } 34 | -------------------------------------------------------------------------------- /internal/logdata/icmpv6.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/google/gopacket/layers" 7 | ) 8 | 9 | // ICMPv6LogData is the struct describing the logged data for ICMPv6 packets 10 | type ICMPv6LogData struct { 11 | TypeCode layers.ICMPv6TypeCode `json:"type_code"` 12 | Type uint8 `json:"type"` 13 | Code uint8 `json:"code"` 14 | TypeCodeName string `json:"type_code_name"` 15 | Checksum uint16 `json:"checksum"` 16 | Payload Payload `json:"payload"` 17 | } 18 | 19 | // ICMPv6EventLog is the event log struct for ICMPv6 packets 20 | type ICMPv6EventLog struct { 21 | ICMPv6 ICMPv6LogData `json:"icmpv6"` 22 | IP IPv6LogData `json:"ip"` 23 | BaseLogData 24 | } 25 | 26 | func (eventLog ICMPv6EventLog) String() (string, error) { 27 | data, err := json.Marshal(eventLog) 28 | if err != nil { 29 | return "", err 30 | } 31 | return string(data), nil 32 | } 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ma111e/melody 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2 7 | github.com/fatih/structtag v1.2.0 8 | github.com/google/gopacket v1.1.19 9 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 10 | github.com/google/uuid v1.2.0 11 | github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect 12 | github.com/k0kubun/pp v3.0.1+incompatible 13 | github.com/kr/text v0.2.0 // indirect 14 | github.com/manifoldco/promptui v0.8.0 15 | github.com/natefinch/lumberjack v2.0.0+incompatible 16 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 17 | github.com/pborman/getopt/v2 v2.1.0 18 | github.com/rs/xid v1.2.1 19 | github.com/spf13/cobra v1.1.3 20 | golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect 21 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 // indirect 22 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 23 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect 24 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 25 | ) 26 | -------------------------------------------------------------------------------- /internal/logdata/icmpv4.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/google/gopacket/layers" 7 | ) 8 | 9 | // ICMPv4LogData is the struct describing the logged data for ICMPv4 packets 10 | type ICMPv4LogData struct { 11 | TypeCode layers.ICMPv4TypeCode `json:"type_code"` 12 | Type uint8 `json:"type"` 13 | Code uint8 `json:"code"` 14 | TypeCodeName string `json:"type_code_name"` 15 | Checksum uint16 `json:"checksum"` 16 | ID uint16 `json:"id"` 17 | Seq uint16 `json:"seq"` 18 | Payload Payload `json:"payload"` 19 | } 20 | 21 | // ICMPv4EventLog is the event log struct for ICMPv4 packets 22 | type ICMPv4EventLog struct { 23 | ICMPv4 ICMPv4LogData `json:"icmpv4"` 24 | IP IPv4LogData `json:"ip"` 25 | BaseLogData 26 | } 27 | 28 | func (eventLog ICMPv4EventLog) String() (string, error) { 29 | data, err := json.Marshal(eventLog) 30 | if err != nil { 31 | return "", err 32 | } 33 | return string(data), nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/rules/test_resources/icmpv4_rules.yml: -------------------------------------------------------------------------------- 1 | ok_icmpv4_typecode: 2 | layer: icmpv4 3 | id: 5ab4a5de-0eff-4045-a50d-e3d44159013b 4 | match: 5 | icmpv4.typecode: 2048 6 | 7 | nok_icmpv4_typecode: 8 | layer: icmpv4 9 | id: 817407fc-5dec-42ca-8e42-1198cd51afdc 10 | match: 11 | icmpv4.typecode: 0 12 | 13 | ok_checksum: 14 | layer: icmpv4 15 | id: b845d07e-c447-4b4c-aad7-1be33bc4fa4a 16 | match: 17 | icmpv4.checksum: 0x0416 18 | 19 | nok_checksum: 20 | layer: icmpv4 21 | id: ca48fc15-c273-43f8-8487-04507f4f4813 22 | match: 23 | icmpv4.checksum: 0 24 | 25 | ok_icmpv4_code: 26 | layer: icmpv4 27 | id: b845d07e-c447-4b4c-aad7-1be33bc4fa4a 28 | match: 29 | icmpv4.code: 0 30 | 31 | nok_icmpv4_code: 32 | layer: icmpv4 33 | id: ca48fc15-c273-43f8-8487-04507f4f4813 34 | match: 35 | icmpv4.code: 1 36 | 37 | ok_icmpv4_type: 38 | layer: icmpv4 39 | id: b845d07e-c447-4b4c-aad7-1be33bc4fa4a 40 | match: 41 | icmpv4.type: 0x8 42 | 43 | nok_icmpv4_type: 44 | layer: icmpv4 45 | id: ca48fc15-c273-43f8-8487-04507f4f4813 46 | match: 47 | icmpv4.type: 0 48 | -------------------------------------------------------------------------------- /internal/rules/test_resources/icmpv6_rules.yml: -------------------------------------------------------------------------------- 1 | ok_icmpv6_typecode: 2 | layer: icmpv6 3 | id: 5ab6a5de-0eff-4065-a50d-e3d44159013b 4 | match: 5 | icmpv6.typecode: 32768 6 | 7 | nok_icmpv6_typecode: 8 | layer: icmpv6 9 | id: 817407fc-5dec-42ca-8e62-1198cd51afdc 10 | match: 11 | icmpv6.typecode: 0 12 | 13 | ok_checksum: 14 | layer: icmpv6 15 | id: b845d07e-c447-4b4c-aad7-1be33bc6fa6a 16 | match: 17 | icmpv6.checksum: 0x275b 18 | 19 | nok_checksum: 20 | layer: icmpv6 21 | id: ca68fc15-c273-43f8-8687-04507f4f4813 22 | match: 23 | icmpv6.checksum: 0 24 | 25 | ok_icmpv6_code: 26 | layer: icmpv6 27 | id: b845d07e-c447-4b4c-aad7-1be33bc6fa6a 28 | match: 29 | icmpv6.code: 0 30 | 31 | nok_icmpv6_code: 32 | layer: icmpv6 33 | id: ca68fc15-c273-43f8-8687-04507f4f4813 34 | match: 35 | icmpv6.code: 1 36 | 37 | ok_icmpv6_type: 38 | layer: icmpv6 39 | id: b845d07e-c447-4b4c-aad7-1be33bc6fa6a 40 | match: 41 | icmpv6.type: 0x80 42 | 43 | nok_icmpv6_type: 44 | layer: icmpv6 45 | id: ca68fc15-c273-43f8-8687-04507f4f4813 46 | match: 47 | icmpv6.type: 0 48 | -------------------------------------------------------------------------------- /internal/config/tagparser.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/ma111e/melody/internal/tagparser" 7 | ) 8 | 9 | // LoadYAMLTagsOf loads the yaml tags of a struct 10 | func LoadYAMLTagsOf(what interface{}) ([]string, error) { 11 | var tags []string 12 | 13 | for i := 0; i < reflect.TypeOf(what).NumField(); i++ { 14 | ruleTag := reflect.TypeOf(what).Field(i).Tag 15 | 16 | _, exists := ruleTag.Lookup("yaml") 17 | if !exists { 18 | continue 19 | } 20 | 21 | tagValue, err := tagparser.ParseYamlTagValue(ruleTag) 22 | if err != nil { 23 | return tags, err 24 | } 25 | 26 | tags = append(tags, tagValue) 27 | } 28 | 29 | return tags, nil 30 | } 31 | 32 | // LoadValidConfigKeysMap returns a map of the json keys present in the Config struct 33 | func LoadValidConfigKeysMap() map[string]interface{} { 34 | configKeysMap := make(map[string]interface{}) 35 | tags, err := LoadYAMLTagsOf(Config{}) 36 | if err != nil { 37 | panic(err) 38 | } 39 | 40 | for _, tag := range tags { 41 | configKeysMap[tag] = new(interface{}) 42 | } 43 | 44 | return configKeysMap 45 | } 46 | -------------------------------------------------------------------------------- /internal/rules/test_resources/udp_rules.yml: -------------------------------------------------------------------------------- 1 | ok_checksum: 2 | layer: udp 3 | id: 352e81f5-beaa-49c5-8306-87f7ba4092bd 4 | match: 5 | udp.checksum: 0xfe37 6 | 7 | nok_checksum: 8 | layer: udp 9 | id: dca1a1ac-3c44-40ae-a1c0-0b8881e9f23e 10 | match: 11 | udp.checksum: 0 12 | 13 | ok_dsize: 14 | layer: udp 15 | id: dc65fcd2-90bc-4b00-894b-7fe936be0298 16 | match: 17 | udp.dsize: 28 18 | 19 | nok_dsize: 20 | layer: udp 21 | id: 67c669bf-585d-4935-8c0c-4d3d18d996fc 22 | match: 23 | udp.dsize: 0 24 | 25 | ok_length: 26 | layer: udp 27 | id: dc65fcd2-90bc-4b00-894b-7fe936be0298 28 | match: 29 | udp.length: 36 30 | 31 | nok_length: 32 | layer: udp 33 | id: 67c669bf-585d-4935-8c0c-4d3d18d996fc 34 | match: 35 | udp.length: 0 36 | 37 | ok_payload: 38 | layer: udp 39 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 40 | match: 41 | udp.payload: 42 | is: 43 | - "after all, we're all alike.\n" 44 | 45 | nok_payload: 46 | layer: udp 47 | id: ae6de878-80fc-4052-bc64-a92beb425248 48 | match: 49 | udp.payload: 50 | is: 51 | - "nonexistent" 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ma111e 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | Welcome to the ~**Melodoc**~. 2 | 3 | Melody is an internet sensor built for threat intelligence. This tool have multiple use cases : 4 | 5 | + Build historic data to extract trends and patterns 6 | + Keep an eye on specific threats 7 | + Monitor emerging threats exploitation 8 | + Index malicious activity by detecting exploitation attempts and targeted scanners 9 | + Log every contact your application receives from the internet to find potentially malicious activity 10 | 11 | Deploying it can be as easy as pulling the latest compiled binary or the official Docker image. 12 | 13 | Add your favorite rules, some configuration tweaks, a BPF to clean the noise a bit and then forget it[^1] and let the internet symphony flow to you. 14 | 15 | You can tweak the options either with a file or directly by passing options trough the CLI, allowing Melody to act as a standalone application. 16 | 17 | Melody will also handle log rotation for you. It has been designed to be able to run forever on the smallest VPS while handling millions of packets a day. 18 | 19 | [^1]: You should either setup an automated patching process or come back often to apply security patches on the host though 20 | -------------------------------------------------------------------------------- /internal/logdata/http.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import "encoding/json" 4 | 5 | // HTTPLogData is the struct describing the logged data for reassembled HTTP packets 6 | type HTTPLogData struct { 7 | Verb string `json:"verb"` 8 | Proto string `json:"proto"` 9 | RequestURI string `json:"uri"` 10 | SourcePort uint16 `json:"src_port"` 11 | DestHost string `json:"dst_host"` 12 | UserAgent string `json:"user_agent"` 13 | Headers map[string]string `json:"headers"` 14 | HeadersKeys []string `json:"headers_keys"` 15 | HeadersValues []string `json:"headers_values"` 16 | Errors []string `json:"errors"` 17 | Body Payload `json:"body"` 18 | IsTLS bool `json:"is_tls"` 19 | } 20 | 21 | // HTTPEventLog is the event log struct for reassembled HTTP packets 22 | type HTTPEventLog struct { 23 | HTTP HTTPLogData `json:"http"` 24 | IP IPLogData `json:"ip"` 25 | BaseLogData 26 | } 27 | 28 | func (eventLog HTTPEventLog) String() (string, error) { 29 | data, err := json.Marshal(eventLog) 30 | if err != nil { 31 | return "", err 32 | } 33 | return string(data), nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/logdata/ipv6.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import ( 4 | "github.com/google/gopacket/layers" 5 | "github.com/ma111e/melody/internal/events/helpers" 6 | ) 7 | 8 | // IPv6LogData is the struct describing the logged data for IPv6 header 9 | type IPv6LogData struct { 10 | Version uint8 `json:"version"` 11 | Length uint16 `json:"length"` 12 | NextHeader layers.IPProtocol `json:"next_header"` 13 | NextHeaderName string `json:"next_header_name"` 14 | TrafficClass uint8 `json:"traffic_class"` 15 | FlowLabel uint32 `json:"flow_label"` 16 | HopLimit uint8 `json:"hop_limit"` 17 | IPLogData `json:"-"` 18 | } 19 | 20 | // NewIPv6LogData is used to create a new IPv6LogData struct 21 | func NewIPv6LogData(ipv6Layer helpers.IPv6Layer) IPv6LogData { 22 | return IPv6LogData{ 23 | Version: ipv6Layer.Header.Version, 24 | Length: ipv6Layer.Header.Length, 25 | NextHeader: ipv6Layer.Header.NextHeader, 26 | NextHeaderName: ipv6Layer.Header.NextHeader.String(), 27 | TrafficClass: ipv6Layer.Header.TrafficClass, 28 | FlowLabel: ipv6Layer.Header.FlowLabel, 29 | HopLimit: ipv6Layer.Header.HopLimit, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/docs/requirements.md: -------------------------------------------------------------------------------- 1 | ## Go version 2 | Minimum supported Go version is 1.11, but we recommend using Go 1.13+ since that's the lowest version we're using for testing. 3 | 4 | ## libpcap 5 | 6 | You'll need the `libpcap` C headers to build the program. 7 | 8 | Install it with : 9 | 10 | ``` 11 | sudo apt update 12 | sudo apt install libpcap-dev 13 | ``` 14 | 15 | !!! Note 16 | You won't need them if you're using Docker or a pre-compiled release binary. 17 | 18 | ## HTTPS dummy server 19 | You'll need TLS certificates in order to use the built-in dummy HTTPS server. 20 | 21 | Use one of these commands to generate them for you : 22 | 23 | ``` 24 | make certs 25 | ``` 26 | 27 | or 28 | 29 | ``` 30 | mkdir -p var/https/certs 31 | openssl req -x509 -subj "/C=AU/ST=Some-State/O=Internet Widgits Pty Ltd/CN=localhost" -newkey rsa:4096 -keyout var/https/certs/key.pem -out var/https/certs/cert.pem -days 3650 -nodes 32 | ``` 33 | 34 | !!! Warning 35 | Using these commands will overwrite any `cert.pem` or `key.pem` file already present in `$melody/var/https/certs/` 36 | 37 | !!! Tip 38 | You can also use your own by putting the `key.pem` and `cert.pem` in `$melody/var/https/certs`. **Keep in mind that it might be used by attackers to fingerprint or gain information on your infrastructure.** 39 | -------------------------------------------------------------------------------- /internal/router/fs.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | // https://www.alexedwards.net/blog/disable-http-fileserver-directory-listings#using-a-custom-filesystem 12 | type neuteredFileSystem struct { 13 | fs http.FileSystem 14 | } 15 | 16 | func melodyFs(root http.FileSystem, notFoundCode int) http.Handler { 17 | fs := http.FileServer( 18 | neuteredFileSystem{ 19 | root, 20 | }) 21 | 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | //make sure the url path starts with / 24 | upath := r.URL.Path 25 | if !strings.HasPrefix(upath, "/") { 26 | upath = "/" + upath 27 | r.URL.Path = upath 28 | } 29 | upath = path.Clean(upath) 30 | 31 | // attempt to open the file via the http.FileSystem 32 | f, err := root.Open(upath) 33 | if err != nil { 34 | if os.IsNotExist(err) { 35 | w.WriteHeader(notFoundCode) 36 | _, _ = w.Write([]byte{}) 37 | return 38 | } 39 | } 40 | 41 | s, _ := f.Stat() 42 | if s.IsDir() { 43 | index := filepath.Join(upath, "index.html") 44 | if _, err := root.Open(index); err != nil { 45 | _ = f.Close() 46 | if os.IsNotExist(err) { 47 | w.WriteHeader(notFoundCode) 48 | _, _ = w.Write([]byte{}) 49 | return 50 | } 51 | } 52 | } 53 | 54 | fs.ServeHTTP(w, r) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /internal/sessions/sessions.go: -------------------------------------------------------------------------------- 1 | package sessions 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/xid" 7 | ) 8 | 9 | // Session abstracts a session made of a last seen record and an uid 10 | type Session struct { 11 | lastSeen time.Time 12 | uid string 13 | } 14 | 15 | // sessionMap abstracts a hash table of multiple Session sorted by their flow data 16 | type sessionMap map[string]*Session 17 | 18 | var ( 19 | // SessionMap is the global sessions hash table 20 | SessionMap = make(sessionMap) 21 | ) 22 | 23 | func (m sessionMap) GetUID(flow string) string { 24 | if session, ok := m[flow]; ok { 25 | return session.uid 26 | } 27 | 28 | return m.add(flow) 29 | } 30 | 31 | func (m *sessionMap) add(flow string) string { 32 | //var ts = strconv.FormatInt(time.Now().UnixNano(), 10) 33 | var ts = xid.New().String() 34 | 35 | (*m)[flow] = &Session{ 36 | uid: ts, 37 | lastSeen: time.Now(), 38 | } 39 | return ts 40 | } 41 | 42 | // FlushOlderThan cleans the session mapping of sessions not seen since the given deadline 43 | func (m *sessionMap) FlushOlderThan(deadline time.Time) { 44 | for flow, session := range *m { 45 | if session.lastSeen.Before(deadline) { 46 | delete(*m, flow) 47 | } 48 | } 49 | } 50 | 51 | // FlushAll removes all sessions from the session mapping 52 | func (m *sessionMap) FlushAll() { 53 | for flow := range *m { 54 | delete(*m, flow) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ma111e/melody/internal/events" 9 | 10 | "github.com/ma111e/melody/internal/logging" 11 | 12 | "github.com/ma111e/melody/internal/config" 13 | ) 14 | 15 | // StartHTTP starts the dummy HTTP server 16 | func StartHTTP(quitErrChan chan error) { 17 | r := http.NewServeMux() 18 | r.Handle("/", 19 | headersHandler( 20 | melodyFs(http.Dir(config.Cfg.ServerHTTPDir), config.Cfg.ServerHTTPMissingResponseStatus), 21 | config.Cfg.ServerHTTPHeaders)) 22 | 23 | logging.Std.Println("Started HTTP server on port", config.Cfg.ServerHTTPPort) 24 | quitErrChan <- http.ListenAndServe(fmt.Sprintf(":%d", config.Cfg.ServerHTTPPort), r) 25 | } 26 | 27 | // StartHTTPS starts the dummy HTTPS server 28 | func StartHTTPS(quitErrChan chan error, eventChan chan events.Event) { 29 | r := http.NewServeMux() 30 | r.Handle("/", 31 | httpsLogger( 32 | headersHandler( 33 | melodyFs(http.Dir(config.Cfg.ServerHTTPSDir), config.Cfg.ServerHTTPSMissingResponseStatus), 34 | config.Cfg.ServerHTTPSHeaders), eventChan)) 35 | 36 | srv := &http.Server{ 37 | Addr: fmt.Sprintf(":%d", config.Cfg.ServerHTTPSPort), 38 | Handler: r, 39 | TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), 40 | } 41 | 42 | logging.Std.Println("Started HTTPS server on port", config.Cfg.ServerHTTPSPort) 43 | quitErrChan <- srv.ListenAndServeTLS(config.Cfg.ServerHTTPSCert, config.Cfg.ServerHTTPSKey) 44 | } 45 | -------------------------------------------------------------------------------- /internal/assembler/stream.go: -------------------------------------------------------------------------------- 1 | package assembler 2 | 3 | import ( 4 | "bufio" 5 | "github.com/google/gopacket" 6 | "github.com/google/gopacket/tcpassembly" 7 | "github.com/google/gopacket/tcpassembly/tcpreader" 8 | "github.com/ma111e/melody/internal/engine" 9 | "github.com/ma111e/melody/internal/events" 10 | "io" 11 | "net/http" 12 | ) 13 | 14 | // HTTPStreamFactory implements tcpassembly.StreamFactory 15 | type HTTPStreamFactory struct{} 16 | 17 | // HTTPStream will handle the actual decoding of http requests. 18 | type HTTPStream struct { 19 | net, transport gopacket.Flow 20 | r tcpreader.ReaderStream 21 | } 22 | 23 | // New creates a new HTTPStreamFactory from the given flow data 24 | func (h *HTTPStreamFactory) New(net, transport gopacket.Flow) tcpassembly.Stream { 25 | hstream := &HTTPStream{ 26 | net: net, 27 | transport: transport, 28 | r: tcpreader.NewReaderStream(), 29 | } 30 | go hstream.run() // Important... we must guarantee that data from the reader stream is read. 31 | 32 | // ReaderStream implements tcpassembly.Stream, so we can return a pointer to it. 33 | return &hstream.r 34 | } 35 | 36 | func (h *HTTPStream) run() { 37 | buf := bufio.NewReader(&h.r) 38 | for { 39 | req, err := http.ReadRequest(buf) 40 | if err == io.EOF { 41 | // We must read until we see an EOF... very important! 42 | return 43 | } else if err != nil { 44 | 45 | } else { 46 | ev, _ := events.NewHTTPEvent(req, h.net, h.transport) 47 | engine.EventChan <- ev 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cmd/meloctl/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "gopkg.in/yaml.v3" 8 | 9 | "github.com/ma111e/melody/internal/config" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ( 15 | configCmd = &cobra.Command{ 16 | Use: "config", 17 | Short: "Interact with a Melody config file", 18 | Long: `This subcommand is used to interact with a Melody config file`, 19 | } 20 | checkConfigCmd = &cobra.Command{ 21 | Use: "check", 22 | Args: cobra.ExactArgs(1), 23 | Short: "Validate a Melody config file", 24 | Long: `This subcommand is used to validate a Melody config file`, 25 | Run: checkConfig, 26 | } 27 | 28 | validConfigKeysMap map[string]interface{} = config.LoadValidConfigKeysMap() 29 | ) 30 | 31 | func init() { 32 | RootCmd.AddCommand(configCmd) 33 | configCmd.AddCommand(checkConfigCmd) 34 | } 35 | 36 | func checkConfig(_ *cobra.Command, args []string) { 37 | var err error 38 | configPath := args[0] 39 | 40 | cfg := make(map[string]interface{}) 41 | 42 | cfgData, err := ioutil.ReadFile(configPath) 43 | if err != nil { 44 | fmt.Printf("❌ [%s]: %s\n", configPath, err) 45 | return 46 | } 47 | 48 | if err := yaml.Unmarshal(cfgData, &cfg); err != nil { 49 | fmt.Printf("❌ [%s]: %s\n", configPath, err) 50 | return 51 | } 52 | 53 | for key := range cfg { 54 | if _, ok := validConfigKeysMap[key]; !ok { 55 | fmt.Printf("❌ [%s]: unknown property '%s'\n", configPath, key) 56 | return 57 | } 58 | } 59 | 60 | fmt.Printf("✅ [%s]: OK\n", configPath) 61 | } 62 | -------------------------------------------------------------------------------- /internal/router/middlewares.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "path/filepath" 6 | 7 | "github.com/ma111e/melody/internal/config" 8 | 9 | "github.com/ma111e/melody/internal/logging" 10 | 11 | "github.com/ma111e/melody/internal/events" 12 | ) 13 | 14 | func (nfs neuteredFileSystem) Open(path string) (http.File, error) { 15 | f, err := nfs.fs.Open(path) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | s, _ := f.Stat() 21 | if s.IsDir() { 22 | index := filepath.Join(path, "index.html") 23 | if _, err := nfs.fs.Open(index); err != nil { 24 | closeErr := f.Close() 25 | if closeErr != nil { 26 | return nil, closeErr 27 | } 28 | 29 | return nil, err 30 | } 31 | } 32 | 33 | return f, nil 34 | } 35 | 36 | func headersHandler(h http.Handler, headers map[string]string) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | for header, val := range headers { 39 | w.Header().Set(header, val) 40 | } 41 | h.ServeHTTP(w, r) // pass request 42 | }) 43 | } 44 | 45 | func httpsLogger(h http.Handler, eventChan chan events.Event) http.Handler { 46 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | if _, ok := config.Cfg.DiscardProto4[config.HTTPSKind]; ok { 48 | h.ServeHTTP(w, r) // pass request 49 | return 50 | } else if _, ok := config.Cfg.DiscardProto6[config.HTTPSKind]; ok { 51 | h.ServeHTTP(w, r) // pass request 52 | return 53 | } 54 | 55 | ev, err := events.NewHTTPEventFromRequest(r) 56 | if err != nil { 57 | logging.Errors.Println(err) 58 | return 59 | } 60 | eventChan <- ev 61 | 62 | h.ServeHTTP(w, r) // pass request 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | pull_request: 7 | branches: 8 | - "**" 9 | jobs: 10 | tests: 11 | name: Unit and integrations tests 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | go: ["1.13", "1.14", "1.15", "1.16"] 17 | steps: 18 | - name: Install libpcap dependencies 19 | run: sudo apt update && sudo apt install libpcap-dev 20 | - uses: actions/setup-go@v2 21 | with: 22 | go-version: ${{ matrix.go }} 23 | - uses: actions/checkout@v2 24 | - name: Run tests 25 | run: | 26 | go test -v -p 1 -race -coverprofile=covprofile -covermode=atomic ./... 27 | - if: ${{ matrix.go == 1.15 }} 28 | name: Update coverage 29 | uses: shogo82148/actions-goveralls@v1.4.2 30 | with: 31 | path-to-profile: covprofile 32 | 33 | lint: 34 | name: Lint 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Install libpcap dependencies 38 | run: sudo apt update && sudo apt install libpcap-dev 39 | - uses: actions/checkout@v2 40 | - name: Run lint 41 | uses: golangci/golangci-lint-action@v2 42 | with: 43 | version: v1.32 44 | args: --timeout 5m 45 | 46 | spellcheck: 47 | name: Spellcheck docs 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v2 51 | - uses: actions/setup-go@v2 52 | with: 53 | go-version: 1.15 54 | - name: Run spellcheck 55 | run: | 56 | go get github.com/client9/misspell/cmd/misspell 57 | misspell -error docs/docs 58 | -------------------------------------------------------------------------------- /internal/logdata/base.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ma111e/melody/internal/loggable" 7 | ) 8 | 9 | // IPLogData is the interface used by packet structs supporting an IP layer 10 | type IPLogData interface{} 11 | 12 | // BaseLogData is used as the base packet log and contains common data, such as the timestamp 13 | type BaseLogData struct { 14 | Timestamp string `json:"timestamp"` 15 | Session string `json:"session"` 16 | Type string `json:"type"` 17 | SourceIP string `json:"src_ip"` 18 | DestPort uint16 `json:"dst_port"` 19 | Tags map[string][]string `json:"matches"` 20 | InlineTags []string `json:"inline_matches"` 21 | Additional map[string]string `json:"embedded"` 22 | } 23 | 24 | // Init takes the common BaseEvent attributes to setup the BaseLogData struct 25 | func (l *BaseLogData) Init(ev loggable.Loggable) { 26 | l.Type = ev.GetKind() 27 | l.SourceIP = ev.GetSourceIP() 28 | l.DestPort = ev.GetDestPort() 29 | l.Session = ev.GetSession() 30 | l.InlineTags = []string{} 31 | 32 | if len(ev.GetTags()) == 0 { 33 | l.Tags = make(map[string][]string) 34 | } else { 35 | l.Tags = ev.GetTags() 36 | l.InlineTags = makeInlineArray(ev.GetTags()) 37 | } 38 | } 39 | 40 | // makeInlineArray converts a Tags map to an array of its values with the keys and values merged with a '.' 41 | func makeInlineArray(tags map[string][]string) []string { 42 | var inlineTags []string 43 | 44 | for key, values := range tags { 45 | for _, val := range values { 46 | inlineTags = append(inlineTags, fmt.Sprintf("%s.%s", key, val)) 47 | } 48 | } 49 | 50 | return inlineTags 51 | } 52 | -------------------------------------------------------------------------------- /internal/logdata/ipv4.go: -------------------------------------------------------------------------------- 1 | package logdata 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/google/gopacket/layers" 7 | "github.com/ma111e/melody/internal/events/helpers" 8 | ) 9 | 10 | // IPv4LogData is the struct describing the logged data for IPv4 header 11 | type IPv4LogData struct { 12 | Version uint8 `json:"version"` 13 | IHL uint8 `json:"ihl"` 14 | TOS uint8 `json:"tos"` 15 | Length uint16 `json:"length"` 16 | ID uint16 `json:"id"` 17 | Fragbits string `json:"fragbits"` 18 | FragOffset uint16 `json:"frag_offset"` 19 | TTL uint8 `json:"ttl"` 20 | Protocol layers.IPProtocol `json:"protocol"` 21 | IPLogData `json:"-"` 22 | } 23 | 24 | // NewIPv4LogData is used to create a new IPv4LogData struct 25 | func NewIPv4LogData(ipv4Layer helpers.IPv4Layer) IPv4LogData { 26 | var ipFlagsStr []string 27 | 28 | if ipv4Layer.Header.Flags&layers.IPv4EvilBit != 0 { 29 | ipFlagsStr = append(ipFlagsStr, "EV") 30 | } 31 | if ipv4Layer.Header.Flags&layers.IPv4DontFragment != 0 { 32 | ipFlagsStr = append(ipFlagsStr, "DF") 33 | } 34 | if ipv4Layer.Header.Flags&layers.IPv4MoreFragments != 0 { 35 | ipFlagsStr = append(ipFlagsStr, "MF") 36 | } 37 | 38 | data := IPv4LogData{ 39 | Version: ipv4Layer.Header.Version, 40 | IHL: ipv4Layer.Header.IHL, 41 | TOS: ipv4Layer.Header.TOS, 42 | Length: ipv4Layer.Header.Length, 43 | ID: ipv4Layer.Header.Id, 44 | FragOffset: ipv4Layer.Header.FragOffset, 45 | TTL: ipv4Layer.Header.TTL, 46 | Protocol: ipv4Layer.Header.Protocol, 47 | Fragbits: strings.Join(ipFlagsStr, ""), 48 | } 49 | 50 | return data 51 | } 52 | -------------------------------------------------------------------------------- /internal/engine/engine.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "github.com/ma111e/melody/internal/config" 5 | "github.com/ma111e/melody/internal/events" 6 | "github.com/ma111e/melody/internal/logging" 7 | "github.com/ma111e/melody/internal/router" 8 | "github.com/ma111e/melody/internal/rules" 9 | ) 10 | 11 | var ( 12 | // EventChan is the channel used to receive event to qualify 13 | EventChan = make(chan events.Event) 14 | ) 15 | 16 | // Start starts the matching and tagging engine 17 | func Start(quitErrChan chan error, shutdownChan chan bool, engineStoppedChan chan bool) { 18 | go startEventQualifier(quitErrChan, shutdownChan, engineStoppedChan) 19 | 20 | if config.Cfg.ServerHTTPEnable { 21 | logging.Std.Println("Starting HTTP server") 22 | go router.StartHTTP(quitErrChan) 23 | } 24 | 25 | if config.Cfg.ServerHTTPSEnable { 26 | logging.Std.Println("Starting HTTPS server") 27 | go router.StartHTTPS(quitErrChan, EventChan) 28 | } 29 | } 30 | 31 | func startEventQualifier(quitErrChan chan error, shutdownChan chan bool, engineStoppedChan chan bool) { 32 | defer func() { 33 | close(engineStoppedChan) 34 | }() 35 | 36 | for { 37 | select { 38 | case <-shutdownChan: 39 | return 40 | 41 | case <-quitErrChan: 42 | return 43 | 44 | case ev := <-EventChan: 45 | var matches []rules.Rule 46 | 47 | for _, ruleset := range rules.GlobalRules[ev.GetKind()] { 48 | for _, rule := range ruleset { 49 | if rule.Match(ev) { 50 | matches = append(matches, rule) 51 | } 52 | } 53 | } 54 | 55 | if len(matches) > 0 { 56 | for _, match := range matches { 57 | ev.AddTags(match.Tags) 58 | ev.AddAdditional(match.Additional) 59 | } 60 | } 61 | 62 | logging.LogChan <- ev 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/filters/utils.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | // From https://github.com/apparentlymart/go-cidr/blob/master/cidr/cidr.go 4 | import ( 5 | "fmt" 6 | "math/big" 7 | "net" 8 | ) 9 | 10 | func addressRange(network *net.IPNet) (net.IP, net.IP, error) { 11 | // the first IP is easy 12 | firstIP := network.IP 13 | 14 | // the last IP is the network address OR NOT the mask address 15 | prefixLen, bits := network.Mask.Size() 16 | if prefixLen == bits { 17 | // Easy! 18 | // But make sure that our two slices are distinct, since they 19 | // would be in all other cases. 20 | lastIP := make([]byte, len(firstIP)) 21 | copy(lastIP, firstIP) 22 | return firstIP, lastIP, nil 23 | } 24 | 25 | firstIPInt, bits, err := ipToInt(firstIP) 26 | if err != nil { 27 | return net.IP{}, net.IP{}, err 28 | } 29 | 30 | hostLen := uint(bits) - uint(prefixLen) 31 | lastIPInt := big.NewInt(1) 32 | lastIPInt.Lsh(lastIPInt, hostLen) 33 | lastIPInt.Sub(lastIPInt, big.NewInt(1)) 34 | lastIPInt.Or(lastIPInt, firstIPInt) 35 | 36 | return firstIP, intToIP(lastIPInt, bits), nil 37 | } 38 | 39 | func ipToInt(ip net.IP) (*big.Int, int, error) { 40 | val := &big.Int{} 41 | val.SetBytes(ip) 42 | if len(ip) == net.IPv4len { 43 | return val, 32, nil 44 | } else if len(ip) == net.IPv6len { 45 | return val, 128, nil 46 | } 47 | 48 | return nil, 0, fmt.Errorf("unsupported address length %d", len(ip)) 49 | } 50 | 51 | func intToIP(ipInt *big.Int, bits int) net.IP { 52 | ipBytes := ipInt.Bytes() 53 | ret := make([]byte, bits/8) 54 | // Pack our IP bytes into the end of the return array, 55 | // since big.Int.Bytes() removes front zero padding. 56 | for i := 1; i <= len(ipBytes); i++ { 57 | ret[len(ret)-i] = ipBytes[len(ipBytes)-i] 58 | } 59 | return ret 60 | } 61 | -------------------------------------------------------------------------------- /cmd/meloctl/get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ma111e/melody/internal/config" 6 | "github.com/ma111e/melody/internal/tagparser" 7 | "reflect" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | getCmd = &cobra.Command{ 14 | Args: cobra.ExactArgs(1), 15 | Use: "get", 16 | Short: "Get a Meloctl config value by name", 17 | Long: `This subcommand is used to get a Meloctl config value by name`, 18 | Run: getConfigKey, 19 | } 20 | ) 21 | 22 | func init() { 23 | RootCmd.AddCommand(getCmd) 24 | } 25 | 26 | func getConfigKey(_ *cobra.Command, args []string) { 27 | var found bool 28 | key := args[0] 29 | 30 | validKeys, _ := config.LoadYAMLTagsOf(MeloctlConfig{}) 31 | for _, valid := range validKeys { 32 | if key == valid { 33 | found = true 34 | break 35 | } 36 | } 37 | 38 | if !found { 39 | fmt.Printf("❌ [%s] Unknown key\n", key) 40 | return 41 | } 42 | 43 | val, err := getValueByYAMLTagName(meloctlConf, key) 44 | if err != nil { 45 | fmt.Printf("❌ Error while fetching value : %s\n", err) 46 | return 47 | } 48 | 49 | fmt.Printf("%s => %s \n", key, val) 50 | } 51 | 52 | func getValueByYAMLTagName(what interface{}, key string) (string, error) { 53 | var val string 54 | 55 | for i := 0; i < reflect.TypeOf(what).Elem().NumField(); i++ { 56 | tag := reflect.TypeOf(what).Elem().Field(i).Tag 57 | 58 | _, exists := tag.Lookup("yaml") 59 | if !exists { 60 | continue 61 | } 62 | 63 | tagValue, err := tagparser.ParseYamlTagValue(tag) 64 | if err != nil { 65 | return val, err 66 | } 67 | 68 | if key == tagValue { 69 | val = reflect.ValueOf(what).Elem().FieldByName(reflect.TypeOf(what).Elem().Field(i).Name).String() 70 | break 71 | } 72 | } 73 | 74 | return val, nil 75 | } 76 | -------------------------------------------------------------------------------- /cmd/meloctl/init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ma111e/melody/internal/fileutils" 6 | "github.com/ma111e/melody/internal/meloctl/prompt" 7 | "io/ioutil" 8 | "os" 9 | 10 | "gopkg.in/yaml.v3" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // MeloctlConfig represents the meloctl config file 16 | type MeloctlConfig struct { 17 | MelodyHomeDir string `yaml:"melody.home" pretty:"Melody home directory"` 18 | //MelodyRulesSource string `yaml:"melody.rules.sources" pretty:"Melody rules sources"` 19 | } 20 | 21 | var ( 22 | initCmd = &cobra.Command{ 23 | Use: "init", 24 | Short: "Create Meloctl config", 25 | Long: `This subcommand is used to setup Meloctl`, 26 | Run: initConfig, 27 | } 28 | 29 | rawMeloconf = map[string]string{} 30 | ) 31 | 32 | func init() { 33 | RootCmd.AddCommand(initCmd) 34 | } 35 | 36 | func initConfig(_ *cobra.Command, args []string) { 37 | _ = os.MkdirAll(meloctlConfDir, 0755) 38 | 39 | if ok, _ := fileutils.Exists(meloctlConfFile); ok { 40 | fmt.Printf("✅ [%s] Meloctl is already installed\n", meloctlConfFile) 41 | return 42 | } 43 | 44 | err := prompt.AskAll(MeloctlConfig{}, &rawMeloconf) 45 | if err != nil { 46 | fmt.Println(err) 47 | return 48 | } 49 | 50 | conf := NewMeloctlConfigFromRaw(rawMeloconf) 51 | out, err := yaml.Marshal(conf) 52 | if err != nil { 53 | fmt.Println(err) 54 | return 55 | } 56 | 57 | err = ioutil.WriteFile(meloctlConfFile, out, 0644) 58 | if err != nil { 59 | fmt.Println(err) 60 | return 61 | } 62 | 63 | fmt.Printf("✅ [%s] Meloctl has been initialized\n", meloctlConfFile) 64 | } 65 | 66 | // NewMeloctlConfigFromRaw creates a default MeloctlConfig struct 67 | func NewMeloctlConfigFromRaw(raw map[string]string) *MeloctlConfig { 68 | return &MeloctlConfig{ 69 | MelodyHomeDir: raw["melody.home"], 70 | //MelodyRulesSource: raw["melody.rules.sources"], 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /internal/logging/control.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "github.com/ma111e/melody/internal/config" 5 | "github.com/ma111e/melody/internal/events" 6 | ) 7 | 8 | var ( 9 | // LogChan is the channel used to receive events to be logged 10 | LogChan = make(chan events.Event) 11 | ) 12 | 13 | // Start starts the logging pipeline 14 | func Start(quitErrChan chan error, shutdownChan chan bool, loggerStoppedChan chan bool) { 15 | go receiveEventsForLogging(quitErrChan, shutdownChan, loggerStoppedChan) 16 | } 17 | 18 | func isIPv4(s string) bool { 19 | for i := 0; i < len(s); i++ { 20 | if s[i] == '.' { 21 | return true 22 | } 23 | } 24 | return false 25 | } 26 | 27 | func receiveEventsForLogging(quitErrChan chan error, shutdownChan chan bool, loggerStoppedChan chan bool) { 28 | defer func() { 29 | close(loggerStoppedChan) 30 | }() 31 | 32 | for { 33 | select { 34 | 35 | case ev := <-LogChan: 36 | switch ev.GetKind() { 37 | case config.HTTPKind: 38 | if isIPv4(ev.GetSourceIP()) { 39 | if _, ok := config.Cfg.DiscardProto4[config.HTTPKind]; ok { 40 | continue 41 | } 42 | } else { 43 | if _, ok := config.Cfg.DiscardProto6[config.HTTPKind]; ok { 44 | continue 45 | } 46 | } 47 | case config.HTTPSKind: 48 | if isIPv4(ev.GetSourceIP()) { 49 | if _, ok := config.Cfg.DiscardProto4[config.HTTPSKind]; ok { 50 | continue 51 | } 52 | } else { 53 | if _, ok := config.Cfg.DiscardProto6[config.HTTPSKind]; ok { 54 | continue 55 | } 56 | } 57 | } 58 | logdata, err := ev.ToLog().String() 59 | if err != nil { 60 | Warnings.Println("Failed to serialize JSON payload while writing to log file") 61 | continue 62 | } 63 | 64 | // Log to sensor file 65 | Sensor.Println(logdata) 66 | 67 | case <-quitErrChan: 68 | return 69 | 70 | case <-shutdownChan: 71 | return 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/rules/test_resources/tcp_rules.yml: -------------------------------------------------------------------------------- 1 | ok_ack: 2 | layer: tcp 3 | id: 352e81f5-beaa-49c5-8306-87f7ba4092bd 4 | match: 5 | tcp.ack: 1335620269 6 | 7 | nok_ack: 8 | layer: tcp 9 | id: dca1a1ac-3c44-40ae-a1c0-0b8881e9f23e 10 | match: 11 | tcp.ack: 0 12 | 13 | ok_seq: 14 | layer: tcp 15 | id: dc65fcd2-90bc-4b00-894b-7fe936be0298 16 | match: 17 | tcp.seq: 283559482 18 | 19 | nok_seq: 20 | layer: tcp 21 | id: 4c27b748-9c53-49d7-b16f-8e58dfeaa591 22 | match: 23 | tcp.seq: 0 24 | 25 | ok_window: 26 | layer: tcp 27 | id: 48594d79-193e-4796-8fc3-33c9d3d189f7 28 | match: 29 | tcp.window: 512 30 | 31 | nok_window: 32 | layer: tcp 33 | id: 49572418-8656-46ee-b141-0b6379e09bae 34 | match: 35 | tcp.window: 0 36 | 37 | ok_flags: 38 | layer: tcp 39 | id: b533a5c4-3ac0-40ce-8c3b-08aaf1577ab5 40 | match: 41 | tcp.flags: 42 | - "FA" 43 | 44 | nok_flags: 45 | layer: tcp 46 | id: d6e3b799-3e03-46d5-b24b-c4cb0a5bbfca 47 | match: 48 | tcp.flags: 49 | - "0" 50 | 51 | ok_fragbits: 52 | layer: tcp 53 | id: a29da618-a354-4b3c-9475-824876e4c4de 54 | match: 55 | tcp.fragbits: 56 | - "D" 57 | 58 | nok_fragbits: 59 | layer: tcp 60 | id: a0e477e7-aa5c-4246-b59b-902f6ccc79c3 61 | match: 62 | tcp.fragbits: 63 | - "M" 64 | 65 | ok_dsize: 66 | layer: tcp 67 | id: dc65fcd2-90bc-4b00-894b-7fe936be0298 68 | match: 69 | tcp.dsize: 78 70 | 71 | nok_dsize: 72 | layer: tcp 73 | id: 67c669bf-585d-4935-8c0c-4d3d18d996fc 74 | match: 75 | tcp.dsize: 0 76 | 77 | ok_payload: 78 | layer: tcp 79 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 80 | match: 81 | any: true 82 | tcp.payload: 83 | is: 84 | - "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/7.58.0\r\nAccept: */*\r\n\r\n" 85 | any: true 86 | 87 | nok_payload: 88 | layer: tcp 89 | id: ae6de878-80fc-4052-bc64-a92beb425248 90 | match: 91 | tcp.payload: 92 | is: 93 | - "nonexistent" 94 | any: true 95 | -------------------------------------------------------------------------------- /rules/rules-available/vpn.yml: -------------------------------------------------------------------------------- 1 | CVE-2020-19781 (Citrix ADC/Gateway): 2 | layer: http 3 | meta: 4 | id: a284dbe4-88c3-4da4-b2db-42709dd85305 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/11/07 10 | description: "Checking or trying to exploit CVE-2020-19781" 11 | references: 12 | - "https://support.citrix.com/article/CTX267027" 13 | match: 14 | http.uri: 15 | contains: 16 | - "/vpns/" 17 | tags: 18 | cve: "cve-2020-19781" 19 | techno: "vpn" 20 | vendor: "citrix" 21 | product: "netscaler_application_delivery_controller" 22 | impact: "information_disclosure" 23 | 24 | CVE-2018-13379 (Fortinet FortiOS SSL VPN): 25 | layer: http 26 | meta: 27 | id: fd46c426-3e87-4636-9ba6-af8d24dc5633 28 | version: 1.0 29 | author: BonjourMalware 30 | status: stable 31 | created: 2020/11/07 32 | modified: 2020/11/07 33 | description: "Checking or trying to exploit CVE-2018-13379" 34 | match: 35 | http.uri: 36 | startswith: 37 | - "/remote/fgt_lang?" 38 | tags: 39 | cve: "cve-2018-13379" 40 | techno: "vpn" 41 | vendor: "fortinet" 42 | product: "fortios" 43 | impact: "information_disclosure" 44 | 45 | #CVE-2020-5135 (SonicWall VPN Portal): 46 | # layer: http 47 | # meta: 48 | # id: 7d7733e2-6047-4dc5-8bad-609da742f230 49 | # version: 1.0 50 | # author: BonjourMalware 51 | # status: stable 52 | # created: 2020/11/07 53 | # modified: 2020/11/07 54 | # description: "Checking or trying to exploit CVE-2020-5135" 55 | # references: 56 | # - "https://www.shodan.io/search?query=product%3A%22SonicWALL+firewall+http+config%22" 57 | # - "https://www.shodan.io/search?query=product%3A%22SonicWALL+SSL-VPN+http+proxy%22" 58 | # - "https://fr.tenable.com/blog/cve-2020-5135-critical-sonicwall-vpn-portal-stack-based-buffer-overflow-vulnerability" 59 | # match: 60 | # 61 | # tags: 62 | # - "cve-2020-5135" 63 | # - "techno.vpn" 64 | # - "vendor.sonicwall" 65 | # - "product.sonicos" 66 | # - "impact.rce" 67 | -------------------------------------------------------------------------------- /cmd/meloctl/set.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "reflect" 7 | 8 | "github.com/ma111e/melody/internal/config" 9 | "github.com/ma111e/melody/internal/tagparser" 10 | "github.com/spf13/cobra" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | var ( 15 | setCmd = &cobra.Command{ 16 | Args: cobra.ExactArgs(2), 17 | Use: "set", 18 | Short: "Set a Meloctl config value by name", 19 | Long: `This subcommand is used to set a Meloctl config value by name`, 20 | Run: setConfigKey, 21 | } 22 | ) 23 | 24 | func init() { 25 | RootCmd.AddCommand(setCmd) 26 | } 27 | 28 | func setConfigKey(_ *cobra.Command, args []string) { 29 | var found bool 30 | key := args[0] 31 | val := args[1] 32 | 33 | validKeys, _ := config.LoadYAMLTagsOf(MeloctlConfig{}) 34 | for _, valid := range validKeys { 35 | if key == valid { 36 | found = true 37 | break 38 | } 39 | } 40 | 41 | if !found { 42 | fmt.Printf("❌ [%s] Unknown key\n", key) 43 | return 44 | } 45 | 46 | if err := setValueByYAMLTagName(meloctlConf, key, val); err != nil { 47 | fmt.Printf("❌ Error while setting value : %s\n", err) 48 | return 49 | } 50 | 51 | fmt.Printf("%s => %s \n", key, val) 52 | 53 | out, err := yaml.Marshal(meloctlConf) 54 | if err != nil { 55 | fmt.Println(err) 56 | return 57 | } 58 | 59 | err = ioutil.WriteFile(meloctlConfFile, out, 0644) 60 | if err != nil { 61 | fmt.Println(err) 62 | return 63 | } 64 | 65 | fmt.Printf("✅ [%s] Configuration file updated\n", meloctlConfFile) 66 | } 67 | 68 | func setValueByYAMLTagName(what interface{}, key string, val string) error { 69 | for i := 0; i < reflect.TypeOf(what).Elem().NumField(); i++ { 70 | tag := reflect.TypeOf(what).Elem().Field(i).Tag 71 | 72 | _, exists := tag.Lookup("yaml") 73 | if !exists { 74 | continue 75 | } 76 | 77 | tagValue, err := tagparser.ParseYamlTagValue(tag) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | if key == tagValue { 83 | reflect.ValueOf(what).Elem().FieldByName(reflect.TypeOf(what).Elem().Field(i).Name).SetString(val) 84 | break 85 | } 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/events/helpers/layers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import "github.com/google/gopacket/layers" 4 | 5 | // IPv4Layer is a custom layer on top of the layers.IPv4 object from gopacket 6 | type IPv4Layer struct { 7 | Header *layers.IPv4 8 | } 9 | 10 | // IPv6Layer is a custom layer on top of the layers.IPv6 object from gopacket 11 | type IPv6Layer struct { 12 | Header *layers.IPv6 13 | } 14 | 15 | // GetIPv4Header returns the gopacket's layers.IPv4 layer from the custom IPv4Layer abstraction 16 | func (lay IPv4Layer) GetIPv4Header() *layers.IPv4 { 17 | return lay.Header 18 | } 19 | 20 | // GetIPv6Header returns the gopacket's layers.IPv6 layer from the custom IPv6Layer abstraction 21 | func (lay IPv6Layer) GetIPv6Header() *layers.IPv6 { 22 | return lay.Header 23 | } 24 | 25 | // UDPLayer is a custom layer on top of the layers.UDP object from gopacket 26 | type UDPLayer struct { 27 | Header *layers.UDP 28 | } 29 | 30 | // GetUDPHeader returns the gopacket's layers.UDP layer from the custom UDPLayer abstraction 31 | func (lay UDPLayer) GetUDPHeader() *layers.UDP { 32 | return lay.Header 33 | } 34 | 35 | // TCPLayer is a custom layer on top of the layers.TCP object from gopacket 36 | type TCPLayer struct { 37 | Header *layers.TCP 38 | } 39 | 40 | // GetTCPHeader returns the gopacket's layers.TCP layer from the custom TCPLayer abstraction 41 | func (lay TCPLayer) GetTCPHeader() *layers.TCP { 42 | return lay.Header 43 | } 44 | 45 | // ICMPv4Layer is a custom layer on top of the layers.ICMPv4 object from gopacket 46 | type ICMPv4Layer struct { 47 | Header *layers.ICMPv4 48 | } 49 | 50 | // GetICMPv4Header returns the gopacket's layers.ICMPv4 layer from the custom ICMPv4Layer abstraction 51 | func (lay ICMPv4Layer) GetICMPv4Header() *layers.ICMPv4 { 52 | return lay.Header 53 | } 54 | 55 | // ICMPv6Layer is a custom layer on top of the layers.ICMPv6 object from gopacket 56 | type ICMPv6Layer struct { 57 | Header *layers.ICMPv6 58 | } 59 | 60 | // GetICMPv6Header returns the gopacket's layers.ICMPv6 layer from the custom ICMPv6Layer abstraction 61 | func (lay ICMPv6Layer) GetICMPv6Header() *layers.ICMPv6 { 62 | return lay.Header 63 | } 64 | -------------------------------------------------------------------------------- /internal/httpparser/httpparser.go: -------------------------------------------------------------------------------- 1 | package httpparser 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/ma111e/melody/internal/config" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "net/http/httputil" 11 | "strconv" 12 | 13 | "github.com/c2h5oh/datasize" 14 | ) 15 | 16 | // GetBodyPayload extract the body of an http.Request without striping it 17 | func GetBodyPayload(r *http.Request) ([]byte, error) { 18 | var buf bytes.Buffer 19 | var b bytes.Buffer 20 | var dest io.Writer = &b 21 | 22 | if r.Body != nil { 23 | if r.Header.Get("Content-Length") == "" { 24 | return []byte{}, nil 25 | } 26 | iContentLength, err := strconv.ParseUint(r.Header.Get("Content-Length"), 10, 64) 27 | 28 | if err != nil { 29 | return []byte{}, fmt.Errorf("request data not logged (failed to parse Content-Length as uint64 : %s)", err) 30 | } 31 | 32 | if iContentLength > config.Cfg.MaxPOSTDataSize { 33 | return []byte{}, fmt.Errorf("request data not logged (over %s : %s)", datasize.ByteSize(config.Cfg.MaxPOSTDataSize).HumanReadable(), (datasize.ByteSize(iContentLength) * datasize.B).HumanReadable()) 34 | } 35 | 36 | if _, err := buf.ReadFrom(r.Body); err != nil { 37 | return []byte{}, fmt.Errorf("failed to parse request body : got error when reading from body [%s]", err.Error()) 38 | } 39 | 40 | if err := r.Body.Close(); err != nil { 41 | // Send read body in such case 42 | return buf.Bytes(), fmt.Errorf("failed to parse request body : got error while closing body [%s]", err.Error()) 43 | } 44 | 45 | bodyReader := ioutil.NopCloser(bytes.NewReader(buf.Bytes())) 46 | 47 | chunked := len(r.TransferEncoding) > 0 && r.TransferEncoding[0] == "chunked" 48 | 49 | if chunked { 50 | dest = httputil.NewChunkedWriter(dest) 51 | } 52 | 53 | if _, err := io.Copy(dest, bodyReader); err != nil { 54 | // Send read body in such case 55 | return buf.Bytes(), fmt.Errorf("failed to parse request body: got error while copying the read body [%s]", err.Error()) 56 | } 57 | 58 | if chunked { 59 | _ = dest.(io.Closer).Close() 60 | _, _ = io.WriteString(&b, "\r\n") 61 | } 62 | } 63 | 64 | return b.Bytes(), nil 65 | } 66 | -------------------------------------------------------------------------------- /rules/rules-available/server.yml: -------------------------------------------------------------------------------- 1 | CVE-2020-14882 Oracle Weblogic Server RCE: 2 | layer: http 3 | meta: 4 | id: 3e1d86d8-fba6-4e15-8c74-941c3375fd3e 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/20/07 10 | description: "Checking or trying to exploit CVE-2020-14882" 11 | references: 12 | - "https://nvd.nist.gov/vuln/detail/CVE-2020-14882" 13 | match: 14 | http.uri: 15 | startswith|any|nocase: 16 | - "/console/css/" 17 | - "/console/images" 18 | contains|any|nocase: 19 | - "console.portal" 20 | - "consolejndi.portal?test_handle=" 21 | tags: 22 | cve: "cve-2020-14882" 23 | vendor: "oracle" 24 | product: "weblogic" 25 | impact: "rce" 26 | 27 | CVE-2020-13942 Apache Unomi RCE: 28 | layer: http 29 | meta: 30 | id: 282560f6-e120-4b08-8b82-73bf1166fce2 31 | version: 1.0 32 | author: BonjourMalware 33 | status: experimental 34 | created: 2020/20/07 35 | modified: 2020/20/07 36 | description: "Checking or trying to exploit CVE-2020-13942" 37 | references: 38 | - "https://nvd.nist.gov/vuln/detail/CVE-2020-13942" 39 | match: 40 | http.method: 41 | is: 42 | - "POST" 43 | http.uri: 44 | is: 45 | - "/context.json" 46 | http.body: 47 | contains: 48 | - '"filters":' 49 | - '"parameterValues":' 50 | - 'getRuntime' 51 | - '"profilePropertyCondition"' 52 | tags: 53 | cve: "cve-2020-13942" 54 | vendor: "apache" 55 | product: "unomi" 56 | impact: "rce" 57 | 58 | CVE-2021-21972 VMware vSphere: 59 | layer: http 60 | meta: 61 | id: 54fb9b52-6d37-4478-91bc-dd1c85acdb2b 62 | version: 1.0 63 | author: BonjourMalware 64 | status: experimental 65 | created: 2021/03/02 66 | modified: 2021/03/02 67 | description: "Checking or trying to exploit CVE-2021-21972" 68 | references: 69 | - "https://nvd.nist.gov/vuln/detail/CVE-2021-21972" 70 | - "https://swarm.ptsecurity.com/unauth-rce-vmware/" 71 | match: 72 | http.uri: 73 | startswith|nocase: 74 | - "/ui/vropspluginui/rest/services" 75 | tags: 76 | cve: "cve-2021-21972" 77 | vendor: "vmware" 78 | product: "vsphere" 79 | impact: "rce" 80 | -------------------------------------------------------------------------------- /internal/rules/test_resources/http_rules.yml: -------------------------------------------------------------------------------- 1 | ok_uri: 2 | layer: http 3 | version: 1.0 4 | id: 9fac5a6d-a3c5-487a-950d-4c56a6d025ad 5 | match: 6 | http.uri: 7 | is: 8 | - "/cgi-bin/mainfunction.cgi" 9 | any: true 10 | 11 | nok_uri: 12 | layer: http 13 | version: 1.0 14 | id: 300ebc19-8f48-4fbe-a39a-9b3457b0bd96 15 | match: 16 | http.uri: 17 | is: 18 | - "/nonexistent" 19 | any: true 20 | 21 | ok_is_tls: 22 | layer: http 23 | version: 1.0 24 | id: 352e81f5-beaa-49c5-8306-87f7ba4092bd 25 | match: 26 | http.tls: false 27 | 28 | nok_is_tls: 29 | layer: http 30 | version: 1.0 31 | id: dca1a1ac-3c44-40ae-a1c0-0b8881e9f23e 32 | match: 33 | http.tls: true 34 | 35 | ok_headers: 36 | layer: http 37 | version: 1.0 38 | id: 5a819c44-bc45-4db1-9504-198a79da689d 39 | match: 40 | http.headers: 41 | is: 42 | - "X-Testing: I am a test header" 43 | 44 | nok_headers: 45 | layer: http 46 | version: 1.0 47 | id: 795e097a-662d-4839-9301-97e73bd5a4bc 48 | match: 49 | http.headers: 50 | is: 51 | - "X-Nonexistent: I do not exist" 52 | 53 | ok_body: 54 | layer: http 55 | version: 1.0 56 | id: 740055f3-1b77-4677-89ec-bcea34097593 57 | match: 58 | http.body: 59 | is: 60 | - '{"testkey": "testvalue"}' 61 | 62 | nok_body: 63 | layer: http 64 | version: 1.0 65 | id: 4adf1dc4-912c-459a-82f8-5b3aa0f20bba 66 | match: 67 | http.body: 68 | is: 69 | - "nonexistent body" 70 | 71 | ok_proto: 72 | layer: http 73 | version: 1.0 74 | id: f0b82f01-cbf7-4af7-bf26-44dc9403f0f9 75 | match: 76 | http.proto: 77 | is: 78 | - "HTTP/1.1" 79 | 80 | nok_proto: 81 | layer: http 82 | version: 1.0 83 | id: c0917358-a738-4afe-83e9-9ababa745b62 84 | match: 85 | http.proto: 86 | is: 87 | - "nonexistent proto" 88 | 89 | ok_method: 90 | layer: http 91 | version: 1.0 92 | id: b2656489-6118-4e9e-aebd-1413737666ad 93 | match: 94 | http.method: 95 | is: 96 | - "POST" 97 | 98 | nok_method: 99 | layer: http 100 | version: 1.0 101 | id: c1e40473-e250-48eb-b5ff-cf25cd4469a0 102 | match: 103 | http.method: 104 | is: 105 | - "nonexistent verb" 106 | -------------------------------------------------------------------------------- /internal/rules/rules.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "github.com/ma111e/melody/internal/filters" 5 | "github.com/ma111e/melody/internal/logging" 6 | ) 7 | 8 | // Rules abstracts an array of Rule 9 | type Rules []Rule 10 | 11 | // Rule describes a parsed Rule object, used to match against a byte array 12 | type Rule struct { 13 | Name string 14 | ID string 15 | Tags map[string]string 16 | 17 | Layer string 18 | 19 | IPProtocol *ConditionsList 20 | 21 | HTTP ParsedHTTPRule 22 | TCP ParsedTCPRule 23 | UDP ParsedUDPRule 24 | ICMPv4 ParsedICMPv4Rule 25 | ICMPv6 ParsedICMPv6Rule 26 | 27 | IPs filters.IPRules 28 | Ports filters.PortRules 29 | Metadata Metadata 30 | Additional map[string]string 31 | 32 | MatchAll bool 33 | } 34 | 35 | // NewRule creates a Rule from a RawRule 36 | func NewRule(rawRule RawRule) Rule { 37 | var portsList = filters.PortRules{ 38 | WhitelistedPorts: filters.PortRanges{}, 39 | BlacklistedPorts: filters.PortRanges{}, 40 | } 41 | 42 | var ipsList = filters.IPRules{ 43 | WhitelistedIPs: filters.IPRanges{}, 44 | BlacklistedIPs: filters.IPRanges{}, 45 | } 46 | 47 | ipsList.ParseRules(rawRule.Whitelist.IPs, rawRule.Blacklist.IPs) 48 | portsList.ParseRules(rawRule.Whitelist.Ports, rawRule.Blacklist.Ports) 49 | 50 | parsedIPProtocol, err := rawRule.IPProtocol.ParseList() 51 | if err != nil { 52 | logging.Errors.Printf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 53 | return Rule{} 54 | } 55 | 56 | rule := Rule{ 57 | Tags: rawRule.Tags, 58 | IPProtocol: parsedIPProtocol, 59 | ID: rawRule.Metadata.ID, 60 | Layer: rawRule.Layer, 61 | Ports: portsList, 62 | IPs: ipsList, 63 | Metadata: rawRule.Metadata, 64 | Additional: rawRule.Additional, 65 | } 66 | 67 | return rule 68 | } 69 | 70 | // Filter is a helper filtering out one or multiple Rule according to a function returning true or false 71 | // Similar to array.filter() in python 72 | func (rules Rules) Filter(fn func(rule Rule) bool) Rules { 73 | res := Rules{} 74 | 75 | for _, rule := range rules { 76 | if fn(rule) { 77 | res = append(res, rule) 78 | } 79 | } 80 | 81 | return res 82 | } 83 | 84 | // Parse parses raw rules to create a set of rules as Rules 85 | //func (rawRules RawRules) Parse() Rules { 86 | // rules := Rules{} 87 | // for rname, rule := range rawRules { 88 | // parsedRule := rule.Parse() 89 | // parsedRule.Name = rname 90 | // rules = append(rules, parsedRule) 91 | // } 92 | // 93 | // return rules 94 | //} 95 | -------------------------------------------------------------------------------- /internal/rules/flags_conditions.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | // RawTCPFlags abstracts a string describing raw TCP flags 8 | type RawTCPFlags string 9 | 10 | // RawFragbits abstracts a string describing raw fragbits 11 | type RawFragbits string 12 | 13 | // RawTCPFlagsList abstracts an array of RawTCPFlags 14 | type RawTCPFlagsList []RawTCPFlags 15 | 16 | // RawFragbitsList abstracts an array of RawFragbits 17 | type RawFragbitsList []RawFragbits 18 | 19 | // ParseList parses a RawFragbitsList and returns a list of fragbits as an uint8 array 20 | func (list RawFragbitsList) ParseList() []*uint8 { 21 | var flagsList []*uint8 22 | 23 | if len(list) == 0 { 24 | return []*uint8{} 25 | } 26 | 27 | for _, val := range list { 28 | flagsList = append(flagsList, val.Parse()) 29 | } 30 | 31 | return flagsList 32 | } 33 | 34 | // ParseList parses a RawTCPFlagsList and returns a list of tcp flags as an uint8 array 35 | func (list RawTCPFlagsList) ParseList() []*uint8 { 36 | var flagsList []*uint8 37 | 38 | if len(list) == 0 { 39 | return nil 40 | } 41 | 42 | for _, val := range list { 43 | flagsList = append(flagsList, val.Parse()) 44 | } 45 | 46 | return flagsList 47 | } 48 | 49 | // Parse parses a RawTCPFlags string to return its equivalent as an uint8 50 | func (rfls RawTCPFlags) Parse() *uint8 { 51 | var flags uint8 52 | 53 | //TODO Add support for "Not" option 54 | for _, val := range rfls { 55 | switch val { 56 | case 'F': 57 | flags |= 0x01 58 | case 'S': 59 | flags |= 0x02 60 | case 'R': 61 | flags |= 0x04 62 | case 'P': 63 | flags |= 0x08 64 | case 'A': 65 | flags |= 0x10 66 | case 'U': 67 | flags |= 0x20 68 | case 'E': 69 | flags |= 0x40 70 | case 'C': 71 | flags |= 0x80 72 | case '0': 73 | flags = 0 74 | default: 75 | log.Println("Unknown TCP flag value :", val) 76 | return nil 77 | } 78 | } 79 | 80 | return &flags 81 | } 82 | 83 | // Parse parses a RawFragbits string to return its equivalent as an uint8 84 | func (rfbs RawFragbits) Parse() *uint8 { 85 | var fragbits uint8 86 | 87 | if len(rfbs) == 0 { 88 | return nil 89 | } 90 | 91 | for _, flag := range rfbs { 92 | switch flag { 93 | case 'M': 94 | fragbits |= 0x01 95 | case 'D': 96 | fragbits |= 0x02 97 | case 'R': 98 | fragbits |= 0x04 99 | default: 100 | log.Println("Unknown fragbits value :", flag) 101 | } 102 | } 103 | 104 | return &fragbits 105 | } 106 | -------------------------------------------------------------------------------- /internal/events/icmpv4.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ma111e/melody/internal/events/helpers" 7 | "github.com/ma111e/melody/internal/logdata" 8 | 9 | "github.com/ma111e/melody/internal/config" 10 | 11 | "github.com/google/gopacket" 12 | "github.com/google/gopacket/layers" 13 | ) 14 | 15 | // ICMPv4Event describes the structure of an event generated by an ICPMv4 packet 16 | type ICMPv4Event struct { 17 | //ICMPv4Header *layers.ICMPv4 18 | LogData logdata.ICMPv4EventLog 19 | BaseEvent 20 | helpers.IPv4Layer 21 | helpers.ICMPv4Layer 22 | } 23 | 24 | // NewICMPv4Event created a new ICMPv4Event from a packet 25 | func NewICMPv4Event(packet gopacket.Packet) (*ICMPv4Event, error) { 26 | var ev = &ICMPv4Event{} 27 | ev.Kind = config.ICMPv4Kind 28 | 29 | ev.Session = "n/a" 30 | ev.Timestamp = packet.Metadata().Timestamp 31 | 32 | ICMPv4Header, _ := packet.Layer(layers.LayerTypeICMPv4).(*layers.ICMPv4) 33 | ev.ICMPv4Layer = helpers.ICMPv4Layer{Header: ICMPv4Header} 34 | 35 | IPHeader, _ := packet.Layer(layers.LayerTypeIPv4).(*layers.IPv4) 36 | ev.IPv4Layer = helpers.IPv4Layer{Header: IPHeader} 37 | ev.SourceIP = ev.IPv4Layer.Header.SrcIP.String() 38 | ev.Additional = make(map[string]string) 39 | ev.Tags = make(Tags) 40 | 41 | return ev, nil 42 | } 43 | 44 | // ToLog parses the event structure and generate an EventLog almost ready to be sent to the logging file 45 | func (ev ICMPv4Event) ToLog() EventLog { 46 | ev.LogData = logdata.ICMPv4EventLog{} 47 | ev.LogData.Timestamp = ev.Timestamp.Format(time.RFC3339Nano) 48 | 49 | //ev.LogData.Type = ev.Kind 50 | //ev.LogData.SourceIP = ev.SourceIP 51 | //ev.LogData.DestPort = ev.DestPort 52 | //ev.LogData.Session = ev.Session 53 | // 54 | //if len(ev.Tags) == 0 { 55 | // ev.LogData.Tags = make(map[string][]string) 56 | //} else { 57 | // ev.LogData.Tags = ev.Tags 58 | //} 59 | 60 | ev.LogData.Init(ev.BaseEvent) 61 | 62 | ev.LogData.ICMPv4 = logdata.ICMPv4LogData{ 63 | TypeCode: ev.ICMPv4Layer.Header.TypeCode, 64 | Type: ev.ICMPv4Layer.Header.TypeCode.Type(), 65 | Code: ev.ICMPv4Layer.Header.TypeCode.Code(), 66 | TypeCodeName: ev.ICMPv4Layer.Header.TypeCode.String(), 67 | Checksum: ev.ICMPv4Layer.Header.Checksum, 68 | ID: ev.ICMPv4Layer.Header.Id, 69 | Seq: ev.ICMPv4Layer.Header.Seq, 70 | Payload: logdata.NewPayloadLogData(ev.ICMPv4Layer.Header.Payload, config.Cfg.MaxICMPv4DataSize), 71 | } 72 | 73 | ev.LogData.IP = logdata.NewIPv4LogData(ev.IPv4Layer) 74 | ev.LogData.Additional = ev.Additional 75 | 76 | return ev.LogData 77 | } 78 | -------------------------------------------------------------------------------- /cmd/meloctl/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/ma111e/melody/internal/fileutils" 11 | 12 | "github.com/ma111e/melody/internal/config" 13 | "gopkg.in/yaml.v3" 14 | 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | // RootCmd represents the base command when called without any subcommands 19 | var ( 20 | RootCmd = &cobra.Command{ 21 | Use: "meloctl", 22 | Short: "Melody helper", 23 | Long: "Melody helper", 24 | } 25 | meloctlConf *MeloctlConfig 26 | melodyConf *config.Config 27 | meloctlConfFile string 28 | meloctlConfDir string 29 | ) 30 | 31 | func init() { 32 | // Trick to prevent the "unused variable" warning message for melodyConf, which is triggered because the relationship 33 | // between the cobra.OnInitialize function and the use of melodyConf via loadConfig is not being currently detected 34 | _ = melodyConf 35 | homeDir, err := os.UserHomeDir() 36 | if err != nil { 37 | log.Fatalln("Failed to get home directory path") 38 | } 39 | 40 | meloctlConfDir = filepath.Join(homeDir, ".config", "meloctl") 41 | meloctlConfFile = filepath.Join(meloctlConfDir, "meloctl.yml") 42 | 43 | cobra.OnInitialize(loadConfig) 44 | } 45 | 46 | func loadConfig() { 47 | var err error 48 | meloctlConf = loadMeloctl() 49 | melodyConf, err = loadMelodyConfig(filepath.Join(meloctlConf.MelodyHomeDir, "config.yml")) 50 | if err != nil { 51 | log.Println(err) 52 | } 53 | } 54 | 55 | func loadMeloctl() *MeloctlConfig { 56 | var conf MeloctlConfig 57 | 58 | ok, err := fileutils.Exists(meloctlConfFile) 59 | if err != nil { 60 | fmt.Println(err) 61 | return nil 62 | } 63 | 64 | if !ok { 65 | if os.Args[1] != "init" { 66 | fmt.Println("⚠️ Meloctl config have not been initialized yet. Create a new configuration file using \"meloctl init\"") 67 | } 68 | 69 | return &conf 70 | } 71 | 72 | rawConf, err := ioutil.ReadFile(meloctlConfFile) 73 | if err != nil { 74 | fmt.Println(err) 75 | return nil 76 | } 77 | 78 | err = yaml.Unmarshal(rawConf, &conf) 79 | if err != nil { 80 | fmt.Println(err) 81 | return nil 82 | } 83 | 84 | return &conf 85 | } 86 | 87 | func loadMelodyConfig(configPath string) (*config.Config, error) { 88 | conf := config.NewConfig() 89 | cfgData, err := ioutil.ReadFile(configPath) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | if err = yaml.Unmarshal(cfgData, conf); err != nil { 95 | return nil, err 96 | } 97 | 98 | return conf, err 99 | } 100 | -------------------------------------------------------------------------------- /internal/events/base.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "github.com/ma111e/melody/internal/loggable" 5 | "time" 6 | ) 7 | 8 | // BaseEvent described the common structure to all the events generated by the received packets 9 | type BaseEvent struct { 10 | IPVersion uint 11 | Tags Tags 12 | Kind string 13 | SourceIP string 14 | DestPort uint16 15 | Session string 16 | Timestamp time.Time 17 | Additional map[string]string 18 | Event 19 | loggable.Loggable 20 | } 21 | 22 | // Tags is an abstraction of map[string]interface{} allowing for the use of a set-like structure and a more graceful 23 | // conversion to array 24 | type Tags map[string][]string 25 | 26 | // GetKind fetches the Kind of an event 27 | func (ev BaseEvent) GetKind() string { 28 | return ev.Kind 29 | } 30 | 31 | // GetSourceIP fetches the SourceIP of an event 32 | func (ev BaseEvent) GetSourceIP() string { 33 | return ev.SourceIP 34 | } 35 | 36 | // GetDestPort fetches the DestPort of an event 37 | func (ev BaseEvent) GetDestPort() uint16 { 38 | return ev.DestPort 39 | } 40 | 41 | // GetSession fetches the Session of an event 42 | func (ev BaseEvent) GetSession() string { 43 | return ev.Session 44 | } 45 | 46 | // GetTags fetches the Tags of an event 47 | func (ev BaseEvent) GetTags() map[string][]string { 48 | return ev.Tags 49 | } 50 | 51 | // AddAdditional fetches the Additional values of an event 52 | func (ev *BaseEvent) AddAdditional(add map[string]string) { 53 | for key, values := range add { 54 | ev.Additional[key] = values 55 | } 56 | } 57 | 58 | // AddTags add the given tag array to the event's tags 59 | //func (ev *BaseEvent) AddTags(tags []string) { 60 | // for _, tag := range tags { 61 | // ev.Tags[tag] = struct{}{} 62 | // } 63 | //} 64 | 65 | // AddTags add the given tag array to the event's tags 66 | func (ev *BaseEvent) AddTags(tags map[string]string) { 67 | // If the tag does not already exist in its category, add it 68 | for cat, tag := range tags { 69 | if _, ok := ev.Tags[cat]; !ok { 70 | ev.Tags[cat] = []string{tag} 71 | continue 72 | } 73 | 74 | for _, val := range ev.Tags[cat] { 75 | if val == tag { 76 | break 77 | } 78 | } 79 | 80 | ev.Tags[cat] = append(ev.Tags[cat], tag) 81 | } 82 | } 83 | 84 | ////ToInlineArray converts a Tags map to an array of its values with the keys and values merged with a '.' 85 | //func (t *Tags) ToInlineArray() []string { 86 | // var inlineTags []string 87 | // 88 | // for key, values := range *t { 89 | // for _, val := range values { 90 | // inlineTags = append(inlineTags, fmt.Sprintf("%s.%s", key, val)) 91 | // } 92 | // } 93 | // 94 | // return inlineTags 95 | //} 96 | -------------------------------------------------------------------------------- /rules/rules-available/router.yml: -------------------------------------------------------------------------------- 1 | CVE-2017-17215 (Huawei routers): 2 | layer: http 3 | meta: 4 | id: 846cef81-3e82-40ba-a8ed-54137ccea691 5 | version: 1.0 6 | author: BonjourMalware 7 | status: stable 8 | created: 2020/11/07 9 | modified: 2020/11/07 10 | description: "Checking or trying to exploit CVE-2017-17215" 11 | match: 12 | http.uri: 13 | is: 14 | - "/ctrlt/DeviceUpgrade_1" 15 | tags: 16 | cve: "cve-2017-17215" 17 | device: "router" 18 | vendor: "huawei" 19 | impact: "rce" 20 | 21 | CVE-2020-8515 (DrayTek routers): 22 | layer: http 23 | meta: 24 | id: b7f564b2-44f0-434f-bf9a-7f55c0e50463 25 | version: 1.0 26 | author: BonjourMalware 27 | status: stable 28 | created: 2020/11/07 29 | modified: 2020/11/07 30 | description: "Checking or trying to exploit CVE-2020-8515" 31 | match: 32 | http.uri: 33 | startswith: 34 | - "/cgi-bin/mainfunction.cgi" 35 | tags: 36 | cve: "cve-2020-8515" 37 | device: "router" 38 | vendor: "draytek" 39 | impact: "rce" 40 | 41 | CVE-2014-8361 (Realtek SDK Miniigd UPnP SOAP): 42 | layer: http 43 | meta: 44 | id: dd852a90-625e-416a-a69d-ad22554f9d25 45 | version: 1.0 46 | author: BonjourMalware 47 | status: stable 48 | created: 2020/11/07 49 | modified: 2020/11/07 50 | description: "Checking or trying to exploit CVE-2014-8361" 51 | match: 52 | http.uri: 53 | is: 54 | - "/picsdesc.xml" 55 | tags: 56 | cve: "cve-2014-8361" 57 | device: "router" 58 | vendor: "realtek" 59 | impact: "rce" 60 | techno: "soap" 61 | 62 | ZyXEL Eir D1000: 63 | layer: http 64 | meta: 65 | id: 98d37645-256a-48d4-a4fe-105907805417 66 | version: 1.0 67 | author: BonjourMalware 68 | status: stable 69 | created: 2020/11/07 70 | modified: 2020/11/07 71 | description: "Checking or trying to exploit a vulnerable ZyXEL Eir D1000 endpoint" 72 | match: 73 | http.uri: 74 | is: 75 | - "/UD/act?1" 76 | tags: 77 | device: "router" 78 | vendor: "zyxel" 79 | impact: "rce" 80 | techno: "soap" 81 | 82 | Linksys routers (Moon worm): 83 | layer: http 84 | meta: 85 | id: 22ac8b19-2ed3-430e-8980-fcb30d96ccb4 86 | version: 1.0 87 | author: BonjourMalware 88 | status: stable 89 | created: 2020/11/07 90 | modified: 2020/11/07 91 | description: "Checking or trying to exploit a vulnerable Lynksys router" 92 | match: 93 | http.uri: 94 | is: 95 | - "/tmUnblock.cgi" 96 | tags: 97 | device: "router" 98 | vendor: "linksys" 99 | impact: "rce" 100 | malware: "moon_worm" 101 | malware_type: "worm" 102 | -------------------------------------------------------------------------------- /internal/rules/tagparser.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/ma111e/melody/internal/tagparser" 7 | ) 8 | 9 | // LoadValidMatchKeysMap returns a map of the json keys for each of the protos structs 10 | func LoadValidMatchKeysMap() map[string]interface{} { 11 | loadFns := []func() ([]string, error){ 12 | loadHTTPYamlTags, 13 | loadTCPYamlTags, 14 | loadUDPYamlTags, 15 | loadICMPv4YamlTags, 16 | loadICMPv6YamlTags, 17 | } 18 | 19 | matchKeysMap := make(map[string]interface{}) 20 | for _, loadFn := range loadFns { 21 | tags, err := loadFn() 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | for _, tag := range tags { 27 | matchKeysMap[tag] = new(interface{}) 28 | } 29 | } 30 | 31 | return matchKeysMap 32 | } 33 | 34 | // Below : all the same functions with a different struct 35 | func loadHTTPYamlTags() ([]string, error) { 36 | var tags []string 37 | for i := 0; i < reflect.TypeOf(HTTPRule{}).NumField(); i++ { 38 | ruleTag := reflect.TypeOf(HTTPRule{}).Field(i).Tag 39 | tagValue, err := tagparser.ParseYamlTagValue(ruleTag) 40 | if err != nil { 41 | return tags, err 42 | } 43 | tags = append(tags, tagValue) 44 | } 45 | 46 | return tags, nil 47 | } 48 | 49 | func loadTCPYamlTags() ([]string, error) { 50 | var tags []string 51 | for i := 0; i < reflect.TypeOf(TCPRule{}).NumField(); i++ { 52 | ruleTag := reflect.TypeOf(TCPRule{}).Field(i).Tag 53 | tagValue, err := tagparser.ParseYamlTagValue(ruleTag) 54 | if err != nil { 55 | return tags, err 56 | } 57 | tags = append(tags, tagValue) 58 | } 59 | 60 | return tags, nil 61 | } 62 | 63 | func loadUDPYamlTags() ([]string, error) { 64 | var tags []string 65 | for i := 0; i < reflect.TypeOf(UDPRule{}).NumField(); i++ { 66 | ruleTag := reflect.TypeOf(UDPRule{}).Field(i).Tag 67 | tagValue, err := tagparser.ParseYamlTagValue(ruleTag) 68 | if err != nil { 69 | return tags, err 70 | } 71 | tags = append(tags, tagValue) 72 | } 73 | 74 | return tags, nil 75 | } 76 | 77 | func loadICMPv4YamlTags() ([]string, error) { 78 | var tags []string 79 | for i := 0; i < reflect.TypeOf(ICMPv4Rule{}).NumField(); i++ { 80 | ruleTag := reflect.TypeOf(ICMPv4Rule{}).Field(i).Tag 81 | tagValue, err := tagparser.ParseYamlTagValue(ruleTag) 82 | if err != nil { 83 | return tags, err 84 | } 85 | tags = append(tags, tagValue) 86 | } 87 | 88 | return tags, nil 89 | } 90 | 91 | func loadICMPv6YamlTags() ([]string, error) { 92 | var tags []string 93 | for i := 0; i < reflect.TypeOf(ICMPv6Rule{}).NumField(); i++ { 94 | ruleTag := reflect.TypeOf(ICMPv6Rule{}).Field(i).Tag 95 | tagValue, err := tagparser.ParseYamlTagValue(ruleTag) 96 | if err != nil { 97 | return tags, err 98 | } 99 | tags = append(tags, tagValue) 100 | } 101 | 102 | return tags, nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/events/icmpv6.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/gopacket" 7 | "github.com/google/gopacket/layers" 8 | "github.com/ma111e/melody/internal/config" 9 | "github.com/ma111e/melody/internal/events/helpers" 10 | "github.com/ma111e/melody/internal/logdata" 11 | ) 12 | 13 | // ICMPv6Event describes the structure of an event generated by an ICPMv6 packet 14 | type ICMPv6Event struct { 15 | LogData logdata.ICMPv6EventLog 16 | BaseEvent 17 | helpers.IPv6Layer 18 | helpers.ICMPv6Layer 19 | } 20 | 21 | // NewICMPv6Event created a new ICMPv6Event from a packet 22 | func NewICMPv6Event(packet gopacket.Packet) (*ICMPv6Event, error) { 23 | var ev = &ICMPv6Event{} 24 | ev.Kind = config.ICMPv6Kind 25 | 26 | ev.Session = "n/a" 27 | ev.Timestamp = packet.Metadata().Timestamp 28 | 29 | ICMPv6Header, _ := packet.Layer(layers.LayerTypeICMPv6).(*layers.ICMPv6) 30 | ev.ICMPv6Layer = helpers.ICMPv6Layer{Header: ICMPv6Header} 31 | 32 | IPHeader, _ := packet.Layer(layers.LayerTypeIPv6).(*layers.IPv6) 33 | ev.IPv6Layer = helpers.IPv6Layer{Header: IPHeader} 34 | ev.SourceIP = ev.IPv6Layer.Header.SrcIP.String() 35 | ev.Additional = make(map[string]string) 36 | ev.Tags = make(Tags) 37 | 38 | return ev, nil 39 | } 40 | 41 | // ToLog parses the event structure and generate an EventLog almost ready to be sent to the logging file 42 | func (ev ICMPv6Event) ToLog() EventLog { 43 | ev.LogData = logdata.ICMPv6EventLog{} 44 | //ev.LogData.Timestamp = time.Now().Format(time.RFC3339) 45 | //ev.LogData.NsTimestamp = strconv.FormatInt(time.Now().UnixNano(), 10) 46 | ev.LogData.Timestamp = ev.Timestamp.Format(time.RFC3339Nano) 47 | 48 | //ev.LogData.Type = ev.Kind 49 | //ev.LogData.SourceIP = ev.SourceIP 50 | //ev.LogData.DestPort = ev.DestPort 51 | //ev.LogData.Session = ev.Session 52 | // 53 | //if len(ev.Tags) == 0 { 54 | // ev.LogData.Tags = make(map[string][]string) 55 | //} else { 56 | // ev.LogData.Tags = ev.Tags 57 | //} 58 | 59 | ev.LogData.Init(ev.BaseEvent) 60 | 61 | ev.LogData.ICMPv6 = logdata.ICMPv6LogData{ 62 | TypeCode: ev.ICMPv6Layer.Header.TypeCode, 63 | Type: ev.ICMPv6Layer.Header.TypeCode.Type(), 64 | Code: ev.ICMPv6Layer.Header.TypeCode.Code(), 65 | TypeCodeName: ev.ICMPv6Layer.Header.TypeCode.String(), 66 | Checksum: ev.ICMPv6Layer.Header.Checksum, 67 | Payload: logdata.NewPayloadLogData(ev.ICMPv6Layer.Header.Payload, config.Cfg.MaxICMPv6DataSize), 68 | } 69 | 70 | ev.LogData.IP = logdata.IPv6LogData{ 71 | Version: ev.IPv6Layer.Header.Version, 72 | Length: ev.IPv6Layer.Header.Length, 73 | NextHeader: ev.IPv6Layer.Header.NextHeader, 74 | NextHeaderName: ev.IPv6Layer.Header.NextHeader.String(), 75 | TrafficClass: ev.IPv6Layer.Header.TrafficClass, 76 | FlowLabel: ev.IPv6Layer.Header.FlowLabel, 77 | HopLimit: ev.IPv6Layer.Header.HopLimit, 78 | } 79 | 80 | ev.LogData.Additional = ev.Additional 81 | 82 | return ev.LogData 83 | } 84 | -------------------------------------------------------------------------------- /internal/rules/load.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/ma111e/melody/internal/logging" 12 | 13 | "github.com/ma111e/melody/internal/config" 14 | 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | var ( 19 | // GlobalRules is the global object holding all the loaded rules 20 | GlobalRules = make(map[string][]Rules) 21 | ) 22 | 23 | // GlobalRawRules describes a set of RawRules 24 | type GlobalRawRules []RawRules 25 | 26 | // LoadRulesDir walks the given directory to find rule files and load them into GlobalRules 27 | func LoadRulesDir(rulesDir string) uint { 28 | var err error 29 | var globalRawRules GlobalRawRules 30 | var total uint 31 | 32 | skiplist := []string{ 33 | ".gitkeep", 34 | } 35 | 36 | globalRawRules, err = ParseRulesDir(rulesDir, skiplist) 37 | if err != nil { 38 | log.Println(fmt.Sprintf("Failed to parse rule directory [%s]", rulesDir)) 39 | log.Println(err) 40 | os.Exit(1) 41 | } 42 | 43 | for _, rawRules := range globalRawRules { 44 | rules := Rules{} 45 | for ruleName, rawRule := range rawRules { 46 | rule, err := rawRule.Parse() 47 | if err != nil { 48 | logging.Warnings.Println(err) 49 | continue 50 | } 51 | rule.Name = ruleName 52 | 53 | rules = append(rules, rule) 54 | } 55 | 56 | for _, proto := range config.Cfg.MatchProtocols { 57 | GlobalRules[proto] = append(GlobalRules[proto], rules.Filter(func(rule Rule) bool { return rule.Layer == proto })) 58 | } 59 | } 60 | 61 | for _, protocolRules := range GlobalRules { 62 | for _, ruleset := range protocolRules { 63 | total += uint(len(ruleset)) 64 | } 65 | } 66 | 67 | return total 68 | } 69 | 70 | // ParseRulesDir walks a directory and parses each of the rule file it encounters 71 | func ParseRulesDir(rulesDir string, skiplist []string) ([]RawRules, error) { 72 | var rawRules []RawRules 73 | err := filepath.Walk(rulesDir, 74 | func(path string, info os.FileInfo, err error) error { 75 | if err != nil { 76 | return err 77 | } 78 | if info.IsDir() { 79 | return nil 80 | } 81 | 82 | for _, skipped := range skiplist { 83 | if info.Name() == skipped { 84 | return nil 85 | } 86 | } 87 | 88 | if strings.HasSuffix(path, ".yml") { 89 | log.Println("Parsing", path) 90 | parsed, err := ParseYAMLRulesFile(path) 91 | if err != nil { 92 | return fmt.Errorf("failed to read YAML rule file [%s] : %s", path, err) 93 | } 94 | 95 | rawRules = append(rawRules, parsed) 96 | } else { 97 | return fmt.Errorf("invalid rule file (wanted : .yml) : %s", path) 98 | } 99 | 100 | return nil 101 | }) 102 | 103 | return rawRules, err 104 | } 105 | 106 | // ParseYAMLRulesFile is an helper that parses the given YAML file and return a set of raw rules as RawRules 107 | func ParseYAMLRulesFile(filepath string) (RawRules, error) { 108 | rawRules := RawRules{} 109 | rulesData, err := ioutil.ReadFile(filepath) 110 | if err != nil { 111 | return RawRules{}, err 112 | } 113 | 114 | if err := yaml.Unmarshal(rulesData, &rawRules); err != nil { 115 | return RawRules{}, err 116 | } 117 | 118 | return rawRules, nil 119 | } 120 | -------------------------------------------------------------------------------- /docs/docs/installation.md: -------------------------------------------------------------------------------- 1 | To install Melody, clone the repo or grab the latest : 2 | 3 | ```bash 4 | git clone https://github.com/ma111e/melody /opt/melody 5 | 6 | cd /opt/melody 7 | 8 | go build -ldflags="-s -w -extldflags=-static" -o melody 9 | sudo setcap cap_net_raw,cap_setpcap=ep ./melody 10 | 11 | echo "Ensure net-tools is installed in order to use the 'route' command" 12 | sudo apt install net-tools 13 | 14 | echo "> Setting listening interface to \"$(route | grep '^default' | grep -o '[^ ]*$')\"" 15 | sed -i "s/# listen.interface: \"lo\"/listen.interface: \"$(route | grep '^default' | grep -o '[^ ]*$')\"/g" /opt/melody/config.yml 16 | echo 17 | echo -n "Current listening interface :\n\t" 18 | grep listen.interface /opt/melody/config.yml 19 | 20 | echo "Current BPF is '$(cat /opt/melody/filter.bpf)'" 21 | 22 | # Don't forget to filter the noise by editing filter.bpf 23 | ``` 24 | 25 | ## Systemd 26 | 27 | You can tweak the provided service file to use Melody with `systemd`. 28 | 29 | The file can be found in `$melody/etc/melody.service`. 30 | 31 | !!! Example 32 | ```service 33 | [Unit] 34 | Description=Melody sensor 35 | After=network-online.target 36 | 37 | [Service] 38 | Type=simple 39 | WorkingDirectory=/opt/melody 40 | ExecStart=/opt/melody/melody 41 | Restart=on-failure 42 | User=melody 43 | Group=melody 44 | 45 | [Install] 46 | WantedBy=multi-user.target 47 | ``` 48 | 49 | Install it with : 50 | 51 | ```bash 52 | make service 53 | ``` 54 | 55 | or 56 | 57 | ```bash 58 | sudo ln -s "$(pwd)/etc/melody.service" /etc/systemd/system/melody.service 59 | sudo systemctl daemon-reload 60 | sudo systemctl enable melody 61 | sudo systemctl status melody 62 | ``` 63 | 64 | ## Supervisord 65 | 66 | You can also tweak the provided configuration file to use Melody with `supervisord`. 67 | 68 | The file can be found in `$melody/etc/melody.conf`. 69 | 70 | !!! Example 71 | ```init 72 | [program:melody] 73 | command=/opt/melody/melody 74 | directory=/opt/melody 75 | stdout_logfile=/opt/melody/melody.out 76 | stderr_logfile=/opt/melody/melody.err 77 | autostart=true 78 | autorestart=true 79 | stopasgroup=true 80 | killasgroup=true 81 | ``` 82 | 83 | Install it with : 84 | 85 | ```bash 86 | make supervisor 87 | ``` 88 | 89 | or 90 | 91 | ```bash 92 | sudo ln -s $(pwd)/etc/melody.conf /etc/supervisor/conf.d/melody.conf 93 | sudo supervisorctl reload 94 | sudo supervisorctl status all 95 | ``` 96 | 97 | ## Uninstall 98 | Uninstall Melody by removing the log directories (default `$melody/logs`), the service files (`/etc/systemd/system/melody.service` and `/etc/supervisor/conf.d/melody.conf`) and the Melody home directory (default `/opt/melody`). 99 | 100 | !!! Example 101 | Uncomment and use these command carefully. 102 | ```bash 103 | # sudo systemctl stop melody && sudo rm /etc/systemd/system/melody.service 104 | # sudo supervisorctl stop melody && sudo rm /etc/supervisor/conf.d/melody.conf 105 | # rm -rf /opt/melody 106 | ``` 107 | 108 | !!! Danger 109 | Keep in mind that removing Melody's home directory will most likely remove its logs directory as well. All logged data might be lost. 110 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs 2 | 3 | default: help 4 | 5 | ## certs : Create TLS certs used by the HTTPS dummy server in "var/https/certs" 6 | certs: 7 | -mkdir -p var/https/certs 8 | openssl req -x509 -subj "/C=AU/ST=Some-State/O=Internet Widgits Pty Ltd/CN=localhost" -newkey rsa:4096 -keyout var/https/certs/key.pem -out var/https/certs/cert.pem -days 3650 -nodes 9 | 10 | ## enable_all_rules : Enable all the rule files present in ./rules/rules-available/ 11 | enable_all_rules: 12 | ln -rs ./rules/rules-available/*.yml ./rules/rules-enabled/ 13 | 14 | ## docker_build : Build Docker image 15 | docker_build: 16 | docker build . -t melody 17 | 18 | ## docker_run : Run local Docker image 19 | docker_run: 20 | @echo "This will only run the locally built image." 21 | @echo "MELODY_CLI example : 'export MELODY_CLI=\"-s -i lo -F 'dst port 10080'\"'" 22 | @-mkdir logs 23 | docker run \ 24 | --net=host \ 25 | -e "MELODY_CLI=${MELODY_CLI}" \ 26 | --mount type=bind,source="$(shell pwd)/filter.bpf",target=/app/filter.bpf,readonly \ 27 | --mount type=bind,source="$(shell pwd)/config.yml",target=/app/config.yml,readonly \ 28 | --mount type=bind,source="$(shell pwd)/var",target=/app/var,readonly \ 29 | --mount type=bind,source="$(shell pwd)/rules",target=/app/rules,readonly \ 30 | --mount type=bind,source="$(shell pwd)/logs",target=/app/logs/ \ 31 | melody 32 | 33 | ## docker : Build and run Docker image 34 | docker: docker_build docker_run 35 | 36 | ## docs : Deploy documentation 37 | docs: 38 | cd docs/; mkdocs gh-deploy 39 | 40 | ## run_local_stdout : Start Melody and log to stdout 41 | run_local_stdout: build 42 | ./melody -s 43 | 44 | ## build : Build and set network capabilities to start Melody without elevated privileges 45 | build: 46 | go build -ldflags="-s -w" -o melody ./cmd/melody 47 | sudo setcap cap_net_raw,cap_setpcap=ep ./melody 48 | 49 | ## cap : Set network capabilities to start Melody without elevated privileges 50 | cap: 51 | sudo setcap cap_net_raw,cap_setpcap=ep ./melody 52 | 53 | ## setup : Init meloctl, install net-tools dependency and patch listen.interface config key with the current default interface 54 | setup: 55 | @echo "Ensure net-tools is installed in order to use the 'route' command" 56 | sudo apt install net-tools 57 | @echo "> Setting listening interface to \"$(shell route | grep '^default' | awk '{print $$8; exit}')\"" 58 | sed -i "s/# listen.interface: \"lo\"/listen.interface: \"$(shell route | grep '^default' | awk '{print $$8; exit}')\"/g" ./config.yml 59 | @echo 60 | @echo -n "Current listening interface :\n\t" 61 | @grep listen.interface ./config.yml 62 | 63 | @echo -n "Current BPF is :\n\t" 64 | @cat ./filter.bpf 65 | 66 | # Don't forget to filter the noise by editing ./filter.bpf 67 | 68 | ## supervisor : Create Melody's supervisor configuration 69 | supervisor: 70 | sudo ln -s $(shell pwd)/etc/melody.conf /etc/supervisor/conf.d/ 71 | sudo supervisorctl reload 72 | sudo supervisorctl status all 73 | 74 | ## service : Create Melody's systemd configuration and enable start at boot 75 | service: 76 | sudo ln -s $(shell pwd)/etc/melody.service /etc/systemd/system/melody.service 77 | sudo systemctl daemon-reload && sudo systemctl enable melody 78 | sudo systemctl stop melody && sudo systemctl status melody 79 | 80 | ## help : Show this help 81 | help: Makefile 82 | @printf "\n Melody helpers\n\n" 83 | @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' 84 | @printf "" 85 | -------------------------------------------------------------------------------- /cmd/melody/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "path/filepath" 9 | "syscall" 10 | 11 | "github.com/ma111e/melody/internal/engine" 12 | "github.com/ma111e/melody/internal/rules" 13 | "github.com/ma111e/melody/internal/sensor" 14 | 15 | "github.com/ma111e/melody/internal/logging" 16 | 17 | "github.com/google/shlex" 18 | "github.com/ma111e/melody/internal/config" 19 | "github.com/pborman/getopt/v2" 20 | ) 21 | 22 | var ( 23 | quitErrChan = make(chan error) 24 | shutdownChan = make(chan bool) 25 | loggerStoppedChan = make(chan bool) 26 | engineStoppedChan = make(chan bool) 27 | sensorStoppedChan = make(chan bool) 28 | quitSigChan = make(chan os.Signal, 1) 29 | ) 30 | 31 | func init() { 32 | signal.Notify(quitSigChan, syscall.SIGINT, syscall.SIGTERM) 33 | 34 | config.Cli.PcapFilePath = getopt.StringLong("pcap", 'r', "", "Replay a pcap file into the sensor") 35 | config.Cli.BPF = getopt.StringLong("filter", 'F', "", "Override the filter.bpf file with the specified filter") 36 | config.Cli.HomeDirPath = getopt.StringLong("home-dir", 'H', "", "Set the home directory") 37 | config.Cli.ConfigDirPath = getopt.StringLong("config-dir", 'C', "", "Set the config directory") 38 | config.Cli.ConfigFilePath = getopt.StringLong("config", 'c', "", "Path to the config file to load") 39 | config.Cli.BPFFilePath = getopt.StringLong("bpf", 'f', "", "Path to the BPF file") 40 | config.Cli.Interface = getopt.StringLong("interface", 'i', "", "Listen on the specified interface") 41 | config.Cli.Stdout = getopt.BoolLong("stdout", 's', "Output logged data to stdout instead") 42 | config.Cli.Dump = getopt.BoolLong("dump", 'd', "Output raw packet details instead of JSON") 43 | getopt.FlagLong(&config.Cli.FreeConfig, "option", 'o', "Override configuration keys") 44 | getopt.Parse() 45 | 46 | if line := os.Getenv("MELODY_CLI"); line != "" { 47 | chunks, err := shlex.Split(line) 48 | if err != nil { 49 | _, _ = fmt.Fprintf(os.Stderr, "Failed to chunk MELODY_CLI env variable") 50 | os.Exit(1) 51 | } 52 | 53 | chunks = append([]string{os.Args[0]}, chunks...) 54 | getopt.CommandLine.Parse(chunks) 55 | } 56 | 57 | config.Cfg = config.NewConfig() 58 | err := config.Cfg.Load() 59 | if err != nil { 60 | log.Println(err) 61 | os.Exit(1) 62 | } 63 | 64 | err = logging.InitLoggers() 65 | if err != nil { 66 | logging.Std.Println(err) 67 | os.Exit(1) 68 | } 69 | loaded := rules.LoadRulesDir(filepath.Join(config.Cfg.HomeDirPath, config.Cfg.RulesDir)) 70 | 71 | logging.Std.Printf("Loaded %d rules\n", loaded) 72 | logging.Std.Printf("Listing on interface %s\n", config.Cfg.Interface) 73 | } 74 | 75 | func main() { 76 | logging.Std.Println("Starting loggers") 77 | logging.Start(quitErrChan, shutdownChan, loggerStoppedChan) 78 | 79 | logging.Std.Println("Starting engine") 80 | engine.Start(quitErrChan, shutdownChan, engineStoppedChan) 81 | 82 | logging.Std.Println("Starting sensor") 83 | sensor.Start(quitErrChan, shutdownChan, sensorStoppedChan) 84 | 85 | logging.Std.Println("All systems started") 86 | 87 | select { 88 | case err := <-quitErrChan: 89 | logging.Errors.Println(err) 90 | close(shutdownChan) 91 | break 92 | case <-quitSigChan: 93 | close(shutdownChan) 94 | break 95 | case <-shutdownChan: 96 | logging.Std.Println("Shutting down") 97 | break 98 | } 99 | 100 | <-sensorStoppedChan 101 | logging.Std.Println("Sensor stopped") 102 | 103 | <-engineStoppedChan 104 | logging.Std.Println("Engine stopped") 105 | 106 | <-loggerStoppedChan 107 | logging.Std.Println("Logger stopped") 108 | 109 | logging.Std.Println("Reached shutdown") 110 | } 111 | -------------------------------------------------------------------------------- /internal/logging/loggers.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/ma111e/melody/internal/config" 10 | "github.com/natefinch/lumberjack" 11 | ) 12 | 13 | var ( 14 | // Sensor is the sensor logger 15 | Sensor *log.Logger 16 | 17 | // Errors is the errors logger 18 | Errors *log.Logger 19 | 20 | // Warnings is the warnings logger 21 | Warnings *log.Logger 22 | 23 | // Std is the standard logger 24 | Std *log.Logger 25 | ) 26 | 27 | // InitLoggers setup the logging environment and initialize the loggers according to the loaded configuration 28 | func InitLoggers() error { 29 | Std = log.New(os.Stderr, "", log.Ldate|log.Ltime) 30 | Sensor = log.New(nil, "", 0) 31 | Errors = log.New(nil, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) 32 | Warnings = log.New(nil, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile) 33 | 34 | sensorLogFilepath := filepath.Join(config.Cfg.LogsDir, config.Cfg.LogsSensorFile) 35 | errorsLogFilepath := filepath.Join(config.Cfg.LogsDir, config.Cfg.LogsErrorsFile) 36 | 37 | if !*config.Cli.Stdout { 38 | if _, err := os.Stat(config.Cfg.LogsDir); os.IsNotExist(err) { 39 | if err := os.Mkdir(config.Cfg.LogsDir, 0755); err != nil { 40 | return fmt.Errorf("failed to create log directory : %s", err) 41 | } 42 | } 43 | 44 | _, err := os.Stat(errorsLogFilepath) 45 | if err != nil { 46 | if os.IsNotExist(err) { 47 | if _, err := os.Create(errorsLogFilepath); err != nil { 48 | return fmt.Errorf("failed to create the error log file : %s", err) 49 | } 50 | } else { 51 | return fmt.Errorf("failed to create the error log file : %s", err) 52 | } 53 | } 54 | 55 | _, err = os.Stat(sensorLogFilepath) 56 | if err != nil { 57 | if os.IsNotExist(err) { 58 | if _, err := os.Create(sensorLogFilepath); err != nil { 59 | return fmt.Errorf("failed to create the error log file : %s", err) 60 | } 61 | } else { 62 | return fmt.Errorf("failed to create the error log file : %s", err) 63 | } 64 | } 65 | 66 | if config.Cfg.LogErrorsEnableRotation { 67 | Errors.SetOutput(&lumberjack.Logger{ 68 | Filename: filepath.Join(errorsLogFilepath), 69 | MaxSize: config.Cfg.LogsErrorsMaxSize, // megabytes 70 | MaxAge: config.Cfg.LogsErrorsMaxAge, //days 71 | Compress: config.Cfg.LogsErrorsCompressRotatedLogs, // enabled by default 72 | }) 73 | 74 | Warnings.SetOutput(&lumberjack.Logger{ 75 | Filename: filepath.Join(errorsLogFilepath), 76 | MaxSize: config.Cfg.LogsErrorsMaxSize, // megabytes 77 | MaxAge: config.Cfg.LogsErrorsMaxAge, // days 78 | Compress: config.Cfg.LogsErrorsCompressRotatedLogs, // enabled by default 79 | }) 80 | } else { 81 | errorsFile, err := os.OpenFile(errorsLogFilepath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 82 | if err != nil { 83 | return fmt.Errorf("failed to open the error log file : %s", err) 84 | } 85 | 86 | Errors.SetOutput(errorsFile) 87 | Warnings.SetOutput(errorsFile) 88 | } 89 | 90 | if config.Cfg.LogSensorEnableRotation { 91 | Sensor.SetOutput(&lumberjack.Logger{ 92 | Filename: filepath.Join(sensorLogFilepath), 93 | MaxSize: config.Cfg.LogsSensorMaxSize, // megabytes 94 | MaxAge: config.Cfg.LogsSensorMaxAge, // days 95 | Compress: config.Cfg.LogsSensorCompressRotatedLogs, // enabled by default 96 | }) 97 | } else { 98 | sensorFile, err := os.OpenFile(sensorLogFilepath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 99 | if err != nil { 100 | return fmt.Errorf("failed to open the sensor log file : %s", err) 101 | } 102 | 103 | Sensor.SetOutput(sensorFile) 104 | } 105 | } else { 106 | Errors.SetOutput(os.Stderr) 107 | Sensor.SetOutput(os.Stdout) 108 | Warnings.SetOutput(os.Stderr) 109 | } 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /internal/events/tcp.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/ma111e/melody/internal/events/helpers" 8 | 9 | "github.com/ma111e/melody/internal/logdata" 10 | 11 | "github.com/ma111e/melody/internal/sessions" 12 | 13 | "github.com/ma111e/melody/internal/config" 14 | 15 | "github.com/google/gopacket" 16 | "github.com/google/gopacket/layers" 17 | ) 18 | 19 | // TCPEvent describes the structure of an event generated by an ICPMv4 packet 20 | type TCPEvent struct { 21 | LogData logdata.TCPEventLog 22 | BaseEvent 23 | helpers.TCPLayer 24 | helpers.IPv4Layer 25 | helpers.IPv6Layer 26 | } 27 | 28 | // NewTCPEvent created a new TCPEvent from a packet 29 | func NewTCPEvent(packet gopacket.Packet, IPVersion uint) (*TCPEvent, error) { 30 | var ev = &TCPEvent{} 31 | ev.Kind = config.TCPKind 32 | ev.IPVersion = IPVersion 33 | 34 | ev.Session = sessions.SessionMap.GetUID(packet.TransportLayer().TransportFlow().String()) 35 | 36 | switch IPVersion { 37 | case 4: 38 | IPHeader, _ := packet.Layer(layers.LayerTypeIPv4).(*layers.IPv4) 39 | ev.IPv4Layer = helpers.IPv4Layer{Header: IPHeader} 40 | ev.SourceIP = IPHeader.SrcIP.String() 41 | case 6: 42 | IPHeader, _ := packet.Layer(layers.LayerTypeIPv6).(*layers.IPv6) 43 | ev.IPv6Layer = helpers.IPv6Layer{Header: IPHeader} 44 | ev.SourceIP = IPHeader.SrcIP.String() 45 | 46 | } 47 | 48 | TCPHeader, _ := packet.Layer(layers.LayerTypeTCP).(*layers.TCP) 49 | 50 | ev.Timestamp = packet.Metadata().Timestamp 51 | ev.TCPLayer = helpers.TCPLayer{Header: TCPHeader} 52 | ev.DestPort = uint16(TCPHeader.DstPort) 53 | 54 | ev.Additional = make(map[string]string) 55 | ev.Tags = make(Tags) 56 | 57 | return ev, nil 58 | } 59 | 60 | // ToLog parses the event structure and generate an EventLog almost ready to be sent to the logging file 61 | func (ev TCPEvent) ToLog() EventLog { 62 | var tcpFlagsStr []string 63 | //var ipFlagsStr []string 64 | 65 | ev.LogData = logdata.TCPEventLog{} 66 | ev.LogData.Timestamp = ev.Timestamp.Format(time.RFC3339Nano) 67 | //ev.LogData.Type = ev.Kind 68 | //ev.LogData.SourceIP = ev.SourceIP 69 | //ev.LogData.DestPort = ev.DestPort 70 | //ev.LogData.Session = ev.Session 71 | // 72 | //if len(ev.Tags) == 0 { 73 | // ev.LogData.Tags = make(map[string][]string) 74 | //} else { 75 | // ev.LogData.Tags = ev.Tags 76 | //} 77 | // 78 | ev.LogData.Init(ev.BaseEvent) 79 | 80 | switch ev.IPVersion { 81 | case 4: 82 | ev.LogData.IP = logdata.NewIPv4LogData(ev.IPv4Layer) 83 | case 6: 84 | ev.LogData.IP = logdata.NewIPv6LogData(ev.IPv6Layer) 85 | } 86 | 87 | ev.LogData.TCP = logdata.TCPLogData{ 88 | Window: ev.TCPLayer.Header.Window, 89 | Seq: ev.TCPLayer.Header.Seq, 90 | Ack: ev.TCPLayer.Header.Ack, 91 | DataOffset: ev.TCPLayer.Header.DataOffset, 92 | Urgent: ev.TCPLayer.Header.Urgent, 93 | Payload: logdata.NewPayloadLogData(ev.TCPLayer.Header.Payload, config.Cfg.MaxTCPDataSize), 94 | } 95 | 96 | if ev.TCPLayer.Header.FIN { 97 | tcpFlagsStr = append(tcpFlagsStr, "F") 98 | } 99 | if ev.TCPLayer.Header.SYN { 100 | tcpFlagsStr = append(tcpFlagsStr, "S") 101 | } 102 | if ev.TCPLayer.Header.RST { 103 | tcpFlagsStr = append(tcpFlagsStr, "R") 104 | } 105 | if ev.TCPLayer.Header.PSH { 106 | tcpFlagsStr = append(tcpFlagsStr, "P") 107 | } 108 | if ev.TCPLayer.Header.ACK { 109 | tcpFlagsStr = append(tcpFlagsStr, "A") 110 | } 111 | if ev.TCPLayer.Header.URG { 112 | tcpFlagsStr = append(tcpFlagsStr, "U") 113 | } 114 | if ev.TCPLayer.Header.ECE { 115 | tcpFlagsStr = append(tcpFlagsStr, "E") 116 | } 117 | if ev.TCPLayer.Header.CWR { 118 | tcpFlagsStr = append(tcpFlagsStr, "C") 119 | } 120 | if ev.TCPLayer.Header.NS { 121 | tcpFlagsStr = append(tcpFlagsStr, "N") 122 | } 123 | 124 | ev.LogData.TCP.Flags = strings.Join(tcpFlagsStr, "") 125 | ev.LogData.Additional = ev.Additional 126 | 127 | return ev.LogData 128 | } 129 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ## Melody configuration file 3 | ## This file shows all supported configuration keys with their default values 4 | 5 | ## 6 | ## Logs 7 | ## 8 | 9 | ## Directory where the logs will be written 10 | # logs.dir: "logs/" 11 | 12 | ## By default, the logs are compressed (gzip) and rotated after reaching 1G, then discarded after 30 days 13 | ## You can disable the log rotation by setting "logs.sensor.rotation.enable: false" 14 | ## The minimum rotation.max_size is 1 megabyte, anything below will disable the rotation based on file size 15 | ## Supported notation : "M, G, MB, GB" (case insensitive) 16 | ## Raw number are parsed as bytes ("1240000" == "1M") 17 | ## Warning : anything below "1240000" (raw) will be parsed as 0 megabytes 18 | ## Using raw values is discouraged 19 | ## The minimum rotation.max_age is 1 day, anything below will disable the rotation based on file age 20 | 21 | ## Sensor logs 22 | # logs.sensor.file: melody.ndjson 23 | # logs.sensor.rotation.max_size: 1G 24 | # logs.sensor.rotation.max_age: 30 # days 25 | # logs.sensor.rotation.enable: true 26 | # logs.sensor.rotation.compress: true 27 | 28 | ## Error logs 29 | # logs.errors.file: melody_err.log 30 | # logs.errors.rotation.max_size: 1G 31 | # logs.errors.rotation.max_age: 30 # days 32 | # logs.errors.rotation.enable: true 33 | # logs.errors.rotation.compress: true 34 | 35 | ## Truncate the logged data in the payload/body after reaching the size limit 36 | ## In such case, the log has the "truncated" field set as "true" 37 | # logs.http.post.max_size: "10kb" 38 | # logs.tcp.payload.max_size: "10kb" 39 | # logs.udp.payload.max_size: "10kb" 40 | # logs.icmpv4.payload.max_size: "10KB" 41 | # logs.icmpv6.payload.max_size: "10KB" 42 | 43 | ## 44 | ## Rules 45 | ## 46 | 47 | ## The directory where the active rules lives 48 | # rules.dir: "rules/rules-enabled" 49 | 50 | ## Whitelist the protocols on which you want to apply rules 51 | ## Please note that the filtered protocols will still be logged 52 | ## Available values : all, http, icmp, tcp, udp, icmpv4, icmpv6 53 | # rules.match.protocols: ["all"] 54 | 55 | ## 56 | ## Listen 57 | ## 58 | 59 | ## The interface on which Melody is listening 60 | ## Listen by default on localhost 61 | ## You want to change it to your internet facing interface (wlp3s0, ens3, enp0s25, eth0...) 62 | # listen.interface: "lo" 63 | 64 | ## 65 | ## Filters 66 | ## 67 | 68 | ## Filter out specific protocols. 69 | ## Available protocols are : udp, tcp, http, https, icmp, icmpv4 (ipv4 only), icmpv6 (ipv6 only) 70 | # filters.ipv4.proto: [] 71 | # filters.ipv6.proto: [] 72 | 73 | ## Filter packets according to the BPF syntax (https://biot.com/capstats/bpf.html) 74 | ## The filter must start with "inbound" to filter outgoing packets 75 | # filters.bpf.file: "filter.bpf" 76 | 77 | ## 78 | ## Dummy server 79 | ## 80 | 81 | ## Configure the dummy server spawned by Melody 82 | ## A listening HTTP server is needed to capture full HTTP requests 83 | ## Use iptables to redirect all traffic to the listening port in order to catch HTTP noise on all ports 84 | # server.http.enable: true 85 | # server.http.port: 10080 86 | # server.http.dir: "var/http/serve" 87 | 88 | ## The missing_status_code is the default HTTP status code sent back if the file is not found 89 | ## A default status code of 200 can be useful to generate false positive in badly configured scanners 90 | # server.http.response.missing_status_code: 200 91 | 92 | ## Add or override specific response headers 93 | # server.http.response.headers: 94 | # Server: "Apache" 95 | 96 | ## Same for the HTTPS server 97 | ## Valid TLS certificates are needed 98 | ## They can be generated using the Makefile (make certs) 99 | # server.https.enable: true 100 | # server.https.port: 10443 101 | # server.https.dir: "var/https/serve" 102 | # server.https.crt: "var/https/certs/cert.pem" 103 | # server.https.key: "var/https/certs/key.pem" 104 | # server.https.response.missing_status_code: 200 105 | # server.https.response.headers: 106 | # Server: "Apache" 107 | -------------------------------------------------------------------------------- /internal/events/udp.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/ma111e/melody/internal/events/helpers" 8 | "github.com/ma111e/melody/internal/logdata" 9 | 10 | "github.com/ma111e/melody/internal/config" 11 | 12 | "github.com/ma111e/melody/internal/sessions" 13 | 14 | "github.com/google/gopacket" 15 | "github.com/google/gopacket/layers" 16 | ) 17 | 18 | // UDPEvent describes the structure of an event generated by an ICPMv4 packet 19 | type UDPEvent struct { 20 | LogData logdata.UDPEventLog 21 | BaseEvent 22 | helpers.UDPLayer 23 | helpers.IPv4Layer 24 | helpers.IPv6Layer 25 | } 26 | 27 | // NewUDPEvent created a new UDPEvent from a packet 28 | func NewUDPEvent(packet gopacket.Packet, IPVersion uint) (*UDPEvent, error) { 29 | var ev = &UDPEvent{} 30 | ev.Kind = config.UDPKind 31 | ev.IPVersion = IPVersion 32 | 33 | ev.Timestamp = packet.Metadata().Timestamp 34 | ev.Session = sessions.SessionMap.GetUID(packet.TransportLayer().TransportFlow().String()) 35 | 36 | switch IPVersion { 37 | case 4: 38 | IPHeader, _ := packet.Layer(layers.LayerTypeIPv4).(*layers.IPv4) 39 | ev.IPv4Layer = helpers.IPv4Layer{Header: IPHeader} 40 | ev.SourceIP = IPHeader.SrcIP.String() 41 | case 6: 42 | IPHeader, _ := packet.Layer(layers.LayerTypeIPv6).(*layers.IPv6) 43 | ev.IPv6Layer = helpers.IPv6Layer{Header: IPHeader} 44 | ev.SourceIP = IPHeader.SrcIP.String() 45 | } 46 | 47 | UDPHeader, _ := packet.Layer(layers.LayerTypeUDP).(*layers.UDP) 48 | ev.UDPLayer = helpers.UDPLayer{Header: UDPHeader} 49 | ev.DestPort = uint16(UDPHeader.DstPort) 50 | 51 | ev.Additional = make(map[string]string) 52 | ev.Tags = make(Tags) 53 | 54 | return ev, nil 55 | } 56 | 57 | // ToLog parses the event structure and generate an EventLog almost ready to be sent to the logging file 58 | func (ev UDPEvent) ToLog() EventLog { 59 | var ipFlagsStr []string 60 | 61 | ev.LogData = logdata.UDPEventLog{} 62 | ev.LogData.Timestamp = ev.Timestamp.Format(time.RFC3339Nano) 63 | //ev.LogData.Type = ev.Kind 64 | //ev.LogData.SourceIP = ev.SourceIP 65 | //ev.LogData.DestPort = ev.DestPort 66 | //ev.LogData.Session = ev.Session 67 | // 68 | //if len(ev.Tags) == 0 { 69 | // ev.LogData.Tags = make(map[string][]string) 70 | //} else { 71 | // ev.LogData.Tags = ev.Tags 72 | //} 73 | 74 | ev.LogData.Init(ev.BaseEvent) 75 | 76 | switch ev.IPVersion { 77 | case 4: 78 | if ev.IPv4Layer.Header.Flags&layers.IPv4EvilBit != 0 { 79 | ipFlagsStr = append(ipFlagsStr, "EV") 80 | } 81 | if ev.IPv4Layer.Header.Flags&layers.IPv4DontFragment != 0 { 82 | ipFlagsStr = append(ipFlagsStr, "DF") 83 | } 84 | if ev.IPv4Layer.Header.Flags&layers.IPv4MoreFragments != 0 { 85 | ipFlagsStr = append(ipFlagsStr, "MF") 86 | } 87 | 88 | ev.LogData.IP = logdata.IPv4LogData{ 89 | Version: ev.IPv4Layer.Header.Version, 90 | IHL: ev.IPv4Layer.Header.IHL, 91 | TOS: ev.IPv4Layer.Header.TOS, 92 | Length: ev.IPv4Layer.Header.Length, 93 | ID: ev.IPv4Layer.Header.Id, 94 | FragOffset: ev.IPv4Layer.Header.FragOffset, 95 | TTL: ev.IPv4Layer.Header.TTL, 96 | Protocol: ev.IPv4Layer.Header.Protocol, 97 | Fragbits: strings.Join(ipFlagsStr, ""), 98 | } 99 | 100 | case 6: 101 | ev.LogData.IP = logdata.IPv6LogData{ 102 | Version: ev.IPv6Layer.Header.Version, 103 | Length: ev.IPv6Layer.Header.Length, 104 | NextHeader: ev.IPv6Layer.Header.NextHeader, 105 | NextHeaderName: ev.IPv6Layer.Header.NextHeader.String(), 106 | TrafficClass: ev.IPv6Layer.Header.TrafficClass, 107 | FlowLabel: ev.IPv6Layer.Header.FlowLabel, 108 | HopLimit: ev.IPv6Layer.Header.HopLimit, 109 | } 110 | } 111 | 112 | ev.LogData.UDP = logdata.UDPLogData{ 113 | Payload: logdata.NewPayloadLogData(ev.UDPLayer.Header.Payload, config.Cfg.MaxUDPDataSize), 114 | Length: ev.UDPLayer.Header.Length, 115 | Checksum: ev.UDPLayer.Header.Checksum, 116 | } 117 | 118 | ev.LogData.Additional = ev.Additional 119 | 120 | return ev.LogData 121 | } 122 | -------------------------------------------------------------------------------- /internal/rules/testing.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/google/gopacket" 6 | "github.com/google/gopacket/layers" 7 | "github.com/google/gopacket/pcap" 8 | "github.com/ma111e/melody/internal/events" 9 | "os" 10 | ) 11 | 12 | var ( 13 | assetsBasePath string 14 | ) 15 | 16 | func init() { 17 | assetsBasePath = "test_resources" 18 | } 19 | 20 | // ReadRawTCPPacketsFromPcap is an helper that reads raw TCP packets from the specified pcap file 21 | func ReadRawTCPPacketsFromPcap(pcapfile string) ([]gopacket.Packet, error) { 22 | var packets []gopacket.Packet 23 | _, rawPackets, err := ReadPacketsFromPcap(pcapfile, layers.IPProtocolTCP, true) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | for _, val := range rawPackets { 29 | packets = append(packets, val.(gopacket.Packet)) 30 | } 31 | return packets, nil 32 | } 33 | 34 | // ReadPacketsFromPcap is an helper that reads packets from the specified pcap file and returns them as an array of Event 35 | func ReadPacketsFromPcap(pcapfile string, filter layers.IPProtocol, raw bool) ([]events.Event, []gopacket.Packet, error) { 36 | var Events []events.Event 37 | var rawPackets []gopacket.Packet 38 | var ret []events.Event 39 | var rawRet []gopacket.Packet 40 | pcapfilePath := MakeAssetFullPath(pcapfile) 41 | 42 | f, err := os.Open(pcapfilePath) 43 | if err != nil { 44 | return []events.Event{}, []gopacket.Packet{}, err 45 | } 46 | handle, err := pcap.OpenOfflineFile(f) 47 | if err != nil { 48 | return []events.Event{}, []gopacket.Packet{}, err 49 | } 50 | 51 | src := gopacket.NewPacketSource(handle, handle.LinkType()) 52 | in := src.Packets() 53 | 54 | loop: 55 | for { 56 | packet := <-in 57 | if packet == nil { 58 | break loop 59 | } 60 | 61 | if _, ok := packet.NetworkLayer().(*layers.IPv4); ok { 62 | if packet.NetworkLayer().(*layers.IPv4).Protocol == filter { 63 | if raw { 64 | rawPackets = append(rawPackets, packet) 65 | } else { 66 | switch filter { 67 | case layers.IPProtocolICMPv4: 68 | ev, err := events.NewICMPv4Event(packet) 69 | if err != nil { 70 | return []events.Event{}, []gopacket.Packet{}, err 71 | } 72 | 73 | Events = append(Events, ev) 74 | 75 | case layers.IPProtocolUDP: 76 | ev, err := events.NewUDPEvent(packet, 4) 77 | if err != nil { 78 | return []events.Event{}, []gopacket.Packet{}, err 79 | } 80 | 81 | Events = append(Events, ev) 82 | 83 | case layers.IPProtocolTCP: 84 | ev, err := events.NewTCPEvent(packet, 4) 85 | if err != nil { 86 | return []events.Event{}, []gopacket.Packet{}, err 87 | } 88 | 89 | Events = append(Events, ev) 90 | 91 | default: 92 | continue loop 93 | } 94 | } 95 | } 96 | } else if _, ok := packet.NetworkLayer().(*layers.IPv6); ok { 97 | if packet.NetworkLayer().(*layers.IPv6).NextHeader == filter { 98 | switch filter { 99 | case layers.IPProtocolICMPv6: 100 | ev, err := events.NewICMPv6Event(packet) 101 | if err != nil { 102 | return []events.Event{}, []gopacket.Packet{}, err 103 | } 104 | 105 | Events = append(Events, ev) 106 | 107 | default: 108 | continue loop 109 | } 110 | } 111 | } 112 | } 113 | 114 | // I'm so lazy 115 | if raw { 116 | rawRet = make([]gopacket.Packet, len(rawPackets)) 117 | copy(rawRet, rawPackets) 118 | } 119 | 120 | ret = make([]events.Event, len(Events)) 121 | copy(ret, Events) 122 | 123 | return ret, rawRet, nil 124 | } 125 | 126 | // LoadRuleFile is an helper that parses the rule file at the given path and returns a rule set 127 | func LoadRuleFile(rulefile string) (map[string]Rule, error) { 128 | ruleset := make(map[string]Rule) 129 | rulefilePath := MakeAssetFullPath(rulefile) 130 | rawRules, err := ParseYAMLRulesFile(rulefilePath) 131 | if err != nil { 132 | return map[string]Rule{}, err 133 | } 134 | for name, rawRule := range rawRules { 135 | ruleset[name], err = rawRule.Parse() 136 | if err != nil { 137 | return ruleset, err 138 | } 139 | } 140 | 141 | return ruleset, nil 142 | } 143 | 144 | // MakeAssetFullPath is an helper that returns the path to the tests resources as defined by the assetsBasePath variable 145 | func MakeAssetFullPath(path string) string { 146 | return fmt.Sprintf("%s/%s", assetsBasePath, path) 147 | } 148 | -------------------------------------------------------------------------------- /internal/rules/test_resources/logic_flow_rules.yml: -------------------------------------------------------------------------------- 1 | # Default matching mode at rule level is all 2 | 3 | ok_any_sub: 4 | layer: tcp 5 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 6 | match: 7 | tcp.payload: 8 | is: 9 | - "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/7.58.0\r\nAccept: */*\r\n\r\n" 10 | contains: 11 | - "nonexistent" 12 | any: true 13 | 14 | nok_any_sub: 15 | layer: tcp 16 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 17 | match: 18 | tcp.payload: 19 | contains: 20 | - "nonexistent" 21 | - "neither" 22 | any: true 23 | 24 | ok_all_sub: 25 | layer: tcp 26 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 27 | match: 28 | tcp.payload: 29 | startswith: 30 | - "GET" 31 | contains: 32 | - "localhost:8080" 33 | endswith: 34 | - "\n\r\n" 35 | 36 | nok_all_sub: 37 | layer: tcp 38 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 39 | match: 40 | tcp.payload: 41 | startswith: 42 | - "GET" 43 | contains: 44 | - "localhost:8080" 45 | endswith: 46 | - "wrong end" 47 | 48 | ok_all_upper: 49 | layer: tcp 50 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 51 | match: 52 | tcp.payload: 53 | startswith: 54 | - "GET" 55 | any: true 56 | tcp.flags: 57 | - "AP" 58 | 59 | nok_all_upper: 60 | layer: tcp 61 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 62 | match: 63 | tcp.payload: 64 | startswith: 65 | - "GET" 66 | any: true 67 | tcp.flags: 68 | - "0" 69 | 70 | ok_any_upper: 71 | layer: tcp 72 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 73 | match: 74 | tcp.payload: 75 | startswith: 76 | - "GET" 77 | tcp.flags: 78 | - "0" 79 | any: true 80 | 81 | nok_any_upper: 82 | layer: tcp 83 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 84 | match: 85 | any: true 86 | tcp.payload: 87 | startswith: 88 | - "nonexistent" 89 | any: true 90 | tcp.flags: 91 | - "0" 92 | 93 | ok_any_upper_mixed: 94 | layer: tcp 95 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 96 | match: 97 | any: true 98 | tcp.payload: 99 | startswith: 100 | - "GET" 101 | contains: 102 | - "nonexistent" 103 | tcp.flags: 104 | - "PA" 105 | 106 | nok_any_upper_mixed: 107 | layer: tcp 108 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 109 | match: 110 | any: true 111 | tcp.payload: 112 | startswith: 113 | - "GET" 114 | contains: 115 | - "nonexistent" 116 | tcp.flags: 117 | - "0" 118 | 119 | ok_all_upper_mixed: 120 | layer: tcp 121 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 122 | match: 123 | tcp.payload: 124 | startswith: 125 | - "GET" 126 | contains: 127 | - "localhost" 128 | tcp.flags: 129 | - "PA" 130 | 131 | nok_all_upper_mixed: 132 | layer: tcp 133 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 134 | match: 135 | tcp.payload: 136 | startswith: 137 | - "GET" 138 | contains: 139 | - "nonexistent" 140 | tcp.flags: 141 | - "PA" 142 | 143 | ok_all_all_full_mixed: 144 | layer: tcp 145 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 146 | match: 147 | tcp.payload: 148 | startswith: 149 | - "GET" 150 | contains: 151 | - "localhost" 152 | - "curl" 153 | tcp.flags: 154 | - "PA" 155 | 156 | nok_all_all_full_mixed: 157 | layer: tcp 158 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 159 | match: 160 | tcp.payload: 161 | startswith: 162 | - "GET" 163 | contains: 164 | - "localhost" 165 | - "nonexistent" 166 | tcp.flags: 167 | - "PA" 168 | 169 | ok_all_any_full_mixed: 170 | layer: tcp 171 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 172 | match: 173 | any: true 174 | tcp.payload: 175 | startswith: 176 | - "GET" 177 | contains: 178 | - "localhost" 179 | - "curl" 180 | tcp.flags: 181 | - "0" 182 | 183 | nok_all_any_full_mixed: 184 | layer: tcp 185 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 186 | match: 187 | any: true 188 | tcp.payload: 189 | startswith: 190 | - "GET" 191 | contains: 192 | - "neither" 193 | - "nonexistent" 194 | tcp.flags: 195 | - "0" 196 | 197 | ok_any_any_full_mixed: 198 | layer: tcp 199 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 200 | match: 201 | any: true 202 | tcp.payload: 203 | startswith: 204 | - "nonexistent" 205 | contains: 206 | - "localhost" 207 | - "curl" 208 | any: true 209 | tcp.flags: 210 | - "0" 211 | 212 | nok_any_any_full_mixed: 213 | layer: tcp 214 | id: 93e28e66-b3e3-4a28-8b27-be50269c84a0 215 | match: 216 | any: true 217 | tcp.payload: 218 | startswith: 219 | - "nonexistent" 220 | contains: 221 | - "neither" 222 | - "nonexistent" 223 | any: true 224 | tcp.flags: 225 | - "0" 226 | -------------------------------------------------------------------------------- /internal/meloctl/prompt/prompt.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/fatih/structtag" 11 | 12 | "github.com/manifoldco/promptui" 13 | ) 14 | 15 | // AskAll prompts the user for each property with a 'pretty' tag 16 | func AskAll(what interface{}, target *map[string]string) error { 17 | for i := 0; i < reflect.TypeOf(what).NumField(); i++ { 18 | var choices []string 19 | 20 | tag := reflect.TypeOf(what).Field(i).Tag 21 | parsedTag, err := structtag.Parse(string(tag)) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | yamlTag, err := parsedTag.Get("yaml") 27 | if err != nil { 28 | return err 29 | } 30 | 31 | // iterate over all tags 32 | prettyTag, _ := parsedTag.Get("pretty") 33 | 34 | if prettyTag == nil { 35 | continue 36 | } 37 | 38 | choicesTag, _ := parsedTag.Get("choices") 39 | if choicesTag != nil { 40 | choices = strings.Split(choicesTag.Value(), ",") 41 | } 42 | 43 | validateModeTag, _ := parsedTag.Get("validate") 44 | hintTag, _ := parsedTag.Get("hint") 45 | 46 | prompt := prettyTag.Value() 47 | if hintTag != nil { 48 | prompt = fmt.Sprintf("%s (%s)", prettyTag, hintTag.Value()) 49 | } 50 | 51 | defaultVal := (*target)[yamlTag.Value()] 52 | 53 | if len(choices) > 0 { 54 | _, res, err := askSelect(prompt, choices, defaultVal) 55 | if err != nil { 56 | //log.Fatalln(err.Error()) 57 | return err 58 | } 59 | (*target)[yamlTag.Value()] = res 60 | continue 61 | } 62 | 63 | if validateModeTag != nil { 64 | validateFn, ok := validatorsMap[validateModeTag.Value()] 65 | if !ok { 66 | //log.Fatalln(fmt.Errorf("unknown validator : %s", validateModeTag.Value())) 67 | return fmt.Errorf("unknown validator : %s", validateModeTag.Value()) 68 | } 69 | 70 | res, err := askString(prompt, defaultVal, &validateFn) 71 | if err != nil { 72 | //log.Fatalln(err.Error()) 73 | return err 74 | } 75 | (*target)[yamlTag.Value()] = res 76 | continue 77 | } 78 | 79 | res, err := askString(prompt, defaultVal, nil) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | (*target)[yamlTag.Value()] = res 85 | } 86 | 87 | return nil 88 | } 89 | 90 | //func askStringArray(what string, defaultVal string, validateFn *func(candidate string) error) ([]string, error) { 91 | // var answers []string 92 | // 93 | // answer, err := askString(what, defaultVal, validateFn) 94 | // if err != nil { 95 | // return answers, err 96 | // } 97 | // answers = append(answers, answer) 98 | // 99 | // for { 100 | // ok, err := AskConfirmation("Add another one ?", true) 101 | // if err != nil { 102 | // return answers, err 103 | // } 104 | // if !ok { 105 | // break 106 | // } 107 | // 108 | // answer, err := askString(what, defaultVal, validateFn) 109 | // if err != nil { 110 | // return answers, err 111 | // } 112 | // answers = append(answers, answer) 113 | // } 114 | // 115 | // return answers, nil 116 | //} 117 | 118 | func askSelect(what string, choices []string, defaultVal string) (int, string, error) { 119 | var found bool 120 | var val string 121 | var idx int 122 | var cursorPos int 123 | 124 | for idx, val = range choices { 125 | if defaultVal == val { 126 | found = true 127 | break 128 | } 129 | } 130 | 131 | if found { 132 | cursorPos = idx 133 | } 134 | 135 | prompt := promptui.Select{ 136 | Label: what, 137 | Items: choices, 138 | CursorPos: cursorPos, 139 | } 140 | 141 | return prompt.Run() 142 | } 143 | 144 | // 145 | //func askString(what string, defaultVal string) (string, error) { 146 | // prompt := promptui.Prompt{ 147 | // Label: what, 148 | // Default: defaultVal, 149 | // AllowEdit: true, 150 | // } 151 | // 152 | // return prompt.Run() 153 | //} 154 | 155 | // 156 | //func askStringArray(what string, defaultVal []string) (string, error) { 157 | // 158 | // 159 | // prompt := promptui.Prompt{ 160 | // Label: what, 161 | // Default: defaultVal, 162 | // AllowEdit: true, 163 | // } 164 | // 165 | // return prompt.Run() 166 | //} 167 | 168 | func askString(what string, defaultVal string, validateFn *func(candidate string) error) (string, error) { 169 | var res string 170 | var err error 171 | 172 | prompt := promptui.Prompt{ 173 | Label: what, 174 | Default: defaultVal, 175 | AllowEdit: true, 176 | } 177 | 178 | if validateFn != nil { 179 | prompt.Validate = *validateFn 180 | } 181 | 182 | res, err = prompt.Run() 183 | if err != nil { 184 | return res, err 185 | } 186 | 187 | return res, err 188 | } 189 | 190 | // AskConfirmation prompts the user for confirmation 191 | func AskConfirmation(what string, defaultChoice bool) (bool, error) { 192 | reader := bufio.NewReader(os.Stdin) 193 | loop := true 194 | 195 | defaultIndicator := "[Y/n]" 196 | choice := defaultChoice 197 | if !defaultChoice { 198 | defaultIndicator = "[y/N]" 199 | } 200 | 201 | for loop { 202 | fmt.Printf("%s %s : ", what, defaultIndicator) 203 | res, err := reader.ReadString('\n') 204 | if err != nil { 205 | return false, err 206 | } 207 | 208 | res = strings.ToLower(strings.TrimSpace(res)) 209 | 210 | if res == "y" || res == "yes" { 211 | choice = true 212 | loop = false 213 | } else if res == "n" || res == "no" { 214 | choice = false 215 | loop = false 216 | } else if res == "" { 217 | loop = false 218 | } 219 | } 220 | return choice, nil 221 | } 222 | -------------------------------------------------------------------------------- /docs/docs/meloctl.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Meloctl is a helper program that streamlines the use of Melody and its ecosystem by providing features such as validation checks for Melody's configuration and rule files. 4 | 5 | ``` 6 | Melody helper 7 | 8 | Usage: 9 | meloctl [command] 10 | 11 | Available Commands: 12 | config Interact with a Melody config file 13 | get Get a Meloctl config value by name 14 | help Help about any command 15 | init Create Meloctl config 16 | rule Handle Melody rule files 17 | set Set a Meloctl config value by name 18 | 19 | Flags: 20 | -h, --help help for meloctl 21 | 22 | Use "meloctl [command] --help" for more information about a command. 23 | ``` 24 | 25 | ## Initilization 26 | 27 | For better user experience, you'll need to store basic information such as Melody's home dir in a configuration file before starting. 28 | 29 | To do so, run `meloctl init` to start the interactive prompt. 30 | 31 | ## Features 32 | ### config 33 | #### check 34 | 35 | Check a rule file or a directory containing multiple files 36 | 37 | Example : 38 | 39 | ``` 40 | $ ./meloctl config check ./config.yml 41 | ✅ [./config.yml]: OK 42 | ``` 43 | 44 | ### rule 45 | #### check 46 | 47 | Validate the YAML syntax and look for unknown properties or fields. 48 | 49 | ``` 50 | $ ./meloctl rule check ./rules/rules-available 51 | ❌ [rules/rules-available/cms.yml]: unknown property 'http.uri|nonexistent' 52 | ✅ [rules/rules-available/microsoft.yml]: OK 53 | ❌ [rules/rules-available/nas.yml]: yaml: line 2: did not find expected key 54 | ✅ [rules/rules-available/rdp.yml]: OK 55 | ✅ [rules/rules-available/router.yml]: OK 56 | ✅ [rules/rules-available/server.yml]: OK 57 | ✅ [rules/rules-available/vpn.yml]: OK 58 | ✅ [rules/rules-available/web.yml]: OK 59 | ``` 60 | 61 | #### init 62 | 63 | Bootstrap a rule with an automatically pre-filled template. 64 | 65 | Usage : 66 | 67 | ``` 68 | Usage: 69 | meloctl rule init [flags] 70 | 71 | Flags: 72 | -a, --author string Author field for new rule (default "Changeme") 73 | -d, --description string Description field for new rule 74 | -f, --force Do not ask permission to overwrite if a rule already defined 75 | -h, --help help for init 76 | -i, --interactive Ask for each parameter for the new rule 77 | -l, --layer string Layer field for new rule (default "http") 78 | -n, --name string Name field for new rule (default "Changeme") 79 | -r, --references stringArray References fields new rule 80 | -s, --status string Status field for new rule (default "experimental") 81 | -t, --tags stringToString Tags fields for new rule (default []) 82 | 83 | ``` 84 | 85 | Default template : 86 | 87 | ``` 88 | $ ./meloctl rule init demo.yml 89 | Writing : 90 | Changeme: 91 | layer: http 92 | meta: 93 | version: "1.0" 94 | id: 6ddbbfaa-72c1-41d8-bb78-34111286a8d2 95 | author: Changeme 96 | status: experimental 97 | created: 2021/04/19 98 | modified: 2021/04/19 99 | description: "" 100 | match: 101 | http.uri: 102 | contains|nocase: 103 | - "" 104 | endswith: 105 | - "" 106 | is|regex: 107 | - "" 108 | startswith|any: 109 | - "" 110 | references: [] 111 | tags: {} 112 | 113 | ✅ [/opt/melody/demo.yml]: Rule file created 114 | ``` 115 | 116 | You can use the interactive mode (`-i`), give specific values, or even mix both : 117 | 118 | ``` 119 | $ ./meloctl rule init demo.yml -i --name "Demo rule" --status testing --tag "purpose=demo" --tag "teapot.state=empty" 120 | Use the arrow keys to navigate: ↓ ↑ → ← 121 | ? Layer: 122 | ▸ http 123 | icmp 124 | tcp 125 | udp 126 | ip 127 | ✔ http 128 | ✔ Version: 1.0 129 | Author: Changeme 130 | Use the arrow keys to navigate: ↓ ↑ → ← 131 | ? Status: 132 | stable 133 | experimental 134 | ▸ testing 135 | ✔ testing 136 | Created: 2021/04/19 137 | Modified: 2021/04/19 138 | ✔ Description: This is a demo rule 139 | 140 | Writing : 141 | Demo rule: 142 | layer: http 143 | meta: 144 | version: "1.0" 145 | id: 8738f81c-35d4-45f0-b553-c9d9c8993e4c 146 | author: Changeme 147 | status: testing 148 | created: 2021/04/19 149 | modified: 2021/04/19 150 | description: "" 151 | match: 152 | http.uri: 153 | contains|nocase: 154 | - "" 155 | endswith: 156 | - "" 157 | is|regex: 158 | - "" 159 | startswith|any: 160 | - "" 161 | references: [] 162 | tags: 163 | purpose: demo 164 | teapot.state: empty 165 | 166 | ✅ [/opt/melody/demo.yml]: Rule file created 167 | ``` 168 | 169 | #### add 170 | 171 | This command will do the same as `init`, except the new rule will be appended to the specified file. 172 | 173 | ### init 174 | 175 | ``` 176 | $ ./meloctl init 177 | Melody home directory: /opt/melody 178 | ✅ [~/.config/meloctl/meloctl.yml] Meloctl has been initialized 179 | ``` 180 | 181 | ### get 182 | 183 | ``` 184 | $ ./meloctl get melody.home 185 | melody.home => /opt/melody 186 | ``` 187 | 188 | ### set 189 | 190 | ``` 191 | $ ./meloctl set melody.home /opt/melody 192 | melody.home => /opt/melody 193 | ✅ [~/.config/meloctl/meloctl.yml] Configuration file updated 194 | ``` -------------------------------------------------------------------------------- /internal/rules/conditions_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestParseHybridPattern(t *testing.T) { 10 | tests := []struct { 11 | OkSrc []string 12 | NokSrc []string 13 | OkDst [][]byte 14 | NokDst [][]byte 15 | ErrSrc []string 16 | ErrDst []string 17 | }{ 18 | { 19 | OkSrc: []string{ 20 | "abcd1234abcd", 21 | "abcd1234|0d0a|abcd", 22 | "abcd1234|0d 0a|abcd", 23 | "abcd1234|0d 0a|", 24 | "|0d 0a|abcd", 25 | }, 26 | OkDst: [][]byte{ 27 | {97, 98, 99, 100, 49, 50, 51, 52, 97, 98, 99, 100}, 28 | {97, 98, 99, 100, 49, 50, 51, 52, 13, 10, 97, 98, 99, 100}, 29 | {97, 98, 99, 100, 49, 50, 51, 52, 13, 10, 97, 98, 99, 100}, 30 | {97, 98, 99, 100, 49, 50, 51, 52, 13, 10}, 31 | {13, 10, 97, 98, 99, 100}, 32 | }, 33 | NokSrc: []string{ 34 | "abcd1234|0b0a|abcd", 35 | }, 36 | NokDst: [][]byte{ 37 | {97, 98, 99, 100, 49, 50, 51, 52, 97, 98, 99, 100}, 38 | }, 39 | ErrSrc: []string{ 40 | "abcd12340d0a|abcd", 41 | "abcd12340d|0x0a 0x0d|abcd", 42 | }, 43 | ErrDst: []string{ 44 | fmt.Sprintf("failed to parse hybrid pattern : uneven number of hex delimiter (\"|\") in %s", "abcd12340d0a|abcd"), 45 | fmt.Sprintf("failed to parse hybrid pattern : [%s] in %s", "encoding/hex: invalid byte: U+0078 'x'", "abcd12340d|0x0a 0x0d|abcd"), 46 | }, 47 | }, 48 | } 49 | 50 | for _, suite := range tests { 51 | for idx, val := range suite.OkSrc { 52 | parsed, err := ParseHybridPattern([]byte(val)) 53 | if err != nil { 54 | t.Error(val, ":", err, "FAILED") 55 | t.Fail() 56 | continue 57 | } 58 | if !bytes.Equal(parsed, suite.OkDst[idx]) { 59 | t.Error(val, "FAILED") 60 | t.Fail() 61 | } 62 | } 63 | 64 | for idx, val := range suite.NokSrc { 65 | parsed, err := ParseHybridPattern([]byte(val)) 66 | if err != nil { 67 | t.Error(val, ":", err, "FAILED") 68 | t.Fail() 69 | continue 70 | } 71 | if bytes.Equal(parsed, suite.NokDst[idx]) { 72 | t.Error(val, "FAILED") 73 | t.Fail() 74 | } 75 | } 76 | 77 | for idx, val := range suite.ErrSrc { 78 | parsed, err := ParseHybridPattern([]byte(val)) 79 | if err == nil { 80 | t.Error(val, "FAILED : got no error") 81 | t.Fail() 82 | continue 83 | } 84 | if parsed != nil { 85 | t.Error(val, "FAILED : parsed value is not empty") 86 | t.Fail() 87 | continue 88 | } 89 | 90 | if err.Error() != suite.ErrDst[idx] { 91 | t.Error(val, ":", err, "FAILED") 92 | t.Fail() 93 | } 94 | 95 | } 96 | } 97 | } 98 | 99 | func TestParseOptions(t *testing.T) { 100 | var cond Conditions 101 | emptyOption := Options{} 102 | 103 | tests := []struct { 104 | OkSrc []string 105 | NokSrc []string 106 | OkDst []Options 107 | NokDst []Options 108 | ErrSrc []string 109 | ErrDst []string 110 | }{ 111 | { 112 | OkSrc: []string{ 113 | "contains", 114 | "any|contains", 115 | "contains|any", 116 | "endswith|any", 117 | "startswith|any", 118 | "is|any", 119 | "contains|any|regex|nocase", 120 | }, 121 | OkDst: []Options{ 122 | { 123 | All: true, 124 | Contains: true, 125 | }, 126 | { 127 | All: false, 128 | Contains: true, 129 | }, 130 | { 131 | All: false, 132 | Contains: true, 133 | }, 134 | { 135 | All: false, 136 | Endswith: true, 137 | }, 138 | { 139 | All: false, 140 | Startswith: true, 141 | }, 142 | { 143 | All: false, 144 | Is: true, 145 | }, 146 | { 147 | All: false, 148 | Contains: true, 149 | Regex: true, 150 | Nocase: true, 151 | }, 152 | }, 153 | NokSrc: []string{ 154 | "any|contains", 155 | "contains", 156 | }, 157 | NokDst: []Options{ 158 | { 159 | All: false, 160 | }, { 161 | All: true, 162 | }, 163 | }, 164 | ErrSrc: []string{ 165 | "contains|is", 166 | "contains|nonexistent", 167 | "", 168 | }, 169 | ErrDst: []string{ 170 | fmt.Sprintf("options httpparser failed for condition '%s' : there can only be one of ", "contains|is"), 171 | fmt.Sprintf("options httpparser failed for condition '%s' : unknown option \"%s\"", "contains|nonexistent", "nonexistent"), 172 | fmt.Sprintf("options httpparser failed for condition '%s' : matching mode cannot be empty", ""), 173 | }, 174 | }, 175 | } 176 | 177 | cond = Conditions{} 178 | 179 | for _, suite := range tests { 180 | for idx, val := range suite.OkSrc { 181 | err := cond.ParseOptions(val) 182 | if err != nil { 183 | t.Error(val, "FAILED :", err) 184 | t.Fail() 185 | continue 186 | } 187 | if cond.Options != suite.OkDst[idx] { 188 | t.Error(val, "FAILED") 189 | t.Fail() 190 | continue 191 | } 192 | } 193 | 194 | cond = Conditions{} 195 | 196 | for _, suite := range tests { 197 | for idx, val := range suite.NokSrc { 198 | err := cond.ParseOptions(val) 199 | if err != nil { 200 | t.Error(val, "FAILED :", err) 201 | t.Fail() 202 | continue 203 | } 204 | if cond.Options == suite.NokDst[idx] { 205 | t.Error(val, "FAILED") 206 | t.Fail() 207 | continue 208 | } 209 | } 210 | } 211 | 212 | cond = Conditions{} 213 | for _, suite := range tests { 214 | for idx, val := range suite.ErrSrc { 215 | err := cond.ParseOptions(val) 216 | if err == nil { 217 | t.Error(val, "FAILED : got no error") 218 | t.Fail() 219 | continue 220 | } 221 | if cond.Options != emptyOption { 222 | t.Error(val, "FAILED : parsed value is not empty") 223 | t.Fail() 224 | continue 225 | } 226 | if err.Error() != suite.ErrDst[idx] { 227 | t.Error(val, ":", err, "FAILED") 228 | t.Fail() 229 | } 230 | } 231 | } 232 | } 233 | } 234 | 235 | //TODO Tests of the logic flow when having multiple condition bloc 236 | -------------------------------------------------------------------------------- /internal/sensor/sensor.go: -------------------------------------------------------------------------------- 1 | package sensor 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/ma111e/melody/internal/logging" 9 | 10 | "github.com/google/gopacket/layers" 11 | "github.com/ma111e/melody/internal/engine" 12 | "github.com/ma111e/melody/internal/events" 13 | 14 | "github.com/ma111e/melody/internal/sessions" 15 | 16 | "github.com/google/gopacket" 17 | "github.com/google/gopacket/pcap" 18 | "github.com/google/gopacket/tcpassembly" 19 | "github.com/ma111e/melody/internal/assembler" 20 | "github.com/ma111e/melody/internal/config" 21 | ) 22 | 23 | // Start starts the pipeline to receive packets 24 | func Start(quitErrChan chan error, shutdownChan chan bool, sensorStoppedChan chan bool) { 25 | go ReceivePackets(quitErrChan, shutdownChan, sensorStoppedChan) 26 | } 27 | 28 | // ReceivePackets setup the capture using the loaded configuration 29 | func ReceivePackets(quitErrChan chan error, shutdownChan chan bool, sensorStoppedChan chan bool) { 30 | // Set up HTTP assembly 31 | streamFactory := &assembler.HTTPStreamFactory{} 32 | streamPool := tcpassembly.NewStreamPool(streamFactory) 33 | httpAssembler := tcpassembly.NewAssembler(streamPool) 34 | var handle *pcap.Handle 35 | var err error 36 | 37 | if config.Cfg.PcapFile != nil { 38 | handle, err = pcap.OpenOfflineFile(config.Cfg.PcapFile) 39 | if err != nil { 40 | quitErrChan <- err 41 | close(sensorStoppedChan) 42 | time.Sleep(2 * time.Second) 43 | logging.Errors.Println(err) 44 | logging.Errors.Println("Failed to shutdown gracefully, exiting now.") 45 | os.Exit(1) 46 | } 47 | } else { 48 | // Open up a pcap handle for packet reads/writes. 49 | handle, err = pcap.OpenLive(config.Cfg.Interface, 65536, true, pcap.BlockForever) 50 | if err != nil { 51 | quitErrChan <- err 52 | close(sensorStoppedChan) 53 | time.Sleep(2 * time.Second) 54 | logging.Errors.Println(err) 55 | logging.Errors.Println("Failed to shutdown gracefully, exiting now.") 56 | os.Exit(1) 57 | } 58 | } 59 | 60 | defer handle.Close() 61 | if config.Cfg.BPF != "" { 62 | if err := handle.SetBPFFilter(config.Cfg.BPF); err != nil { 63 | quitErrChan <- err 64 | time.Sleep(2 * time.Second) 65 | logging.Errors.Println(err) 66 | logging.Errors.Println("Failed to shutdown gracefully, exiting now.") 67 | os.Exit(1) 68 | } 69 | } 70 | 71 | assemblerFlushTicker := time.NewTicker(time.Minute) 72 | sessionsFlushTicker := time.NewTicker(time.Second * 30) 73 | src := gopacket.NewPacketSource(handle, handle.LinkType()) 74 | in := src.Packets() 75 | logging.Std.Println("Now listening for packets") 76 | 77 | defer func() { 78 | httpAssembler.FlushAll() 79 | sessions.SessionMap.FlushAll() 80 | close(sensorStoppedChan) 81 | }() 82 | 83 | loop: 84 | for { 85 | var packet gopacket.Packet 86 | select { 87 | case packet = <-in: 88 | if packet == nil { 89 | break loop 90 | } 91 | handlePacket(packet, httpAssembler) 92 | case <-assemblerFlushTicker.C: 93 | // Every minute, flush connections that haven't seen activity in the past 2 minutes 94 | httpAssembler.FlushOlderThan(time.Now().Add(time.Minute * -2)) 95 | case <-sessionsFlushTicker.C: 96 | // Every 30 seconds, flush inactive flows 97 | sessions.SessionMap.FlushOlderThan(time.Now().Add(time.Second * -30)) 98 | case <-shutdownChan: 99 | return 100 | } 101 | } 102 | 103 | close(shutdownChan) 104 | } 105 | 106 | func handlePacket(packet gopacket.Packet, assembler *tcpassembly.Assembler) { 107 | var event events.Event 108 | var err error 109 | 110 | if packet.NetworkLayer() != nil { 111 | if _, ok := packet.NetworkLayer().(*layers.IPv4); ok { 112 | switch packet.NetworkLayer().(*layers.IPv4).Protocol { 113 | case layers.IPProtocolICMPv4: 114 | if _, ok := config.Cfg.DiscardProto4[config.ICMPv4Kind]; ok { 115 | return 116 | } 117 | 118 | event, err = events.NewICMPv4Event(packet) 119 | if err != nil { 120 | logging.Errors.Println(err) 121 | return 122 | } 123 | 124 | case layers.IPProtocolUDP: 125 | if _, ok := config.Cfg.DiscardProto4[config.UDPKind]; ok { 126 | return 127 | } 128 | 129 | event, err = events.NewUDPEvent(packet, 4) 130 | if err != nil { 131 | logging.Errors.Println(err) 132 | return 133 | } 134 | 135 | case layers.IPProtocolTCP: 136 | tcpPacket := packet.TransportLayer().(*layers.TCP) 137 | assembler.AssembleWithTimestamp(packet.NetworkLayer().NetworkFlow(), tcpPacket, packet.Metadata().Timestamp) 138 | 139 | if _, ok := config.Cfg.DiscardProto4[config.TCPKind]; ok { 140 | return 141 | } 142 | 143 | event, err = events.NewTCPEvent(packet, 4) 144 | if err != nil { 145 | logging.Errors.Println(err) 146 | return 147 | } 148 | 149 | default: 150 | return 151 | } 152 | 153 | if *config.Cli.Dump { 154 | fmt.Println(packet.String()) 155 | } else { 156 | engine.EventChan <- event 157 | } 158 | } else if _, ok := packet.NetworkLayer().(*layers.IPv6); ok { 159 | switch packet.NetworkLayer().(*layers.IPv6).NextHeader { 160 | case layers.IPProtocolICMPv6: 161 | if _, ok := config.Cfg.DiscardProto6[config.ICMPv6Kind]; ok { 162 | return 163 | } 164 | 165 | event, err = events.NewICMPv6Event(packet) 166 | if err != nil { 167 | logging.Errors.Println(err) 168 | return 169 | } 170 | 171 | default: 172 | switch packet.NetworkLayer().(*layers.IPv6).NextLayerType() { 173 | case layers.IPProtocolTCP.LayerType(): 174 | tcpPacket := packet.TransportLayer().(*layers.TCP) 175 | assembler.AssembleWithTimestamp(packet.NetworkLayer().NetworkFlow(), tcpPacket, packet.Metadata().Timestamp) 176 | 177 | if _, ok := config.Cfg.DiscardProto6[config.TCPKind]; ok { 178 | return 179 | } 180 | 181 | event, err = events.NewTCPEvent(packet, 6) 182 | if err != nil { 183 | logging.Errors.Println(err) 184 | return 185 | } 186 | 187 | case layers.IPProtocolUDP.LayerType(): 188 | if _, ok := config.Cfg.DiscardProto6[config.UDPKind]; ok { 189 | return 190 | } 191 | 192 | event, err = events.NewUDPEvent(packet, 6) 193 | if err != nil { 194 | logging.Errors.Println(err) 195 | return 196 | } 197 | 198 | default: 199 | return 200 | } 201 | } 202 | 203 | if *config.Cli.Dump { 204 | fmt.Println(packet.String()) 205 | } else { 206 | engine.EventChan <- event 207 | } 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /internal/events/http.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/ma111e/melody/internal/logdata" 10 | 11 | "github.com/rs/xid" 12 | 13 | "github.com/google/gopacket/layers" 14 | 15 | "github.com/ma111e/melody/internal/sessions" 16 | 17 | "github.com/ma111e/melody/internal/config" 18 | 19 | "github.com/google/gopacket" 20 | "github.com/ma111e/melody/internal/httpparser" 21 | ) 22 | 23 | // HTTPEvent describes the structure of an event generated by a reassembled HTTP packet 24 | type HTTPEvent struct { 25 | Verb string `json:"verb"` 26 | Proto string `json:"proto"` 27 | RequestURI string `json:"URI"` 28 | SourcePort uint16 `json:"src_port"` 29 | DestHost string `json:"dst_host"` 30 | DestPort uint16 `json:"dst_port"` 31 | Headers map[string]string `json:"headers"` 32 | HeadersKeys []string `json:"headers_keys"` 33 | HeadersValues []string `json:"headers_values"` 34 | InlineHeaders []string 35 | Errors []string `json:"errors"` 36 | Body logdata.Payload `json:"body"` 37 | IsTLS bool `json:"is_tls"` 38 | Req *http.Request 39 | LogData logdata.HTTPEventLog 40 | BaseEvent 41 | } 42 | 43 | // GetIPHeader satisfies the Event interface by returning nil. As they're application-level data, HTTP events 44 | // does not support IP header data 45 | func (ev HTTPEvent) GetIPHeader() *layers.IPv4 { 46 | return nil 47 | } 48 | 49 | // GetHTTPData returns the event's data 50 | func (ev HTTPEvent) GetHTTPData() HTTPEvent { 51 | return ev 52 | } 53 | 54 | // ToLog parses the event structure and generate an EventLog almost ready to be sent to the logging file 55 | func (ev HTTPEvent) ToLog() EventLog { 56 | ev.LogData = logdata.HTTPEventLog{} 57 | ev.LogData.Timestamp = time.Now().Format(time.RFC3339Nano) 58 | //ev.LogData.NsTimestamp = strconv.FormatInt(time.Now().UnixNano(), 10) 59 | //ev.LogData.Type = ev.Kind 60 | //ev.LogData.SourceIP = ev.SourceIP 61 | //ev.LogData.DestPort = ev.DestPort 62 | //ev.LogData.Session = ev.Session 63 | // 64 | //if len(ev.Tags) == 0 { 65 | // ev.LogData.Tags = make(map[string][]string) 66 | //} else { 67 | // ev.LogData.Tags = ev.Tags 68 | //} 69 | 70 | ev.LogData.Init(ev.BaseEvent) 71 | 72 | ev.LogData.Session = ev.Session 73 | ev.LogData.HTTP.Verb = ev.Verb 74 | ev.LogData.HTTP.Proto = ev.Proto 75 | ev.LogData.HTTP.RequestURI = ev.RequestURI 76 | ev.LogData.HTTP.SourcePort = ev.SourcePort 77 | ev.LogData.HTTP.DestHost = ev.DestHost 78 | ev.LogData.DestPort = ev.DestPort 79 | ev.LogData.SourceIP = ev.SourceIP 80 | ev.LogData.HTTP.Headers = ev.Headers 81 | ev.LogData.HTTP.Body = ev.Body 82 | ev.LogData.HTTP.IsTLS = ev.IsTLS 83 | ev.LogData.Additional = ev.Additional 84 | 85 | if val, ok := ev.Headers["User-Agent"]; ok { 86 | ev.LogData.HTTP.UserAgent = val 87 | } 88 | 89 | var headersKeys []string 90 | var headersValues []string 91 | 92 | for key, val := range ev.Headers { 93 | headersKeys = append(headersKeys, key) 94 | headersValues = append(headersValues, val) 95 | } 96 | 97 | ev.LogData.HTTP.HeadersKeys = headersKeys 98 | ev.LogData.HTTP.HeadersValues = headersValues 99 | 100 | return ev.LogData 101 | } 102 | 103 | // NewHTTPEvent creates an HTTPEvent from a reassembled http.Request. It uses flow information if available to allow 104 | // quality source and destination information. Only available to HTTP events, as HTTPS events are generated from a 105 | // webserver and thus not reassembled 106 | func NewHTTPEvent(r *http.Request, network gopacket.Flow, transport gopacket.Flow) (*HTTPEvent, error) { 107 | headers := make(map[string]string) 108 | var inlineHeaders []string 109 | var errs []string 110 | var params []byte 111 | var err error 112 | 113 | for header := range r.Header { 114 | headers[header] = r.Header.Get(header) 115 | inlineHeaders = append(inlineHeaders, header+": "+r.Header.Get(header)) 116 | } 117 | 118 | dstPort, _ := strconv.ParseUint(transport.Dst().String(), 10, 16) 119 | srcPort, _ := strconv.ParseUint(transport.Src().String(), 10, 16) 120 | 121 | params, err = httpparser.GetBodyPayload(r) 122 | if err != nil { 123 | errs = append(errs, err.Error()) 124 | } 125 | 126 | ev := &HTTPEvent{ 127 | Verb: r.Method, 128 | Proto: r.Proto, 129 | RequestURI: r.URL.RequestURI(), 130 | SourcePort: uint16(srcPort), 131 | DestPort: uint16(dstPort), 132 | DestHost: network.Dst().String(), 133 | Body: logdata.NewPayloadLogData(params, config.Cfg.MaxPOSTDataSize), 134 | IsTLS: r.TLS != nil, 135 | Headers: headers, 136 | InlineHeaders: inlineHeaders, 137 | Errors: errs, 138 | } 139 | 140 | // Cannot use promoted (inherited) fields in struct literal 141 | ev.Session = sessions.SessionMap.GetUID(transport.String()) 142 | ev.SourceIP = network.Src().String() 143 | ev.Tags = make(Tags) 144 | ev.Additional = make(map[string]string) 145 | 146 | if ev.IsTLS { 147 | ev.Kind = config.HTTPSKind 148 | } else { 149 | ev.Kind = config.HTTPKind 150 | } 151 | 152 | return ev, nil 153 | } 154 | 155 | // NewHTTPEventFromRequest creates an HTTPEvent from an http.Request if flow information is not available. It is used 156 | // for HTTPS events, as they're generated from the dummy webserver and not reassembled by Melody 157 | func NewHTTPEventFromRequest(r *http.Request) (*HTTPEvent, error) { 158 | headers := make(map[string]string) 159 | var inlineHeaders []string 160 | var errs []string 161 | var params []byte 162 | var srcIP string 163 | var dstHost string 164 | var rawDstPort string 165 | var rawSrcPort string 166 | var err error 167 | 168 | for header := range r.Header { 169 | headers[header] = r.Header.Get(header) 170 | inlineHeaders = append(inlineHeaders, header+": "+r.Header.Get(header)) 171 | } 172 | 173 | dstHost, rawDstPort, err = net.SplitHostPort(r.Host) 174 | if err != nil { 175 | errs = append(errs, err.Error()) 176 | } 177 | 178 | srcIP, rawSrcPort, err = net.SplitHostPort(r.RemoteAddr) 179 | if err != nil { 180 | errs = append(errs, err.Error()) 181 | } 182 | 183 | params, err = httpparser.GetBodyPayload(r) 184 | if err != nil { 185 | errs = append(errs, err.Error()) 186 | } 187 | 188 | srcPort, _ := strconv.ParseUint(rawSrcPort, 10, 16) 189 | dstPort, _ := strconv.ParseUint(rawDstPort, 10, 16) 190 | 191 | ev := &HTTPEvent{ 192 | Verb: r.Method, 193 | Proto: r.Proto, 194 | RequestURI: r.URL.RequestURI(), 195 | SourcePort: uint16(srcPort), 196 | DestPort: uint16(dstPort), 197 | DestHost: dstHost, 198 | Body: logdata.NewPayloadLogData(params, config.Cfg.MaxPOSTDataSize), 199 | IsTLS: r.TLS != nil, 200 | Headers: headers, 201 | InlineHeaders: inlineHeaders, 202 | Errors: errs, 203 | } 204 | 205 | // Cannot use promoted (inherited) fields in struct literal 206 | ev.Session = xid.New().String() 207 | ev.SourceIP = srcIP 208 | ev.Tags = make(Tags) 209 | ev.Additional = make(map[string]string) 210 | 211 | if ev.IsTLS { 212 | ev.Kind = config.HTTPSKind 213 | } else { 214 | ev.Kind = config.HTTPKind 215 | } 216 | 217 | return ev, nil 218 | } 219 | -------------------------------------------------------------------------------- /internal/filters/portrules.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ma111e/melody/internal/logging" 10 | ) 11 | 12 | // PortRanges abstracts an array of PortRange 13 | type PortRanges []PortRange 14 | 15 | // PortRules groups the whitelisted and blacklisted ip rules 16 | type PortRules struct { 17 | WhitelistedPorts PortRanges 18 | BlacklistedPorts PortRanges 19 | } 20 | 21 | // PortRange is a range of Port represented by a lower and an upper bound 22 | type PortRange struct { 23 | Lower uint16 24 | Upper uint16 25 | } 26 | 27 | // NewPortRange created a new ip range from a lower and an upper bound 28 | func NewPortRange(lower uint16, upper uint16) PortRange { 29 | return PortRange{ 30 | Lower: lower, 31 | Upper: upper, 32 | } 33 | } 34 | 35 | // ParseRules loads a whitelist and a blacklist into a set of PortRules 36 | func (prls *PortRules) ParseRules(whitelist []string, blacklist []string) { 37 | for _, rawRule := range whitelist { 38 | rule := strings.Replace(rawRule, " ", "", -1) 39 | 40 | if strings.Contains(rule, "-") { 41 | err := prls.WhitelistRange(rule) 42 | if err != nil { 43 | logging.Errors.Println(fmt.Sprintf("Failed to parse the Port rule [%s]:", rule)) 44 | logging.Errors.Println(err) 45 | os.Exit(1) 46 | } 47 | } else { 48 | err := prls.Whitelist(rule) 49 | if err != nil { 50 | logging.Errors.Println(fmt.Sprintf("Failed to parse the Port rule [%s]:", rule)) 51 | logging.Errors.Println(err) 52 | os.Exit(1) 53 | } 54 | } 55 | } 56 | 57 | for _, rawRule := range blacklist { 58 | rule := strings.Replace(rawRule, " ", "", -1) 59 | 60 | if strings.Contains(rule, "-") { 61 | err := prls.BlacklistRange(rule) 62 | if err != nil { 63 | logging.Errors.Println(fmt.Sprintf("Failed to parse the Port rule [%s]:", rule)) 64 | logging.Errors.Println(err) 65 | os.Exit(1) 66 | } 67 | continue 68 | } 69 | 70 | err := prls.Blacklist(rule) 71 | if err != nil { 72 | logging.Errors.Println(fmt.Sprintf("Failed to parse the Port rule [%s]:", rule)) 73 | logging.Errors.Println(err) 74 | os.Exit(1) 75 | } 76 | continue 77 | } 78 | 79 | prls.BlacklistedPorts.MergeOverlapping() 80 | prls.WhitelistedPorts.MergeOverlapping() 81 | } 82 | 83 | // 84 | // PortRanges methods 85 | // 86 | 87 | // MergeOverlapping optimize the parsed PortRange by keeping only non-overlapping ranges 88 | func (prgs *PortRanges) MergeOverlapping() { 89 | workSlice := make(PortRanges, len(*prgs)) 90 | copy(workSlice, *prgs) 91 | 92 | for i := 0; i < len(workSlice); i++ { 93 | for idx, candidate := range workSlice { 94 | if candidate.Equals(workSlice[i]) { 95 | // Skip 96 | continue 97 | } 98 | 99 | if candidate.ContainsPortRange(workSlice[i]) { 100 | workSlice.RemoveAt(i) 101 | i = 0 // Restart upper loop 102 | break 103 | } 104 | 105 | if workSlice[i].ContainsPortRange(candidate) { 106 | workSlice.RemoveAt(idx) 107 | i = 0 // Restart upper loop 108 | break 109 | } 110 | 111 | if !candidate.IsUpperOrLowerBoundary(workSlice[i].Lower) && candidate.IsUpperOrLowerBoundary(workSlice[i].Upper) { 112 | // Replace the candidate's upper with the current's upper 113 | workSlice[idx].Upper = workSlice[i].Upper 114 | workSlice.RemoveAt(i) 115 | i = 0 // Restart upper loop 116 | break 117 | } 118 | 119 | if !candidate.IsUpperOrLowerBoundary(workSlice[i].Upper) && candidate.IsUpperOrLowerBoundary(workSlice[i].Lower) { 120 | // Replace the candidate's lower with the current's lower 121 | workSlice[idx].Lower = workSlice[i].Lower 122 | workSlice.RemoveAt(i) 123 | i = 0 // Restart upper loop 124 | break 125 | } 126 | } 127 | } 128 | 129 | *prgs = workSlice 130 | } 131 | 132 | // RemoveAt is an helper that removes a range at the the given index 133 | func (prgs *PortRanges) RemoveAt(index int) { 134 | workSlice := make(PortRanges, len(*prgs)) 135 | copy(workSlice, *prgs) 136 | 137 | workSlice = append(workSlice[:index], workSlice[index+1:]...) 138 | *prgs = workSlice 139 | } 140 | 141 | // Add is an helper that adds a range made of a single Port 142 | func (prgs *PortRanges) Add(port uint16) { 143 | portRange := NewPortRange(port, port) 144 | *prgs = append(*prgs, portRange) 145 | } 146 | 147 | // AddRange is an helper that parses and adds a range of Port 148 | func (prgs *PortRanges) AddRange(lower uint16, upper uint16) { 149 | ipr := NewPortRange(lower, upper) 150 | *prgs = append(*prgs, ipr) 151 | } 152 | 153 | // 154 | // PortRange methods 155 | // 156 | 157 | // ContainsPort is an helper that checks if a range contains the given Port 158 | func (prg PortRange) ContainsPort(port uint16) bool { 159 | if port >= prg.Lower && port <= prg.Upper { 160 | return true 161 | } 162 | 163 | return false 164 | } 165 | 166 | // ContainsPortRange is an helper that checks if a range contains the given Port range 167 | func (prg PortRange) ContainsPortRange(portRange PortRange) bool { 168 | if prg.ContainsPort(portRange.Lower) && portRange.ContainsPort(portRange.Upper) { 169 | return true 170 | } 171 | 172 | return false 173 | } 174 | 175 | // IsUpperOrLowerBoundary is an helper that checks if the given Port is either the lower of the upper bound of a range 176 | func (prg PortRange) IsUpperOrLowerBoundary(port uint16) bool { 177 | if prg.Lower != port && prg.Upper != port { 178 | return true 179 | } 180 | 181 | return false 182 | } 183 | 184 | // Equals is an helper that checks if a PortRange is equal to another 185 | func (prg *PortRange) Equals(portRange PortRange) bool { 186 | return prg.Upper == portRange.Upper && prg.Lower == portRange.Lower 187 | } 188 | 189 | // 190 | // Ranges 191 | // 192 | 193 | // WhitelistRange parses and adds a Port range string to the PortRules' whitelist 194 | func (prls *PortRules) WhitelistRange(rawPortRange string) error { 195 | portFrom, portTo, err := parseRawPortRange(rawPortRange) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | prls.WhitelistedPorts.AddRange(portFrom, portTo) 201 | 202 | return nil 203 | } 204 | 205 | // BlacklistRange parses and adds a Port range string to the PortRules' blacklist 206 | func (prls *PortRules) BlacklistRange(rawPortRange string) error { 207 | portFrom, portTo, err := parseRawPortRange(rawPortRange) 208 | if err != nil { 209 | return err 210 | } 211 | 212 | prls.BlacklistedPorts.AddRange(portFrom, portTo) 213 | 214 | return nil 215 | } 216 | 217 | func parseRawPortRange(rawPortRange string) (uint16, uint16, error) { 218 | var portFrom uint16 219 | var portTo uint16 220 | var err error 221 | 222 | hostRange := strings.Split(rawPortRange, "-") 223 | 224 | lower, higher := hostRange[0], hostRange[1] 225 | 226 | portFrom, err = parsePortString(lower) 227 | if err != nil { 228 | return portFrom, portTo, err 229 | } 230 | 231 | portTo, err = parsePortString(higher) 232 | if err != nil { 233 | return portFrom, portTo, err 234 | } 235 | 236 | return portFrom, portTo, err 237 | } 238 | 239 | // 240 | // Single Ports 241 | // 242 | 243 | // Whitelist checks the validity of a Port string and adds it to the PortRules' whitelist 244 | func (prls *PortRules) Whitelist(port string) error { 245 | parsed, err := parsePortString(port) 246 | if err != nil { 247 | return err 248 | } 249 | 250 | prls.WhitelistedPorts.Add(parsed) 251 | return nil 252 | } 253 | 254 | // Blacklist checks the validity of a Port string and adds it to the PortRules' blacklist 255 | func (prls *PortRules) Blacklist(port string) error { 256 | parsed, err := parsePortString(port) 257 | if err != nil { 258 | return err 259 | } 260 | 261 | prls.BlacklistedPorts.Add(parsed) 262 | return nil 263 | } 264 | 265 | func parsePortString(port string) (uint16, error) { 266 | port = strings.Replace(port, " ", "", -1) 267 | 268 | if strings.HasPrefix(port, "-") { 269 | return 0, fmt.Errorf("port cannot be under 0 : '%s'", port) 270 | } 271 | 272 | parsed, err := strconv.ParseUint(port, 10, 64) 273 | if err != nil { 274 | return uint16(parsed), err 275 | } 276 | 277 | if parsed > 65535 { 278 | return uint16(parsed), fmt.Errorf("port must be between 0 and 65535 : '%s'", port) 279 | } 280 | 281 | return uint16(parsed), nil 282 | } 283 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |

Melody

4 |

Monitor the Internet's background noise

5 |

6 |

7 | Go Report Card 8 | Coverage Status 9 | Docker build status 10 | Docker image size 11 |

12 | 13 |

14 | Latest release 15 | Documentation 16 | Installation 17 | Quickstart 18 | Go Report Card 19 |

20 | 21 | --- 22 | 23 | Melody is a transparent internet sensor built for threat intelligence and supported by a detection rule framework which allows you to tag packets of interest for further analysis and threat monitoring. 24 | 25 | # Table of Contents 26 | 27 | * [Melody](#melody) 28 | * [Table of contents](#table-of-contents) 29 | * [Features](#features) 30 | * [Wishlist](#wishlist) 31 | * [Use cases](#use-cases) 32 | * [Internet facing sensor](#internet-facing-sensor) 33 | * [Stream analysis](#stream-analysis) 34 | * [Preview](#preview) 35 | * [Quickstart](#quickstart) 36 | * [TL;DR](#tldr) 37 | * [Release](#release) 38 | * [From source](#from-source) 39 | * [Docker](#docker) 40 | * [Rules](#rules) 41 | * [Rule example](#rule-example) 42 | * [Logs](#logs) 43 | 44 | # Features 45 | Here are some key features of Melody : 46 | 47 | + Transparent capture 48 | + Write detection rules and tag specific packets to analyze them at scale 49 | + Mock vulnerable websites using the builtin HTTP/S server 50 | + Supports the main internet protocols over IPv4 and IPv6 51 | + Handles log rotation for you : Melody is designed to run forever on the smallest VPS 52 | + Minimal configuration required 53 | + Standalone mode : configure Melody using only the CLI 54 | + Easily scalable : 55 | + Statically compiled binary 56 | + Up-to-date Docker image 57 | 58 | # Wishlist 59 | Since I have to focus on other projects right now, I can't put much time in Melody's development. 60 | 61 | There is a lot of rom for improvement though, so here are some features that I'd like to implement someday : 62 | + ~~Dedicated helper program to create, test and manage rules~~ -> Check Meloctl in `cmd/meloctl` 63 | + Centralized rules management 64 | + Per port mock application 65 | 66 | # Use cases 67 | ## Internet facing sensor 68 | 69 | + Extract trends and patterns from Internet's noise 70 | + Index malicious activity, exploitation attempts and targeted scanners 71 | + Monitor emerging threats exploitation 72 | + Keep an eye on specific threats 73 | 74 | ## Stream analysis 75 | + Build a background noise profile to make targeted attacks stand out 76 | + Replay captures to tag malicious packets in a suspicious stream 77 | 78 | # Preview 79 | 80 |

81 | 82 | 83 |

84 | 85 | # Quickstart 86 | [Quickstart details.](https://ma111e.github.io/melody/installation) 87 | 88 | ## TL;DR 89 | ### Release 90 | Get the latest release at `https://github.com/ma111e/melody/releases`. 91 | 92 | ```bash 93 | make install # Set default outfacing interface 94 | make cap # Set network capabilities to start Melody without elevated privileges 95 | make certs # Make self signed certs for the HTTPS fileserver 96 | make enable_all_rules # Enable the default rules 97 | make service # Create a systemd service to restart the program automatically and launch it at startup 98 | 99 | sudo systemctl stop melody # Stop the service while we're configuring it 100 | ``` 101 | 102 | Update the `filter.bpf` file to filter out unwanted packets. 103 | 104 | ```bash 105 | sudo systemctl start melody # Start Melody 106 | sudo systemctl status melody # Check that Melody is running 107 | ``` 108 | 109 | The logs should start to pile up in `/opt/melody/logs/melody.ndjson`. 110 | 111 | ```bash 112 | tail -f /opt/melody/logs/melody.ndjson # | jq 113 | ``` 114 | 115 | ### From source 116 | 117 | ```bash 118 | git clone https://github.com/ma111e/melody /opt/melody 119 | cd /opt/melody 120 | make build 121 | ``` 122 | 123 | Then continue with the steps from the [release](#release) TL;DR. 124 | 125 | ### Docker 126 | 127 | ```bash 128 | make certs # Make self signed certs for the HTTPS fileserver 129 | make enable_all_rules # Enable the default rules 130 | mkdir -p /opt/melody/logs 131 | cd /opt/melody/ 132 | 133 | docker pull ma111e/melody:latest 134 | 135 | MELODY_CLI="" # Put your CLI options here. Example : export MELODY_CLI="-s -i 'lo' -F 'dst port 5555' -o 'server.http.port: 5555'" 136 | 137 | docker run \ 138 | --net=host \ 139 | -e "MELODY_CLI=$MELODY_CLI" \ 140 | --mount type=bind,source="$(pwd)/filter.bpf",target=/app/filter.bpf,readonly \ 141 | --mount type=bind,source="$(pwd)/config.yml",target=/app/config.yml,readonly \ 142 | --mount type=bind,source="$(pwd)/var",target=/app/var,readonly \ 143 | --mount type=bind,source="$(pwd)/rules",target=/app/rules,readonly \ 144 | --mount type=bind,source="$(pwd)/logs",target=/app/logs/ \ 145 | ma111e/melody 146 | ``` 147 | 148 | The logs should start to pile up in `/opt/melody/logs/melody.ndjson`. 149 | 150 | # Rules 151 | 152 | [Rule syntax details.](https://ma111e.github.io/melody/installation) 153 | 154 | ## Example 155 | 156 | ```yaml 157 | CVE-2020-14882 Oracle Weblogic Server RCE: 158 | layer: http 159 | meta: 160 | id: 3e1d86d8-fba6-4e15-8c74-941c3375fd3e 161 | version: 1.0 162 | author: BonjourMalware 163 | status: stable 164 | created: 2020/11/07 165 | modified: 2020/20/07 166 | description: "Checking or trying to exploit CVE-2020-14882" 167 | references: 168 | - "https://nvd.nist.gov/vuln/detail/CVE-2020-14882" 169 | match: 170 | http.uri: 171 | startswith|any|nocase: 172 | - "/console/css/" 173 | - "/console/images" 174 | contains|any|nocase: 175 | - "console.portal" 176 | - "consolejndi.portal?test_handle=" 177 | tags: 178 | cve: "cve-2020-14882" 179 | vendor: "oracle" 180 | product: "weblogic" 181 | impact: "rce" 182 | ``` 183 | 184 | # Logs 185 | 186 | [Logs content details.](https://ma111e.github.io/melody/layers) 187 | 188 | ## Example 189 | 190 | Netcat TCP packet over IPv4 : 191 | 192 | ```json 193 | { 194 | "tcp": { 195 | "window": 512, 196 | "seq": 1906765553, 197 | "ack": 2514263732, 198 | "data_offset": 8, 199 | "flags": "PA", 200 | "urgent": 0, 201 | "payload": { 202 | "content": "I made a discovery today. I found a computer.\n", 203 | "base64": "SSBtYWRlIGEgZGlzY292ZXJ5IHRvZGF5LiAgSSBmb3VuZCBhIGNvbXB1dGVyLgo=", 204 | "truncated": false 205 | } 206 | }, 207 | "ip": { 208 | "version": 4, 209 | "ihl": 5, 210 | "tos": 0, 211 | "length": 99, 212 | "id": 39114, 213 | "fragbits": "DF", 214 | "frag_offset": 0, 215 | "ttl": 64, 216 | "protocol": 6 217 | }, 218 | "timestamp": "2020-11-16T15:50:01.277828+01:00", 219 | "session": "bup9368o4skolf20rt8g", 220 | "type": "tcp", 221 | "src_ip": "127.0.0.1", 222 | "dst_port": 1234, 223 | "matches": {}, 224 | "inline_matches": [], 225 | "embedded": {} 226 | } 227 | ``` 228 | -------------------------------------------------------------------------------- /internal/rules/conditions.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "fmt" 7 | "log" 8 | "os" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | // RawConditions describes the format of a condition field in a rule file 14 | type RawConditions struct { 15 | Groups map[string][]string `yaml:"-,inline"` 16 | Any bool `yaml:"any"` 17 | Depth uint `yaml:"depth"` 18 | Offset uint `yaml:"offset"` 19 | } 20 | 21 | // ConditionsList describes the format of a list of RawConditions 22 | type ConditionsList struct { 23 | Conditions []Conditions 24 | MatchAll bool 25 | //MatchAny bool 26 | } 27 | 28 | // Conditions describes a parsed RawConditions 29 | type Conditions struct { 30 | Values []ConditionValue 31 | Options Options 32 | } 33 | 34 | // Options describes the available matching options 35 | type Options struct { 36 | Depth uint 37 | Offset uint 38 | Nocase bool 39 | Is bool 40 | All bool 41 | Contains bool 42 | Startswith bool 43 | Endswith bool 44 | Regex bool 45 | } 46 | 47 | // ConditionValue abstracts the parsed value of a condition to use in a match attempt 48 | type ConditionValue struct { 49 | CompiledRegex *regexp.Regexp 50 | ByteValue []byte 51 | } 52 | 53 | // Match matches a byte array against a ConditionsList 54 | func (clst ConditionsList) Match(received []byte) bool { 55 | if clst.Conditions != nil { 56 | if !clst.MatchAll { 57 | var condOK = false 58 | for _, condGroup := range clst.Conditions { 59 | // If any condition group is valid, continue 60 | 61 | if condGroup.Match(received) { 62 | condOK = true 63 | break 64 | } 65 | } 66 | 67 | if !condOK { 68 | return false 69 | } 70 | } else { // condList.MatchAll 71 | for _, condGroup := range clst.Conditions { 72 | // If any condition group is invalid, rule is false 73 | // Continue if the test for all the values are successful 74 | 75 | if !condGroup.Match(received) { 76 | return false 77 | } 78 | } 79 | } 80 | } 81 | 82 | return true 83 | } 84 | 85 | // Match matches a byte array against a set of conditions 86 | func (cds Conditions) Match(received []byte) bool { 87 | var contentMatch bool 88 | var matchCounter int 89 | var valuesLen = len(cds.Values) 90 | 91 | for _, condVal := range cds.Values { 92 | contentMatch = cds.MatchBytesWithOptions(received, condVal) 93 | 94 | if cds.Options.All && !contentMatch { 95 | return false 96 | // Continue unless all the tests passed 97 | } else if cds.Options.All && matchCounter < valuesLen { 98 | matchCounter++ 99 | continue 100 | } else if contentMatch { 101 | // If at least one match in the list, then continue 102 | break 103 | } 104 | } 105 | 106 | return contentMatch 107 | } 108 | 109 | // MatchBytesWithOptions matches a byte array against a set of conditions, according to the specified ConditionValue 110 | // This function only cares about the matching modifier ("contains", "startswith", etc, not "all") 111 | // The condition's options are being taken care of in the Conditions.Match function 112 | func (cds Conditions) MatchBytesWithOptions(received []byte, condVal ConditionValue) bool { 113 | var match bool 114 | var condValContent = condVal.ByteValue 115 | 116 | if cds.Options.Nocase { 117 | received = bytes.ToLower(received) 118 | condValContent = bytes.ToLower(condValContent) 119 | } 120 | 121 | if cds.Options.Offset > 0 && cds.Options.Offset < uint(len(received)) { 122 | received = received[cds.Options.Offset:] 123 | } 124 | 125 | if cds.Options.Depth > 0 && cds.Options.Depth < uint(len(received)) { 126 | received = received[:cds.Options.Depth] 127 | } 128 | 129 | if cds.Options.Is { 130 | match = bytes.Equal(received, condValContent) 131 | } else if cds.Options.Regex { 132 | match = condVal.CompiledRegex.Match(received) 133 | } else if cds.Options.Contains { 134 | match = bytes.Contains(received, condValContent) 135 | } else if cds.Options.Startswith { 136 | match = bytes.HasPrefix(received, condValContent) 137 | } else if cds.Options.Endswith { 138 | match = bytes.HasSuffix(received, condValContent) 139 | } 140 | return match 141 | } 142 | 143 | // ParseList parses a RawConditions set to create a ConditionsList 144 | func (rclst RawConditions) ParseList() (*ConditionsList, error) { 145 | if len(rclst.Groups) == 0 { 146 | return nil, nil 147 | } 148 | 149 | condsList := ConditionsList{ 150 | MatchAll: !rclst.Any, 151 | } 152 | var bufCond Conditions 153 | 154 | for options, val := range rclst.Groups { 155 | bufCond = Conditions{} 156 | err := bufCond.ParseOptions(options) 157 | if err != nil { 158 | return nil, err 159 | } 160 | bufCond.ParseValues(val) 161 | bufCond.Options.Offset = rclst.Offset 162 | bufCond.Options.Depth = rclst.Depth 163 | //bufCond.Options.All = rclst.Any == false 164 | 165 | condsList.Conditions = append(condsList.Conditions, bufCond) 166 | } 167 | 168 | //condsList.ParseMatchType(rclst.MatchType, ruleID) 169 | 170 | return &condsList, nil 171 | } 172 | 173 | // ParseOptions parses a condition's name to extract the options separated by a | 174 | func (cds *Conditions) ParseOptions(opt string) error { 175 | chunks := strings.Split(opt, "|") 176 | modeQty := 0 177 | var newOption Options 178 | 179 | // Default to all = true 180 | newOption.All = true 181 | 182 | if opt == "" { 183 | return fmt.Errorf("options httpparser failed for condition '%s' : matching mode cannot be empty", opt) 184 | } 185 | 186 | for _, chunk := range chunks { 187 | switch chunk { 188 | case "any": 189 | newOption.All = false 190 | case "nocase": 191 | newOption.Nocase = true 192 | case "regex": 193 | newOption.Regex = true 194 | case "is": 195 | modeQty++ 196 | newOption.Is = true 197 | case "contains": 198 | modeQty++ 199 | newOption.Contains = true 200 | case "startswith": 201 | modeQty++ 202 | newOption.Startswith = true 203 | case "endswith": 204 | modeQty++ 205 | newOption.Endswith = true 206 | default: 207 | return fmt.Errorf("options httpparser failed for condition '%s' : unknown option \"%s\"", opt, chunk) 208 | } 209 | } 210 | 211 | if modeQty > 1 { 212 | return fmt.Errorf("options httpparser failed for condition '%s' : there can only be one of ", opt) 213 | } 214 | 215 | //newOption.All = any == false 216 | cds.Options = newOption 217 | 218 | return nil 219 | } 220 | 221 | // ParseValues loads a Conditions set from a list of condition strings 222 | func (cds *Conditions) ParseValues(list []string) { 223 | var err error 224 | var condValBuf = ConditionValue{} 225 | 226 | for _, val := range list { 227 | buffer := []byte(val) 228 | 229 | condValBuf = ConditionValue{} 230 | if cds.Options.Regex { 231 | condValBuf.CompiledRegex, err = regexp.Compile(val) 232 | if err != nil { 233 | log.Println("Failed to compile regex", val, ":", err) 234 | os.Exit(1) 235 | } 236 | } 237 | parsedBuffer, err := ParseHybridPattern(buffer) 238 | if err != nil { 239 | log.Println(err) 240 | os.Exit(1) 241 | } 242 | 243 | condValBuf.ByteValue = parsedBuffer 244 | 245 | cds.Values = append(cds.Values, condValBuf) 246 | } 247 | } 248 | 249 | // ParseHybridPattern parses a byte array composed of hybrid hex and ascii characters and returns its 250 | // equivalent as a byte array 251 | func ParseHybridPattern(buffer []byte) ([]byte, error) { 252 | var isHex bool 253 | var parsedBuffer []byte 254 | var byteBuffer []byte 255 | 256 | for _, c := range buffer { 257 | if c == byte('|') { 258 | // If we already met a '|', then this one is the end delimiter 259 | // -> Dump the recorded byte buffer and clean it 260 | if isHex { 261 | isHex = false 262 | 263 | data, err := hex.DecodeString(string(byteBuffer)) 264 | if err != nil { 265 | return nil, fmt.Errorf("failed to parse hybrid pattern : [%s] in %s", err, buffer) 266 | } 267 | 268 | parsedBuffer = append(parsedBuffer, data...) 269 | byteBuffer = []byte{} 270 | continue 271 | } else { 272 | // Else start recording the bytes until a delimiter is found 273 | isHex = true 274 | continue 275 | } 276 | } else if isHex { 277 | // If we already have met a byte delimiter and this char is not a delimiter as well, record as byte 278 | // Skip spaces in user defined hex string 279 | if c == ' ' { 280 | continue 281 | } 282 | byteBuffer = append(byteBuffer, c) 283 | } else { 284 | parsedBuffer = append(parsedBuffer, c) 285 | } 286 | } 287 | 288 | if isHex { 289 | return nil, fmt.Errorf("failed to parse hybrid pattern : uneven number of hex delimiter (\"|\") in %s", buffer) 290 | } 291 | 292 | return parsedBuffer, nil 293 | } 294 | -------------------------------------------------------------------------------- /docs/docs/layers.md: -------------------------------------------------------------------------------- 1 | ## HTTP / HTTPS 2 | ### Rules 3 | 4 | |Key|Type|Example| 5 | |---|---|---| 6 | |`http.uri`|*complex*|
http.uri:
  contains:
    - "/console/css/%2e"
| 7 | |`http.body`|*complex*|
http.body:
  contains:
    - "I made a discovery today."
| 8 | |`http.headers`|*complex*|
http.headers:
  is:
    - "User-agent: Mozilla/5.0 zgrab/0.x"
| 9 | |`http.method`|*complex*|
http.method:
  is:
    - "POST"
| 10 | |`http.proto`|*complex*|
http.proto:
  is:
    - "HTTP/1.1"
| 11 | |`http.tls`|*bool*|
false
| 12 | 13 | !!! Important 14 | HTTP being an application protocol, the full packet is assembled from multiple frames and thus does not have its transport information embedded. 15 | 16 | However, the reassembled packet data share its session with the TCP frames it comes from. You can link them together by looking up the session. 17 | 18 | !!! Note 19 | HTTPS packets are captured via the webserver and not reassembled : they have their own session and are **not** linked with the source frames. 20 | 21 | ### Log data 22 | 23 | !!! Example 24 | ```json 25 | { 26 | "http": { 27 | "verb": "POST", 28 | "proto": "HTTP/1.1", 29 | "uri": "/", 30 | "src_port": 51746, 31 | "dst_host": "127.0.0.1", 32 | "user_agent": "curl/7.58.0", 33 | "headers": { 34 | "Accept": "*/*", 35 | "Content-Length": "14", 36 | "Content-Type": "application/x-www-form-urlencoded", 37 | "User-Agent": "curl/7.58.0" 38 | }, 39 | "headers_keys": [ 40 | "User-Agent", 41 | "Accept", 42 | "Content-Length", 43 | "Content-Type" 44 | ], 45 | "headers_values": [ 46 | "curl/7.58.0", 47 | "*/*", 48 | "14", 49 | "application/x-www-form-urlencoded" 50 | ], 51 | "errors": null, 52 | "body": { 53 | "content": "Enter my world", 54 | "base64": "RW50ZXIgbXkgd29ybGQ=", 55 | "truncated": false 56 | }, 57 | "is_tls": false 58 | }, 59 | "ip": null, 60 | "timestamp": "2020-11-17T21:16:23.847161686+01:00", 61 | "session": "buq2v5oo4skos28gfp20", 62 | "type": "http", 63 | "src_ip": "127.0.0.1", 64 | "dst_port": 10080, 65 | "matches": {}, 66 | "inline_matches": [], 67 | "embedded": {} 68 | } 69 | ``` 70 | 71 | !!! Info 72 | The `errors` field contains the error met while parsing the request body or the Host field. 73 | 74 | ## TCP 75 | ### Rules 76 | |Key|Type|Example| 77 | |---|---|---| 78 | |`tcp.payload`|*complex*|
tcp.payload:
  contains:
    - "/console/css/%2e"
|
 79 | |`tcp.flags`|*flags*|
tcp.flags:
  - "PA"
  - "S"
|
 80 | |`tcp.fragbits`|*flags*|
tcp.fragbits:
  - "M"
| 81 | |`tcp.dsize`|*number*|
tcp.dsize: 1234
| 82 | |`tcp.seq`|*number*|
tcp.seq: 4321
| 83 | |`tcp.ack`|*number*|
tcp.ack: 0
| 84 | |`tcp.window`|*number*|
tcp.window: 512
| 85 | 86 | TCP flags values : 87 | 88 | |Keyword|Name|Value| 89 | |---|---|---| 90 | |`F`|FIN|
0x01
| 91 | |`S`|SYN|
0x02
| 92 | |`R`|RST|
0x04
| 93 | |`P`|PSH|
0x08
| 94 | |`A`|ACK|
0x10
| 95 | |`U`|URG|
0x20
| 96 | |`E`|ECE|
0x40
| 97 | |`C`|CWR|
0x80
| 98 | |`0`|NULL|
0x00
| 99 | 100 | TCP fragbits values : 101 | 102 | |Keyword|Name|Value| 103 | |---|---|---| 104 | |`M`|More Fragments|
0x01
| 105 | |`D`|Don't Fragment|
0x02
| 106 | |`R`|Reserved Bit|
0x04
| 107 | 108 | ### Log data 109 | 110 | !!! Example 111 | ```json 112 | { 113 | "tcp": { 114 | "window": 512, 115 | "seq": 1906765553, 116 | "ack": 2514263732, 117 | "data_offset": 8, 118 | "flags": "PA", 119 | "urgent": 0, 120 | "payload": { 121 | "content": "I made a discovery today. I found a computer.\n", 122 | "base64": "SSBtYWRlIGEgZGlzY292ZXJ5IHRvZGF5LiAgSSBmb3VuZCBhIGNvbXB1dGVyLgo=", 123 | "truncated": false 124 | } 125 | }, 126 | "ip": { 127 | "version": 4, 128 | "ihl": 5, 129 | "tos": 0, 130 | "length": 99, 131 | "id": 39114, 132 | "fragbits": "DF", 133 | "frag_offset": 0, 134 | "ttl": 64, 135 | "protocol": 6 136 | }, 137 | "timestamp": "2020-11-16T15:50:01.277828+01:00", 138 | "session": "bup9368o4skolf20rt8g", 139 | "type": "tcp", 140 | "src_ip": "127.0.0.1", 141 | "dst_port": 1234, 142 | "matches": {}, 143 | "inline_matches": [], 144 | "embedded": {} 145 | } 146 | ``` 147 | 148 | ## UDP 149 | ### Rules 150 | 151 | |Key|Type|Example| 152 | |---|---|---| 153 | |`udp.payload`|*complex*|
udp.payload:
  contains:
    - "/console/css/%2e"
|
154 | |`udp.checksum`|*number*|
udp.checksum: 0xfe37
| 155 | |`udp.length`|*number*|
udp.length: 36
| 156 | |`udp.dsize`|*number*|
udp.dsize: 28
| 157 | 158 | !!! Tip 159 | `udp.dsize` check the payload size, while the `udp.length` check the UDP packet's length. 160 | 161 | ### Log data 162 | 163 | !!! Example 164 | 165 | ```json 166 | { 167 | "udp": { 168 | "payload": { 169 | "content": "I made a discovery today. I found a computer.\n", 170 | "base64": "SSBtYWRlIGEgZGlzY292ZXJ5IHRvZGF5LiBJIGZvdW5kIGEgY29tcHV0ZXIuCg==", 171 | "truncated": false 172 | }, 173 | "length": 54, 174 | "checksum": 65097 175 | }, 176 | "ip": { 177 | "version": 4, 178 | "ihl": 5, 179 | "tos": 0, 180 | "length": 74, 181 | "id": 3230, 182 | "fragbits": "DF", 183 | "frag_offset": 0, 184 | "ttl": 64, 185 | "protocol": 17 186 | }, 187 | "timestamp": "2020-11-17T19:02:12.90819+01:00", 188 | "session": "buq1090o4sktrqnfoe6g", 189 | "type": "udp", 190 | "src_ip": "127.0.0.1", 191 | "dst_port": 1234, 192 | "matches": {}, 193 | "inline_matches": [], 194 | "embedded": {} 195 | } 196 | ``` 197 | 198 | ## ICMPv4 199 | ### Rules 200 | 201 | |Key|Type|Example| 202 | |---|---|---| 203 | |`icmpv4.payload`|*complex*|
icmpv4.payload:
  contains:
    - "the world of the electron and the switch"
|
204 | |`icmpv4.typecode`|*number*|
icmpv4.typecode: 2048
| 205 | |`icmpv4.type`|*number*|
icmpv4.type: 0x8
| 206 | |`icmpv4.code`|*number*|
icmpv4.code: 0
| 207 | |`icmpv4.seq`|*number*|
icmpv4.seq: 1
| 208 | |`icmpv4.checksum`|*number*|
icmpv4.checksum: 0x0416
| 209 | 210 | ### Log data 211 | 212 | !!! Example 213 | 214 | ```json 215 | { 216 | "icmpv4": { 217 | "type_code": 2048, 218 | "type": 8, 219 | "code": 0, 220 | "type_code_name": "EchoRequest", 221 | "checksum": 10240, 222 | "id": 0, 223 | "seq": 0, 224 | "payload": { 225 | "content": "the world of the electron and the switch", 226 | "base64": "dGhlIHdvcmxkIG9mIHRoZSBlbGVjdHJvbiBhbmQgdGhlIHN3aXRjaA==", 227 | "truncated": false 228 | } 229 | }, 230 | "ip": { 231 | "version": 4, 232 | "ihl": 5, 233 | "tos": 0, 234 | "length": 68, 235 | "id": 1, 236 | "fragbits": "", 237 | "frag_offset": 0, 238 | "ttl": 64, 239 | "protocol": 1 240 | }, 241 | "timestamp": "2020-11-18T12:47:25.101191+01:00", 242 | "session": "n/a", 243 | "type": "icmpv4", 244 | "src_ip": "127.0.0.1", 245 | "dst_port": 0, 246 | "matches": {}, 247 | "inline_matches": [], 248 | "embedded": {} 249 | } 250 | ``` 251 | 252 | ## ICMPv6 253 | ### Rules 254 | 255 | |Key|Type|Example| 256 | |---|---|---| 257 | |`icmpv6.payload`|*complex*|
icmpv6.payload:
  contains:
    - "the world of the electron and the switch"
|
258 | |`icmpv6.typecode`|*number*|
icmpv6.typecode: 32768
| 259 | |`icmpv6.type`|*number*|
icmpv6.type: 0x80
| 260 | |`icmpv6.code`|*number*|
icmpv6.code: 0
| 261 | |`icmpv6.checksum`|*number*|
icmpv6.checksum: 0x275b
| 262 | 263 | ### Log data 264 | 265 | !!! Example 266 | 267 | ```json 268 | { 269 | "icmpv6": { 270 | "type_code": 32768, 271 | "type": 128, 272 | "code": 0, 273 | "type_code_name": "EchoRequest", 274 | "checksum": 44947, 275 | "payload": { 276 | "content": "\u0000\u0000\u0000\u0000the world of the electron and the switch", 277 | "base64": "AAAAAHRoZSB3b3JsZCBvZiB0aGUgZWxlY3Ryb24gYW5kIHRoZSBzd2l0Y2g=", 278 | "truncated": false 279 | } 280 | }, 281 | "ip": { 282 | "version": 6, 283 | "length": 48, 284 | "next_header": 58, 285 | "next_header_name": "ICMPv6", 286 | "traffic_class": 0, 287 | "flow_label": 0, 288 | "hop_limit": 64 289 | }, 290 | "timestamp": "2020-11-18T12:42:47.461931+01:00", 291 | "session": "n/a", 292 | "type": "icmpv6", 293 | "src_ip": "::1", 294 | "dst_port": 0, 295 | "matches": {}, 296 | "inline_matches": [], 297 | "embedded": {} 298 | } 299 | ``` 300 | -------------------------------------------------------------------------------- /internal/rules/raw_rules.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/ma111e/melody/internal/logging" 9 | 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // RawRules abstracts a group of raw rules in a rule file 14 | type RawRules map[string]RawRule 15 | 16 | // HTTPRule describes the raw "match" section of a rule targeting HTTP 17 | type HTTPRule struct { 18 | URI RawConditions `yaml:"http.uri"` 19 | Body RawConditions `yaml:"http.body"` 20 | Headers RawConditions `yaml:"http.headers"` 21 | Verb RawConditions `yaml:"http.method"` 22 | Proto RawConditions `yaml:"http.proto"` 23 | TLS *bool `yaml:"http.tls"` 24 | Any bool `yaml:"any"` 25 | } 26 | 27 | // ParsedHTTPRule describes the parsed "match" section of a rule targeting HTTP 28 | type ParsedHTTPRule struct { 29 | URI *ConditionsList 30 | Body *ConditionsList 31 | Headers *ConditionsList 32 | Verb *ConditionsList 33 | Proto *ConditionsList 34 | TLS *bool 35 | } 36 | 37 | // TCPRule describes the raw "match" section of a rule targeting TCP 38 | type TCPRule struct { 39 | IPOption RawConditions `yaml:"tcp.ipoption"` 40 | Fragbits RawFragbitsList `yaml:"tcp.fragbits"` 41 | Dsize *uint `yaml:"tcp.dsize"` 42 | Flags RawTCPFlagsList `yaml:"tcp.flags"` 43 | Seq *uint32 `yaml:"tcp.seq"` 44 | Ack *uint32 `yaml:"tcp.ack"` 45 | Payload RawConditions `yaml:"tcp.payload"` 46 | Window *uint16 `yaml:"tcp.window"` 47 | Any bool `yaml:"any"` 48 | } 49 | 50 | // ParsedTCPRule describes the parsed "match" section of a rule targeting TCP 51 | type ParsedTCPRule struct { 52 | IPOption *ConditionsList 53 | Fragbits []*uint8 54 | Flags []*uint8 55 | Dsize *uint 56 | Seq *uint32 57 | Ack *uint32 58 | Window *uint16 59 | Payload *ConditionsList 60 | } 61 | 62 | // ICMPv4Rule describes the raw "match" section of a rule targeting ICMPv4 63 | type ICMPv4Rule struct { 64 | TypeCode *uint16 `yaml:"icmpv4.typecode"` 65 | Type *uint8 `yaml:"icmpv4.type"` 66 | Code *uint8 `yaml:"icmpv4.code"` 67 | Checksum *uint16 `yaml:"icmpv4.checksum"` 68 | Seq *uint16 `yaml:"icmpv4.seq"` 69 | Payload RawConditions `yaml:"icmpv4.payload"` 70 | Any bool `yaml:"any"` 71 | } 72 | 73 | // ParsedICMPv4Rule describes the parsed "match" section of a rule targeting ICMPv4 74 | type ParsedICMPv4Rule struct { 75 | TypeCode *uint16 76 | Type *uint8 77 | Code *uint8 78 | Checksum *uint16 79 | Seq *uint16 80 | Payload *ConditionsList 81 | } 82 | 83 | // ICMPv6Rule describes the raw "match" section of a rule targeting ICMPv6 84 | type ICMPv6Rule struct { 85 | TypeCode *uint16 `yaml:"icmpv6.typecode"` 86 | Type *uint8 `yaml:"icmpv6.type"` 87 | Code *uint8 `yaml:"icmpv6.code"` 88 | Checksum *uint16 `yaml:"icmpv6.checksum"` 89 | Payload RawConditions `yaml:"icmpv6.payload"` 90 | Any bool `yaml:"any"` 91 | } 92 | 93 | // ParsedICMPv6Rule describes the parsed "match" section of a rule targeting ICMPv6 94 | type ParsedICMPv6Rule struct { 95 | TypeCode *uint16 96 | Type *uint8 97 | Code *uint8 98 | Checksum *uint16 99 | Payload *ConditionsList 100 | } 101 | 102 | // UDPRule describes the raw "match" section of a rule targeting UDP 103 | type UDPRule struct { 104 | Length *uint16 `yaml:"udp.length"` 105 | Dsize *uint `yaml:"udp.dsize"` 106 | Checksum *uint16 `yaml:"udp.checksum"` 107 | Payload RawConditions `yaml:"udp.payload"` 108 | Any bool `yaml:"any"` 109 | } 110 | 111 | // ParsedUDPRule describes the parsed "match" section of a rule targeting UDP 112 | type ParsedUDPRule struct { 113 | Length *uint16 114 | Dsize *uint 115 | Checksum *uint16 116 | Payload *ConditionsList 117 | } 118 | 119 | // Filters groups the exposed rule filters 120 | type Filters struct { 121 | Ports []string `yaml:"ports"` 122 | IPs []string `yaml:"ips"` 123 | } 124 | 125 | // Metadata describes the exposed content of the "meta" field 126 | type Metadata struct { 127 | ID string `yaml:"id"` 128 | Status string `yaml:"status"` 129 | Description string `yaml:"description"` 130 | Author string `yaml:"author"` 131 | Created string `yaml:"created"` 132 | Modified string `yaml:"modified"` 133 | References []string `yaml:"references"` 134 | } 135 | 136 | // RawRule describes the format of a rule as written by the user 137 | type RawRule struct { 138 | Whitelist Filters `yaml:"whitelist"` 139 | Blacklist Filters `yaml:"blacklist"` 140 | 141 | Match interface{} `yaml:"match"` 142 | 143 | Tags map[string]string `yaml:"tags"` 144 | Layer string `yaml:"layer"` 145 | IPProtocol RawConditions `yaml:"ip_protocol"` 146 | 147 | Metadata Metadata `yaml:"meta"` 148 | Additional map[string]string `yaml:"embed"` 149 | } 150 | 151 | var ( 152 | validMatchKeysMap map[string]interface{} = LoadValidMatchKeysMap() 153 | ) 154 | 155 | // Parse creates a Rule from a RawRule 156 | func (rawRule RawRule) Parse() (Rule, error) { 157 | var err error 158 | rule := NewRule(rawRule) 159 | 160 | if rawRule.Match == nil { 161 | return rule, nil 162 | } 163 | 164 | rawMatch, err := yaml.Marshal(rawRule.Match) 165 | if err != nil { 166 | logging.Errors.Printf("failed to parse rule '%s' : invalid yaml definition (%s)", rawRule.Metadata.ID, err) 167 | // Fatal error 168 | os.Exit(1) 169 | } 170 | 171 | for key := range rawRule.Match.(map[string]interface{}) { 172 | // "any" is the only valid non-layer specific property in the "match" block : 173 | // It can be either "any" or . 174 | if key != "any" && !strings.HasPrefix(key, rawRule.Layer+".") { 175 | return Rule{}, fmt.Errorf("property '%s' is not supported with layer '%s'", key, rawRule.Layer) 176 | } 177 | 178 | if _, ok := validMatchKeysMap[key]; !ok { 179 | return Rule{}, fmt.Errorf("unknown property '%s'", key) 180 | } 181 | } 182 | 183 | switch rawRule.Layer { 184 | case "http": 185 | var buf HTTPRule 186 | 187 | err = yaml.Unmarshal(rawMatch, &buf) 188 | if err != nil { 189 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 190 | } 191 | 192 | parsedURI, err := buf.URI.ParseList() 193 | if err != nil { 194 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 195 | } 196 | 197 | parsedBody, err := buf.Body.ParseList() 198 | if err != nil { 199 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 200 | } 201 | 202 | parsedVerb, err := buf.Verb.ParseList() 203 | if err != nil { 204 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 205 | } 206 | 207 | parsedHeaders, err := buf.Headers.ParseList() 208 | if err != nil { 209 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 210 | } 211 | 212 | parsedProto, err := buf.Proto.ParseList() 213 | if err != nil { 214 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 215 | } 216 | 217 | rule.HTTP = ParsedHTTPRule{ 218 | URI: parsedURI, 219 | Body: parsedBody, 220 | Verb: parsedVerb, 221 | Headers: parsedHeaders, 222 | Proto: parsedProto, 223 | TLS: buf.TLS, 224 | } 225 | 226 | rule.MatchAll = !buf.Any 227 | 228 | case "tcp": 229 | var buf TCPRule 230 | 231 | err = yaml.Unmarshal(rawMatch, &buf) 232 | if err != nil { 233 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 234 | } 235 | 236 | parsedIPOption, err := buf.IPOption.ParseList() 237 | if err != nil { 238 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 239 | } 240 | 241 | parsedPayload, err := buf.Payload.ParseList() 242 | if err != nil { 243 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 244 | } 245 | 246 | rule.TCP = ParsedTCPRule{ 247 | IPOption: parsedIPOption, 248 | Fragbits: buf.Fragbits.ParseList(), 249 | Flags: buf.Flags.ParseList(), 250 | Window: buf.Window, 251 | Dsize: buf.Dsize, 252 | Seq: buf.Seq, 253 | Ack: buf.Ack, 254 | Payload: parsedPayload, 255 | } 256 | 257 | rule.MatchAll = !buf.Any 258 | 259 | case "udp": 260 | var buf UDPRule 261 | 262 | err = yaml.Unmarshal(rawMatch, &buf) 263 | if err != nil { 264 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 265 | } 266 | 267 | parsedPayload, err := buf.Payload.ParseList() 268 | if err != nil { 269 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 270 | } 271 | 272 | rule.UDP = ParsedUDPRule{ 273 | Dsize: buf.Dsize, 274 | Length: buf.Length, 275 | Checksum: buf.Checksum, 276 | Payload: parsedPayload, 277 | } 278 | 279 | rule.MatchAll = !buf.Any 280 | 281 | case "icmpv4": 282 | var buf ICMPv4Rule 283 | 284 | err = yaml.Unmarshal(rawMatch, &buf) 285 | if err != nil { 286 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 287 | } 288 | 289 | parsedPayload, err := buf.Payload.ParseList() 290 | if err != nil { 291 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 292 | } 293 | 294 | rule.ICMPv4 = ParsedICMPv4Rule{ 295 | TypeCode: buf.TypeCode, 296 | Type: buf.Type, 297 | Code: buf.Code, 298 | Checksum: buf.Checksum, 299 | Seq: buf.Seq, 300 | Payload: parsedPayload, 301 | } 302 | 303 | rule.MatchAll = !buf.Any 304 | 305 | case "icmpv6": 306 | var buf ICMPv6Rule 307 | 308 | err = yaml.Unmarshal(rawMatch, &buf) 309 | if err != nil { 310 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 311 | } 312 | 313 | parsedPayload, err := buf.Payload.ParseList() 314 | if err != nil { 315 | return Rule{}, fmt.Errorf("failed to parse rule '%s' : %s", rawRule.Metadata.ID, err) 316 | } 317 | 318 | rule.ICMPv6 = ParsedICMPv6Rule{ 319 | TypeCode: buf.TypeCode, 320 | Type: buf.Type, 321 | Code: buf.Code, 322 | Checksum: buf.Checksum, 323 | Payload: parsedPayload, 324 | } 325 | 326 | rule.MatchAll = !buf.Any 327 | } 328 | 329 | return rule, nil 330 | } 331 | -------------------------------------------------------------------------------- /docs/docs/rules.md: -------------------------------------------------------------------------------- 1 | Melody rules are used to apply tags on matching packets. They have multiple use cases, such as monitoring emerging threats, automated droppers, vulnerability scanners... 2 | 3 | Take a look in the `$melody/rule-available` and `$melody/internal/rules/test_resources` folders to quickly find working examples. 4 | 5 | ## First look 6 | 7 | A rule file can contain multiple rule descriptions. 8 | 9 | !!! Example 10 | This example detects CVE-2020-14882 (Oracle Weblogic RCE) scans or exploitation attempts by matching either of the two URI on the HTTP level : 11 | 12 | ```yaml 13 | CVE-2020-14882 Oracle Weblogic Server RCE: 14 | layer: http 15 | meta: 16 | id: 3e1d86d8-fba6-4e15-8c74-941c3375fd3e 17 | version: 1.0 18 | author: BonjourMalware 19 | status: stable 20 | created: 2020/11/07 21 | modified: 2020/20/07 22 | description: "Checking or trying to exploit CVE-2020-14882" 23 | references: 24 | - "https://nvd.nist.gov/vuln/detail/CVE-2020-14882" 25 | match: 26 | http.uri: 27 | startswith|any|nocase: 28 | - "/console/css/" 29 | - "/console/images" 30 | contains|any|nocase: 31 | - "console.portal" 32 | - "consolejndi.portal?test_handle=" 33 | tags: 34 | cve: "cve-2020-14882" 35 | vendor: "oracle" 36 | product: "weblogic" 37 | impact: "rce" 38 | ``` 39 | 40 | !!! Tip 41 | Check the [the `whitelist` and `blacklist` section](#whitelist-and-blacklist) to filter ports and IP addresses. 42 | 43 | ## Structure 44 | 45 | The rules have 7 sections : `layer`, `meta`, `match`, `whitelist`, `blacklist`, `tags` and `embed`. 46 | 47 | ### layer 48 | The rule will look for matches in the specified `layer`'s protocol data. 49 | 50 | Each `layer` expose different fields depending on the protocol they represent. They're detailed in the [Layers](/layers/) page. 51 | 52 | The following layers are supported : 53 | 54 | |Key|IPv4|IPv6| 55 | |---|---|---| 56 | |http|✅|✅| 57 | |tcp|✅|✅| 58 | |udp|✅|✅| 59 | |icmpv4|✅|❌| 60 | |icmpv6|❌|✅| 61 | 62 | !!! important 63 | A single rule only applies to the targeted layer. Use multiple rules if you want to match multiple layers. 64 | 65 | ### meta 66 | The `meta` section contains all the rule's metadata. Every keys are mandatory, except `references`. 67 | 68 | |Key|Type|Description|Values|Examples| 69 | |---|---|---|---|---| 70 | |**id**|*string*|Rule's unique identifier. Each rule must have a unique UUIDv4|-|
id: c30370f7-aaa8-41d0-a392-b56c94869128
| 71 | |**version**|*string*|Rule syntax version|1.0|
version: 1.0
| 72 | |**author**|*string*|The name of the rule's author|-|
author: BonjourMalware
| 73 | |**status**|*string*|The status gives an indication of the usability of the rule|stable, experimental|
status: stable
| 74 | |**created**|*yyyy/mm/dd*|Creation date|-|
created: 2020/11/07
| 75 | |**modified**|*yyyy/mm/dd*|Last modification date|-|
modified: 2020/11/07
| 76 | |**description**|*string*|A quick description of what the rule is attempting to match|-|
description: Checking or trying to exploit CVE-2020-14882
| 77 | |**references**|*array*|The status gives an indication of the usability of the rule|-|
references: 
    - "https://nvd.nist.gov/vuln/detail/CVE-2020-14882"
    - "https://github.com/jas502n/CVE-2020-14882"
    - ...
| 78 | 79 | !!! Important 80 | You must generate a new UUIDv4 for the `id` of every rule you create. 81 | 82 | Sample code for Python : 83 | ```python 84 | import uuid 85 | 86 | print(uuid.uuid4()) 87 | ``` 88 | 89 | Go ([playground](https://play.golang.org/p/9qDBHpZ2QqY)) : 90 | ```go 91 | package main 92 | 93 | import ( 94 | "fmt" 95 | "github.com/google/uuid" 96 | ) 97 | 98 | func main(){ 99 | fmt.Println(uuid.New()) 100 | } 101 | ``` 102 | 103 | ### match 104 | The `match` block contains a set of *conditions* that will be checked on every packet of the rule's `layer` type. 105 | 106 | Here is the structure of the `match` section : 107 | 108 | ```yaml 109 | match: 110 | any: [true|false] # false by default 111 | field1: # complex condition 112 | any: [true|false] # false by default 113 | operator1|modifier1|modifier2: # matching operator with its modifiers 114 | - value1 115 | - value2 116 | operator2: 117 | - value 118 | field2: # array condition 119 | - value1 120 | - value2 121 | field3: value # string or number condition 122 | ``` 123 | 124 | #### Conditions 125 | A *condition* corresponds to a field in a packet, specified by its name. 126 | 127 | The available *conditions* depends on the `layer` key. The keys are namespaced according to the type they belong to. 128 | 129 | !!! Example 130 | `udp.payload`, `tcp.flags`, `http.uri`... 131 | 132 | There are 3 types of *conditions* : `number`, `flags` or `complex`. 133 | 134 | ##### Number 135 | A number. 136 | 137 | !!! Example 138 | ```yaml 139 | tcp.window: 512 140 | ``` 141 | 142 | !!! Note 143 | The `number` types takes advantage of YAML to support octal (0o1234), hex (0x1234) and decimal (1234) representation. 144 | 145 | ##### Flags 146 | 147 | `flags` *condition* are made of a list of flag combination to match. 148 | 149 | The *condition* is valid as soon as a match is found (OR). 150 | 151 | !!! Example 152 | ``` 153 | tcp.flags: 154 | - "PA" 155 | - "S" 156 | ``` 157 | 158 | This rule will match a TCP packet with its flag bits set to "PA" (PSH-ACK, 0x18) or "S" (SYN, 0x2). 159 | 160 | !!! Note 161 | Only two fields support `flags` *condition* : `tcp.flags` and `tcp.fragbits`. 162 | 163 | ##### Complex 164 | 165 | The `complex` *condition* type supports *matching operators* and inline *modifiers*. 166 | 167 | To check which fields support `complex` *conditions*, take a look at the [layers documentation](/layers). 168 | 169 | ###### Matching operators 170 | The *matching operator* specifies how to handle data. 171 | 172 | A single *condition* can be made of a set of *matching operators*. 173 | 174 | !!! Important 175 | By default, a rule needs to validate all the *conditions* to match. However, you can specify `any: true` to force a rule to test all of its conditions and return a match as soon as it find a valid one. 176 | 177 | !!! Example 178 | ```yaml 179 | udp.payload: 180 | contains: 181 | - "after all, we're all alike." 182 | startswith: 183 | - "Damn kids" 184 | any: true 185 | ``` 186 | 187 | In this example, the *condition* key is `udp.payload` and the *matching operators* are `contains` and `startswith`. 188 | 189 | This rule will match if the payload of an UDP packet startswith the string "Damn kids" OR contains "after all, we're all alike.". 190 | 191 | The rule needs both to match if we remove the `any: true` option. 192 | 193 | |Name|Description| 194 | |---|---| 195 | |is|The packet's field value is **strictly equal** to the *condition*'s value| 196 | |contains|The packet's field value **contains** the *condition*'s value| 197 | |startswith|The packet's field value **starts** with the *condition*'s value| 198 | |endswith|The packet's field value **ends** with the *condition*'s value| 199 | 200 | ###### Modifiers 201 | *Modifiers* are a way to quickly set options for the *matching operator*. 202 | 203 | They live on the same line, split by `|`. All *modifiers* can be mixed at once. 204 | 205 | !!! Important 206 | By default, a *condition* needs to match all of the given values (AND). However, you can use the `|any` *modifier* to reverse it and force it to test all the values and to return on the first match. 207 | 208 | !!! Example 209 | ```yaml 210 | http.body: 211 | contains|any|nocase: 212 | - "Enter my world" 213 | - "the beauty of the baud" 214 | ``` 215 | 216 | In this example, the *modifiers* are `any` and `nocase`. This rule will match if the URI field of an HTTP packet contains any item in the list. 217 | 218 | |Name|Description|Example| 219 | |---|---|---| 220 | |any|The rule match if **any** of the values in the list matches|-| 221 | |nocase|The match is **case insensitive**|abcd == aBcD == ABCD| 222 | |regex|The value is a **regular expression**|'(?:[0-9]{1,3}\.){3}[0-9]{1,3}' == 192.0.2.1| 223 | 224 | !!! Danger 225 | Although the regex is compiled only once, it can cause severe overhead while matching packets. Use it with caution. 226 | 227 | ###### Hybrid pattern 228 | 229 | `complex` *condition*'s support hex values by wrapping them between two `|`. 230 | 231 | You can mix hex and ascii in a single string as well. 232 | 233 | !!! Example 234 | ```yaml 235 | http.body: 236 | contains: 237 | - "|45 6e 74 65 72206d79| world" 238 | ``` 239 | 240 | !!! Note 241 | '0x' hex notation (`|0xbe 0xef|`) is invalid. You can mix spaced and not spaced hex bytes though. 242 | 243 | 244 | ### tags 245 | Each of the key/value pair in the `tags` object will be appended to the `matches` field of each of the matching packets. 246 | 247 | ### embed 248 | This is a block where the user can will embed any data in the `embedded` key of the matching packet. It can be used as an alternative to `tags` to add contextual information. 249 | 250 | !!! Example 251 | ```yaml 252 | embed: 253 | my_crime: "curiosity" 254 | ... 255 | ``` 256 | 257 | ### whitelist and blacklist 258 | 259 | These two fields can be used to filter the packets on which the rule is applied. 260 | 261 | IP source addresses and ports are supported. 262 | 263 | #### IP address 264 | 265 | !!! Example 266 | ```yaml 267 | whitelist: 268 | ips: 269 | - 127.0.0.1 270 | ``` 271 | 272 | This example only tries to match the packets coming from 127.0.0.1. 273 | 274 | ```yaml 275 | blacklist: 276 | ips: 277 | - 127.0.0.1 278 | ``` 279 | 280 | Use the blacklist keyword to reverse the logic and apply the rule to all packets but the one coming from 127.0.0.1. 281 | 282 | !!! Example 283 | ```yaml 284 | whitelist: 285 | ips: 286 | - 127.0.0.0/24 287 | ``` 288 | 289 | CIDR notation supported. 290 | 291 | #### Ports 292 | 293 | !!! Example 294 | ```yaml 295 | whitelist: 296 | ports: 297 | - 80 298 | ``` 299 | 300 | This example only tries to match the packets going to port 80 . 301 | 302 | ```yaml 303 | blacklist: 304 | ports: 305 | - 80 306 | ``` 307 | 308 | Use the blacklist keyword to reverse the logic and apply the rule to all packets but the one going to port 80. 309 | 310 | !!! Example 311 | ```yaml 312 | whitelist: 313 | ports: 314 | - 8000 - 9000 315 | ``` 316 | 317 | Port ranges are supported. You can choose to put spaces or not. 318 | --------------------------------------------------------------------------------