summaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go466
1 files changed, 466 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d5f8d69
--- /dev/null
+++ b/main.go
@@ -0,0 +1,466 @@
1package main
2
3import (
4 "bytes"
5 "embed"
6 "fmt"
7 "io"
8 "io/fs"
9 "io/ioutil"
10 "log"
11 "net/http"
12 "os"
13 "path"
14 "path/filepath"
15 "strings"
16 "time"
17
18 "github.com/hillu/go-yara/v4"
19 "github.com/jessevdk/go-flags"
20)
21
22const (
23 RulesURI = "https://raw.githubusercontent.com/jvoisin/php-malware-finder/master/php-malware-finder/data"
24 RulesFile = "php.yar"
25 ScanMaxDuration = time.Duration(60)
26 TooShort = "TooShort"
27 TooShortMaxLines = 2
28 TooShortMinChars = 300
29 DangerousMatchWeight = 2
30 DangerousMinScore = 3
31 FileBufferSize = 32 * 1024 // 32KB
32 YaraMaxThreads = 32
33 TempDirPrefix = "pmf-"
34)
35
36var (
37 args struct { // command-line arguments specs using github.com/jessevdk/go-flags
38 RulesDir string `short:"r" long:"rules-dir" description:"Alternative rules location (default: embedded rules)"`
39 ShowAll bool `short:"a" long:"show-all" description:"Display all matched rules"`
40 Fast bool `short:"f" long:"fast" description:"Enable YARA's fast mode"`
41 RateLimit int `short:"R" long:"rate-limit" description:"Max. filesystem ops per second, 0 for no limit" default:"0"`
42 Verbose bool `short:"v" long:"verbose" description:"Verbose mode"`
43 Workers int `short:"w" long:"workers" description:"Number of workers to spawn for scanning" default:"32"`
44 LongLines bool `short:"L" long:"long-lines" description:"Check long lines"`
45 ExcludeCommon bool `short:"c" long:"exclude-common" description:"Do not scan files with common extensions"`
46 ExcludeImgs bool `short:"i" long:"exclude-imgs" description:"Do not scan image files"`
47 ExcludedExts []string `short:"x" long:"exclude-ext" description:"Additional file extensions to exclude"`
48 Update bool `short:"u" long:"update" description:"Update rules"`
49 ShowVersion bool `short:"V" long:"version" description:"Show version number and exit"`
50 Positional struct {
51 Target string
52 } `positional-args:"yes"`
53 }
54 scanFlags yara.ScanFlags
55 stoppedWorkers int
56 lineFeed = []byte{'\n'}
57 dangerousMatches = map[string]struct{}{
58 "PasswordProtection": {},
59 "Websites": {},
60 "TooShort": {},
61 "NonPrintableChars": {},
62 }
63 excludedDirs = [...]string{
64 "/.git/", "/.hg/", "/.svn/", "/.CVS/",
65 }
66 excludedExts = map[string]struct{}{}
67 commonExts = [...]string{
68 ".js", ".coffee", ".map", ".min", ".css", ".less", // static files
69 ".zip", ".rar", ".7z", ".gz", ".bz2", ".xz", ".tar", ".tgz", // archives
70 ".txt", ".csv", ".json", ".rst", ".md", ".yaml", ".yml", // plain text
71 ".so", ".dll", ".bin", ".exe", ".bundle", // binaries
72 }
73 imageExts = [...]string{
74 ".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".ico",
75 }
76 scannedFilesCount int
77 rulesFiles = [...]string{
78 RulesFile, "whitelist.yar",
79 "whitelists/custom.yar", "whitelists/drupal.yar", "whitelists/magento1ce.yar", "whitelists/magento2.yar",
80 "whitelists/phpmyadmin.yar", "whitelists/prestashop.yar", "whitelists/symfony.yar", "whitelists/wordpress.yar",
81 }
82 tempDirPathPrefix = path.Join(os.TempDir(), TempDirPrefix)
83 version = "dev"
84
85 //go:embed data/php.yar data/whitelist.yar data/whitelists
86 data embed.FS
87)
88
89// handleError is a generic error handler which displays an error message to the user and exits if required.
90func handleError(err error, desc string, isFatal bool) {
91 if err != nil {
92 if desc != "" {
93 desc = " " + desc + ":"
94 }
95 log.Println("[ERROR]"+desc, err)
96 if isFatal {
97 os.Exit(1)
98 }
99 }
100}
101
102// writeRulesFiles copies the rules from the content of a `fs.FS` to a temporary folder and
103// returns its location.
104func writeRulesFiles(content fs.FS) string {
105 // create temporary folder structure
106 rulesPath, err := ioutil.TempDir(os.TempDir(), TempDirPrefix)
107 handleError(err, "unable to create temporary folder", true)
108 err = os.Mkdir(path.Join(rulesPath, "whitelists"), 0755)
109 handleError(err, "unable to create temporary subfolder", true)
110
111 // write each YARA file to the disk
112 for _, rulesFile := range rulesFiles {
113 // read embedded content
114 f, err := content.Open(path.Join("data", rulesFile))
115 handleError(err, "unable to read embedded rule", true)
116 ruleData, err := ioutil.ReadAll(f)
117
118 // write to temporary file
119 err = os.WriteFile(path.Join(rulesPath, rulesFile), ruleData, 0640)
120 handleError(err, "unable to write rule to disk", true)
121 err = f.Close()
122 handleError(err, "unable to close rules file", false)
123 }
124 return rulesPath
125}
126
127// updateRules downloads latest YARA rules from phpmalwarefinder GitHub repository.
128// Download location is either `args.RulesDir`, `/etc/phpmalwarefinder`, or the current directory.
129func updateRules() {
130 if strings.HasPrefix(args.RulesDir, tempDirPathPrefix) {
131 handleError(fmt.Errorf("rules folder must be specified to update"), "", true)
132 }
133 if args.Verbose {
134 log.Println("[DEBUG] updating ruleset")
135 }
136
137 downloadFile := func(uri string) []byte {
138 resp, err := http.Get(uri)
139 handleError(err, "unable to download rule", true)
140 defer func() {
141 err := resp.Body.Close()
142 handleError(err, "unable to close response body", false)
143 }()
144 data, err := ioutil.ReadAll(resp.Body)
145 handleError(err, "unable to read response body", false)
146 return data
147 }
148 writeFile := func(dst string, data []byte) {
149 err := ioutil.WriteFile(dst, data, 0640)
150 handleError(err, "unable to write downloaded file", true)
151 }
152
153 rulesFiles := [...]string{
154 RulesFile,
155 "whitelist.yar", "whitelists/drupal.yar", "whitelists/magento1ce.yar",
156 "whitelists/magento2.yar", "whitelists/phpmyadmin.yar", "whitelists/prestashop.yar",
157 "whitelists/symfony.yar", "whitelists/wordpress.yar"}
158
159 // download rules
160 for _, rule := range rulesFiles {
161 rulesUri := RulesURI + rule
162 data := downloadFile(rulesUri)
163 outPath := path.Join(args.RulesDir, rule)
164 writeFile(outPath, data)
165 log.Println("[INFO] updated rule:", rule)
166 }
167}
168
169// fileStats takes a file path as argument and returns its lines and characters count.
170// File reading is done using a 32KB buffer to minimize memory usage.
171func fileStats(filepath string) (int, int, error) {
172 f, err := os.Open(filepath)
173 if err != nil {
174 return 0, 0, err
175 }
176 defer func() {
177 err := f.Close()
178 handleError(err, "unable to close file", false)
179 }()
180 charCount, lineCount := 0, 0
181 buf := make([]byte, FileBufferSize)
182 for {
183 chunkSize, err := f.Read(buf)
184 charCount += chunkSize
185 lineCount += bytes.Count(buf[:chunkSize], lineFeed)
186 switch {
187 case err == io.EOF:
188 return charCount, lineCount, nil
189 case err != nil:
190 return charCount, lineCount, err
191 }
192 }
193}
194
195// makeScanner creates a YARA scanner with the appropriate options set.
196func makeScanner(rules *yara.Rules) *yara.Scanner {
197 scanner, err := yara.NewScanner(rules)
198 handleError(err, "unable to create YARA scanner", true)
199 scanner.SetFlags(scanFlags)
200 scanner.SetTimeout(ScanMaxDuration)
201 return scanner
202}
203
204// processFiles reads file paths from the `targets` channel, scans it, and writes matches to the `results` channel.
205// Scanning is done using YARA `rules`, and using `fileStats` if `args.LongLines` is set.
206// `ticker` is a `time.Time` object created with `time.Tick` used to throttle file scans to minimize impact on I/O.
207func processFiles(rules *yara.Rules, targets <-chan string, results chan<- map[string][]yara.MatchRule, ticker <-chan time.Time) {
208 scanner := makeScanner(rules)
209 for target := range targets {
210 <-ticker
211 scannedFilesCount++
212 result := map[string][]yara.MatchRule{target: {}}
213
214 if args.LongLines {
215 charCount, lineCount, err := fileStats(target)
216 handleError(err, "unable to get file stats", false)
217 if lineCount <= TooShortMaxLines && charCount >= TooShortMinChars {
218 tooShort := yara.MatchRule{Rule: TooShort}
219 result[target] = append(result[target], tooShort)
220 }
221 }
222
223 var matches yara.MatchRules
224 err := scanner.SetCallback(&matches).ScanFile(target)
225 if err != nil {
226 log.Println("[ERROR]", err)
227 continue
228 }
229 for _, match := range matches {
230 result[target] = append(result[target], match)
231 }
232 results <- result
233 }
234 stoppedWorkers++
235 if stoppedWorkers == args.Workers {
236 close(results)
237 }
238}
239
240// scanDir recursively crawls `dirName`, and writes file paths to the `targets` channel.
241// Files sent to `targets` are filtered according to their extensions.
242func scanDir(dirName string, targets chan<- string, ticker <-chan time.Time) {
243 visit := func(pathName string, fileInfo os.FileInfo, err error) error {
244 <-ticker
245 if !fileInfo.IsDir() {
246 for _, dir := range excludedDirs {
247 if strings.Contains(pathName, dir) {
248 return nil
249 }
250 }
251 fileExt := filepath.Ext(fileInfo.Name())
252 if _, exists := excludedExts[fileExt]; !exists {
253 targets <- pathName
254 }
255 }
256 return nil
257 }
258 err := filepath.Walk(dirName, visit)
259 handleError(err, "unable to complete target crawling", false)
260 close(targets)
261}
262
263// loadRulesFile reads YARA rules from specified `fileName` and returns
264// them in their compiled form.
265func loadRulesFile(fileName string) (*yara.Rules, error) {
266 var err error = nil
267 // record working directory and move to rules location
268 curDir, err := os.Getwd()
269 if err != nil {
270 return nil, fmt.Errorf("unable to determine working directory: %v", err)
271 }
272 ruleDir, ruleName := filepath.Split(fileName)
273 err = os.Chdir(ruleDir)
274 if err != nil {
275 return nil, fmt.Errorf("unable to move to rules directory: %v", err)
276 }
277
278 // read file content
279 data, err := ioutil.ReadFile(ruleName)
280 if err != nil {
281 return nil, fmt.Errorf("unable to read rules file: %v", err)
282 }
283
284 // compile rules
285 rules, err := yara.Compile(string(data), nil)
286 if err != nil {
287 return nil, fmt.Errorf("unable to load rules: %v", err)
288 }
289
290 // move back to working directory
291 err = os.Chdir(curDir)
292 if err != nil {
293 return nil, fmt.Errorf("unable to move back to working directory: %v", err)
294 }
295
296 return rules, nil
297}
298
299func main() {
300 startTime := time.Now()
301 _, err := flags.Parse(&args)
302 if err != nil {
303 os.Exit(1)
304 }
305 if args.ShowVersion {
306 println(version)
307 os.Exit(0)
308 }
309
310 // check rules path
311 if args.RulesDir == "" {
312 args.RulesDir = writeRulesFiles(data)
313 }
314 if args.Verbose {
315 log.Println("[DEBUG] rules directory:", args.RulesDir)
316 }
317
318 // update rules if required
319 if args.Update {
320 updateRules()
321 os.Exit(0)
322 }
323
324 // add custom excluded file extensions
325 if args.ExcludeCommon {
326 for _, commonExt := range commonExts {
327 excludedExts[commonExt] = struct{}{}
328 }
329 }
330 if args.ExcludeImgs || args.ExcludeCommon {
331 for _, imgExt := range imageExts {
332 excludedExts[imgExt] = struct{}{}
333 }
334 }
335 for _, ext := range args.ExcludedExts {
336 if string(ext[0]) != "." {
337 ext = "." + ext
338 }
339 excludedExts[ext] = struct{}{}
340 }
341 if args.Verbose {
342 extList := make([]string, len(excludedExts))
343 i := 0
344 for ext := range excludedExts {
345 extList[i] = ext[1:]
346 i++
347 }
348 log.Println("[DEBUG] excluded file extensions:", strings.Join(extList, ","))
349 }
350
351 // load YARA rules
352 rulePath := path.Join(args.RulesDir, RulesFile)
353 rules, err := loadRulesFile(rulePath)
354 handleError(err, "", true)
355 if args.Verbose {
356 log.Println("[DEBUG] ruleset loaded:", rulePath)
357 }
358
359 // set YARA scan flags
360 if args.Fast {
361 scanFlags = yara.ScanFlags(yara.ScanFlagsFastMode)
362 } else {
363 scanFlags = yara.ScanFlags(0)
364 }
365
366 // check if requested threads count is not greater than YARA's MAX_THREADS
367 if args.Workers > YaraMaxThreads {
368 log.Printf("[WARNING] workers count too high, using %d instead of %d\n", YaraMaxThreads, args.Workers)
369 args.Workers = YaraMaxThreads
370 }
371
372 // scan target
373 if f, err := os.Stat(args.Positional.Target); os.IsNotExist(err) {
374 handleError(err, "", true)
375 } else {
376 if args.Verbose {
377 log.Println("[DEBUG] scan workers:", args.Workers)
378 log.Println("[DEBUG] target:", args.Positional.Target)
379 }
380 if f.IsDir() { // parallelized folder scan
381 // create communication channels
382 targets := make(chan string)
383 results := make(chan map[string][]yara.MatchRule)
384
385 // rate limit
386 var tickerRate time.Duration
387 if args.RateLimit == 0 {
388 tickerRate = time.Nanosecond
389 } else {
390 tickerRate = time.Second / time.Duration(args.RateLimit)
391 }
392 ticker := time.Tick(tickerRate)
393 if args.Verbose {
394 log.Println("[DEBUG] delay between fs ops:", tickerRate.String())
395 }
396
397 // start consumers and producer workers
398 for w := 1; w <= args.Workers; w++ {
399 go processFiles(rules, targets, results, ticker)
400 }
401 go scanDir(args.Positional.Target, targets, ticker)
402
403 // read results
404 matchCount := make(map[string]int)
405 var keepListing bool
406 var countedDangerousMatch bool
407 for result := range results {
408 for target, matchedSigs := range result {
409 keepListing = true
410 matchCount[target] = 0
411 countedDangerousMatch = false
412 for _, sig := range matchedSigs {
413 matchCount[target] += DangerousMatchWeight
414 if !countedDangerousMatch {
415 if _, exists := dangerousMatches[sig.Rule]; exists {
416 matchCount[target]++
417 }
418 countedDangerousMatch = true
419 }
420 if keepListing {
421 log.Printf("[WARNING] match found: %s (%s)\n", target, sig.Rule)
422 if !args.ShowAll {
423 keepListing = false
424 }
425 }
426 }
427 }
428 }
429 for target, count := range matchCount {
430 if count >= DangerousMinScore {
431 log.Println("[WARNING] dangerous file found:", target)
432 }
433 }
434 } else { // single file mode
435 scannedFilesCount++
436 var matches yara.MatchRules
437 scanner := makeScanner(rules)
438 err := scanner.SetCallback(&matches).ScanFile(args.Positional.Target)
439 handleError(err, "unable to scan target", true)
440 for _, match := range matches {
441 log.Println("[WARNING] match found:", args.Positional.Target, match.Rule)
442 if args.Verbose {
443 for _, matchString := range match.Strings {
444 log.Printf("[DEBUG] match string for %s: 0x%x:%s: %s\n", args.Positional.Target, matchString.Offset, matchString.Name, matchString.Data)
445 }
446 }
447 if !args.ShowAll {
448 break
449 }
450 }
451 }
452 if args.Verbose {
453 endTime := time.Now()
454 log.Printf("[DEBUG] scanned %d files in %s\n", scannedFilesCount, endTime.Sub(startTime).String())
455 }
456 }
457
458 // delete temporary files
459 if strings.HasPrefix(args.RulesDir, tempDirPathPrefix) {
460 if args.Verbose {
461 log.Println("[DEBUG] deleting temporary folder:", args.RulesDir)
462 }
463 err := os.RemoveAll(args.RulesDir)
464 handleError(err, "unable to delete temporary folder", true)
465 }
466}