diff --git a/README.md b/README.md index e9b55f4..3486845 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ COMMANDS: zip-rules creates an encrypted zip containing compiled yara rules join joins dumps with padding crash-process, crash crash a process + as-service executes yapscan as a windows service (windows only) help, h Shows a list of commands or help for one command ``` @@ -105,6 +106,32 @@ yapscan scan -r rules.zip --filter-permissions-exact rx --all-processes yapscan --log-level debug --log-path yapscan.log scan -r rules.zip --full-report --store-dumps --all-processes ``` +## Running as Service + +Yapscan can be run as a windows service in order to gain SYSTEM privileges. +This allows you to crash even other windows services, using the crash command. +Running as service is currently an **experimental feature**. + +For memory scanning this should not be necessary. +In my experiments it has been sufficient to run yapscan as administrator in order to read the memory of any process. +If you find a process that yapscan cannot scan with administrator privileges but that can be scanned as a service, please let me know in the [issues](https://github.com/fkie-cad/yapscan/issues/new). + +In order to use yapscan as a service just prepend the `as-service` command to the command (and flags) you wish to execute. +Example: + +```shell +# Normal mode +.\yapscan.exe crash 42 +# Service mode +.\yapscan.exe as-service crash 42 +``` + +The output of the windows service is transmitted to the terminal via two TCP connections. +If this breaks a warning will be emitted. +In such a case the service may still be running, you just won't see any output. +Also CTRL-C will break the proxy command, preventing you from seeing any output, but will not affect the running service. +If you want to kill the service, you'll have to use the windows service manager for now. + ## Executable DLL **The DLL built by this project is not a usual DLL, meant for importing functions from.** @@ -126,7 +153,7 @@ extern void run(HWND hWnd, HINSTANCE hInst, LPTSTR lpCmdLine, int nCmdShow); Some environments like VDIs (Virtual Desktop Infrastructure) may prevent the execution of arbitrary exe-files but still allows for use of arbitrary DLLs. If you gain access to a command line terminal in such an environment you can call yapscan via the built DLL like so. -``` +```shell rundll32.exe yapscan.dll,run scan -r rules.zip --all-processes ``` diff --git a/acceptanceTests/reports_test.go b/acceptanceTests/reports_test.go index 5071266..1c0d20f 100644 --- a/acceptanceTests/reports_test.go +++ b/acceptanceTests/reports_test.go @@ -53,7 +53,7 @@ func TestMatchIsFound(t *testing.T) { "--filter-size-max", maxSizeFilter, strconv.Itoa(pid)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -90,7 +90,7 @@ func TestMatchIsFound_Fuzzy(t *testing.T) { "--filter-size-max", maxSizeFilter, strconv.Itoa(pid)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -133,7 +133,7 @@ func TestDoesNotMatchFalsePositive_Fuzzy(t *testing.T) { "--filter-size-max", maxSizeFilter, strconv.Itoa(pid)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -164,7 +164,7 @@ func TestFullReportIsWritten_Unencrypted(t *testing.T) { "--full-report", "--report-dir", reportDir, strconv.Itoa(pid)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -190,7 +190,7 @@ func TestFullReportIsWritten_Unencrypted_WhenScanningTwoProcesses(t *testing.T) "--full-report", "--report-dir", reportDir, strconv.Itoa(matchingPID), strconv.Itoa(nonMatchingPID)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -215,7 +215,7 @@ func TestFullReportIsWritten_Unencrypted_WhenScanningTwoProcesses(t *testing.T) "--full-report", "--report-dir", reportDir, strconv.Itoa(nonMatchingPID), strconv.Itoa(matchingPID)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -244,7 +244,7 @@ func TestPasswordProtectedFullReport(t *testing.T) { "--full-report", "--report-dir", reportDir, strconv.Itoa(pid)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -273,7 +273,7 @@ func TestPGPProtectedFullReport(t *testing.T) { "--full-report", "--report-dir", reportDir, strconv.Itoa(pid)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() @@ -300,7 +300,7 @@ func TestAnonymizedFullReport(t *testing.T) { "--full-report", "--report-dir", reportDir, strconv.Itoa(pid)} ctx, cancel := context.WithTimeout(context.Background(), yapscanTimeout) - err := app.MakeApp(args).RunContext(ctx, args) + err := app.MakeApp().RunContext(ctx, args) cancel() cleanupCapture() diff --git a/app/app.go b/app/app.go index cb573da..60e3c31 100644 --- a/app/app.go +++ b/app/app.go @@ -149,7 +149,7 @@ func askYesNoAlwaysNever(msg string) (yes bool, always bool, never bool) { return } -func MakeApp(args []string) *cli.App { +func MakeApp() *cli.App { suspendFlags := []cli.Flag{ &cli.BoolFlag{ Name: "suspend", @@ -454,18 +454,23 @@ func MakeApp(args []string) *cli.App { if runtime.GOOS == "windows" { app.Commands = append(app.Commands, &cli.Command{ - Name: "as-service", - Usage: "executes yapscan as a windows service", + Name: "as-service", + Usage: "executes yapscan as a windows service", + SkipFlagParsing: true, Action: func(c *cli.Context) error { - // This is a dummy - return cli.Exit("\"as-service\" must be the first argument", 1) + if len(os.Args) < 2 || os.Args[1] != "as-service" { + return cli.Exit("\"as-service\" must be the first argument", 1) + } + if len(os.Args) == 2 { + return cli.Exit("not enough arguments for \"as-service\", "+ + "please provide the command to execute as a service", 1) + } + args := make([]string, 0, len(os.Args)-1) + args = append(args, os.Args[0]) + args = append(args, os.Args[2:]...) // Cut out the "as-service" argument + return asService(args) }, }) - - if len(args) >= 2 && args[1] == "as-service" { - asService(args) - return nil - } } return app diff --git a/app/asService.go b/app/asService.go index 6714838..c7f8f0c 100644 --- a/app/asService.go +++ b/app/asService.go @@ -1,10 +1,19 @@ package app import ( + "context" + "errors" "fmt" "os" "os/exec" "path/filepath" + "strconv" + "sync" + "time" + + "github.com/fkie-cad/yapscan/service/output" + + "github.com/urfave/cli/v2" ) func run(cmdName string, cmdArgs ...string) error { @@ -14,42 +23,114 @@ func run(cmdName string, cmdArgs ...string) error { return cmd.Run() } -func asService(args []string) { +func asService(args []string) error { serviceName := "yapscan" binpath, err := filepath.Abs(args[0]) if err != nil { - fmt.Printf("ERROR: Could not determine absolute path of yapscan.exe, %v\n", err) - os.Exit(1) + return cli.Exit(fmt.Sprintf("ERROR: Could not determine absolute path of yapscan.exe, %v\n", err), 1) } - args = args[2:] - scStartArguments := []string{"start", serviceName} - scStartArguments = append(scStartArguments, args...) - fmt.Println("WARNING: This feature is experimental!") - fmt.Println("WARNING: You will not see any output of the executed service. Using --log-path is strongly advised.") fmt.Println("Removing service in case it exists already...") - run("sc.exe", "delete", "yapscan") - fmt.Println("Done removing.") + err = run("sc.exe", "delete", "yapscan") + if err != nil { + fmt.Printf("WARNING: Could not remove service, %v\n", err) + } else { + fmt.Println("Done removing.") + } fmt.Println("Installing service...") err = run("sc.exe", "create", serviceName, "type=", "own", "start=", "demand", "binpath=", binpath) if err != nil { - fmt.Println("FAILURE") - fmt.Print(err) - os.Exit(10) + return cli.Exit(fmt.Sprintf("FAILURE: %v\n", err), 10) } fmt.Println("Done installing service.") + fmt.Println("Starting output proxy...") + proxy := output.NewOutputProxyServer() + err = proxy.Listen() + defer proxy.Close() + + outputReadyForConnection := &sync.WaitGroup{} + connectionSuccess := false + waitConnection := &sync.WaitGroup{} + + if err != nil { + proxy = nil + fmt.Printf("WARNING: Could not start output proxy, the service will still be started, but no output will be visible. Reason: %v\n", err) + } else { + outputReadyForConnection.Add(1) + waitConnection.Add(1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + defer func() { + waitConnection.Done() + }() + + outputReadyForConnection.Done() + var err error + err = proxy.WaitForConnection(ctx) + + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + fmt.Println("WARNING: Output proxy connection timed out. The service was likely still started, but no output will be visible.") + return + } + if err != nil { + fmt.Printf("WARNING: Service did not connect to output proxy. The service was likely still started, but no output will be visible. Reason: %v\n", err) + return + } + + connectionSuccess = true + }() + fmt.Println("Done, proxy is waiting for connections.") + } + + scStartArguments := []string{"start", serviceName} + if proxy != nil { + scStartArguments = append(scStartArguments, strconv.Itoa(proxy.StdoutPort()), strconv.Itoa(proxy.StderrPort())) + } else { + scStartArguments = append(scStartArguments, "0", "0") + } + scStartArguments = append(scStartArguments, args...) + + outputReadyForConnection.Wait() + fmt.Println("Starting service with arguments...") err = run("sc.exe", scStartArguments...) if err != nil { - fmt.Println("FAILURE") - fmt.Print(err) - os.Exit(11) + return cli.Exit(fmt.Sprintf("FAILURE: %v\n", err), 11) } fmt.Println("Done starting service, yapscan should now be running as a service with the following arguments") fmt.Println(args) + + if proxy != nil { + waitConnection.Wait() + if !connectionSuccess { + return nil + } + + fmt.Println() + fmt.Println("========== Yapscan Service Output ==========") + + outputDone := &sync.WaitGroup{} + outputDone.Add(1) + go func() { + var err error + defer func() { + outputDone.Done() + }() + + err = proxy.ReceiveAndOutput(context.Background(), os.Stdout, os.Stderr) + if err != nil { + fmt.Printf("WARNING: Output proxy connection broke. The service might still be running, but you will not see any further output. Reason: %v\n", err) + } + }() + outputDone.Wait() + } + + return nil } diff --git a/cicd/release.sh b/cicd/release.sh index 61b96f8..0119356 100755 --- a/cicd/release.sh +++ b/cicd/release.sh @@ -2,11 +2,6 @@ zstd_level=12 -if [[ "$GITHUB_USERNAME" == "" || "$GITHUB_TOKEN" == "" ]]; then - echo "ERROR: Missing GITHUB_USERNAME or GITHUB_TOKEN environment variables." - exit 1 -fi - while [[ $# -gt 0 ]]; do key="$1" @@ -21,30 +16,7 @@ while [[ $# -gt 0 ]]; do done set -- "${POSITIONAL[@]}" # restore positional parameters -function curl-authenticated() { - curl -u "$GITHUB_USERNAME:$GITHUB_TOKEN" "$@" -} - -function githubApi() { - endpoint="$1" - shift - curl-authenticated -H "Accept: application/vnd.github.v3+json" "https://api.github.com/repos/fkie-cad/yapscan$endpoint" "$@" -} - -function getDownloadURL() { - # Assuming the first entry is the latest artifact - githubApi /actions/artifacts | jq -c '.artifacts[] | {name: .name, url: .archive_download_url}' | grep "$1" | head -n1 | jq -r '.url' -} - -function download() { - url=$(getDownloadURL "$1") - if [[ "$url" == "" ]]; then - echo "ERROR: Could not get download url for artifact '$1'." - exit 1 - fi - echo "Downloading from $url" - curl-authenticated -L -o "$1.zip" "$url" -} +repo="fkie-cad/yapscan" wd=$(pwd) tmpdir=$(mktemp -d) @@ -54,22 +26,21 @@ if [[ ! "$tmpdir" || ! -d "$tmpdir" ]]; then exit 10 fi -pushd "$tmpdir" || exit 11 +runId=$(gh run list -q '.[] | select(.headBranch == "master")' --json headBranch,conclusion,databaseId -L1 | jq '.databaseId') +gh -R $repo run download -D "$tmpdir" $runId || exit 11 -download yapscan-linux || exit 12 -download deps-linux || exit 12 -download yapscan-windows || exit 12 +pushd "$tmpdir" || exit 11 mkdir yapscan_linux_amd64 yapscan_windows_amd64 pushd yapscan_linux_amd64 || exit 11 -7z x ../deps-linux.zip || exit 13 -7z x ../yapscan-linux.zip || exit 13 +mv ../deps-linux/* . || exit 12 +mv ../yapscan-linux/* . || exit 13 chmod +x yapscan popd || exit 11 pushd yapscan_windows_amd64 || exit 11 -7z x ../yapscan-windows.zip || exit 13 +mv ../yapscan-windows/* . || exit 13 chmod +x yapscan.exe popd || exit 11 @@ -91,24 +62,12 @@ if [[ "$CREATE_RELEASE" != "1" ]]; then fi echo -echo "Creating release draft $RELEASE_TAG..." +echo "Creating release draft $RELEASE_TAG and uploading assets..." -upload_url=$(githubApi /releases -X POST -d '{"tag_name":"'$RELEASE_TAG'", "draft": true}' | jq -r '.upload_url') -if [[ "$?" != "0" ]]; then - echo "ERROR: Could not create release!" - exit 15 -fi -upload_url=${upload_url%\{*} - -echo "Uploading assets to $upload_url..." - -curl-authenticated -L -X POST -H "Content-Type: application/octet-stream" \ - --data-binary @"yapscan_linux_amd64.zip" "${upload_url}?name=yapscan_linux_amd64.zip" || exit 16 -curl-authenticated -L -X POST -H "Content-Type: application/octet-stream" \ - --data-binary @"yapscan_linux_amd64.tar.zst" "${upload_url}?name=yapscan_linux_amd64.tar.zst" || exit 16 -curl-authenticated -L -X POST -H "Content-Type: application/octet-stream" \ - --data-binary @"yapscan_windows_amd64.zip" "${upload_url}?name=yapscan_windows_amd64.zip" || exit 16 -curl-authenticated -L -X POST -H "Content-Type: application/octet-stream" \ - --data-binary @"yapscan_windows_amd64.tar.zst" "${upload_url}?name=yapscan_windows_amd64.tar.zst" || exit 16 +gh -R $repo release create -d $RELEASE_TAG \ + "yapscan_linux_amd64.zip" \ + "yapscan_linux_amd64.tar.zst" \ + "yapscan_windows_amd64.zip" \ + "yapscan_windows_amd64.tar.zst" || exit 15 echo "Done" diff --git a/cmd/yapscan-dll/main_windows.go b/cmd/yapscan-dll/main_windows.go index ceb67fb..9fc6deb 100644 --- a/cmd/yapscan-dll/main_windows.go +++ b/cmd/yapscan-dll/main_windows.go @@ -43,7 +43,7 @@ func start(argc C.int, argv **C.char) C.int { for i := range args { args[i] = C.GoString(C.arg_index(argv, C.int(i))) } - err := app.MakeApp(args).Run(args) + err := app.MakeApp().Run(args) if err != nil { fmt.Println(err) logrus.Error(err) @@ -99,7 +99,7 @@ func run(hWnd C.HWND, hInst C.HINSTANCE, lpCmdLine C.LPTSTR, nCmdShow C.int) { args, _ := shlex.Split(str) args = append([]string{"rundll32.exe"}, args...) - err = app.MakeApp(args).Run(args) + err = app.MakeApp().Run(args) if err != nil { fmt.Println(err) logrus.Error(err) diff --git a/cmd/yapscan/main.go b/cmd/yapscan/main.go index f9365ae..2eb3925 100644 --- a/cmd/yapscan/main.go +++ b/cmd/yapscan/main.go @@ -13,7 +13,7 @@ import ( var onExit func() func runApp(args []string) { - err := app.MakeApp(args).Run(args) + err := app.MakeApp().Run(args) if err != nil { fmt.Println(err) diff --git a/service/output/output.go b/service/output/output.go new file mode 100644 index 0000000..ed46a90 --- /dev/null +++ b/service/output/output.go @@ -0,0 +1,200 @@ +package output + +import ( + "context" + "fmt" + "io" + "net" + "os" + "strconv" + "strings" + "time" + + "github.com/targodan/go-errors" +) + +type OutputProxyServer struct { + outListener net.Listener + errListener net.Listener + + outConn net.Conn + errConn net.Conn +} + +func NewOutputProxyServer() *OutputProxyServer { + return &OutputProxyServer{} +} + +func (p *OutputProxyServer) Listen() error { + lOut, err := net.Listen("tcp", "127.0.0.1:") + if err != nil { + return err + } + lErr, err := net.Listen("tcp", "127.0.0.1:") + if err != nil { + lOut.Close() + return err + } + + p.outListener = lOut + p.errListener = lErr + + return nil +} + +func (p *OutputProxyServer) addrToPort(addr string) int { + parts := strings.Split(addr, ":") + portStr := parts[len(parts)-1] + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + panic(err) + } + return int(port) +} + +func (p *OutputProxyServer) StdoutPort() int { + return p.addrToPort(p.outListener.Addr().String()) +} + +func (p *OutputProxyServer) StderrPort() int { + return p.addrToPort(p.errListener.Addr().String()) +} + +func (p *OutputProxyServer) acceptWatchdog(ctx context.Context) { + <-ctx.Done() + + if p.outConn == nil || p.errConn == nil { + p.Close() + } +} + +func (p *OutputProxyServer) WaitForConnection(ctx context.Context) error { + acceptCtx, cancel := context.WithCancel(ctx) + defer cancel() + + go p.acceptWatchdog(acceptCtx) + + errChan := make(chan error) + + go func() { + var err error + p.outConn, err = p.outListener.Accept() + errChan <- err + }() + + go func() { + var err error + p.errConn, err = p.errListener.Accept() + errChan <- err + }() + + var err error + err = errors.NewMultiError(err, <-errChan) + err = errors.NewMultiError(err, <-errChan) + return err +} + +func (p *OutputProxyServer) ReceiveAndOutput(ctx context.Context, stdout io.Writer, stderr io.Writer) error { + acceptCtx, cancel := context.WithCancel(ctx) + defer cancel() + + go p.acceptWatchdog(acceptCtx) + + errChan := make(chan error) + + go func() { + _, err := io.Copy(stdout, p.outConn) + errChan <- err + }() + + go func() { + _, err := io.Copy(stderr, p.errConn) + errChan <- err + }() + + var err error + err = errors.NewMultiError(err, <-errChan) + err = errors.NewMultiError(err, <-errChan) + return err +} + +func (p *OutputProxyServer) Close() (err error) { + if p.outConn != nil { + err = errors.NewMultiError(err, p.outConn.Close()) + } + if p.errConn != nil { + err = errors.NewMultiError(err, p.errConn.Close()) + } + + err = errors.NewMultiError(err, p.outListener.Close()) + err = errors.NewMultiError(err, p.errListener.Close()) + + return err +} + +type timeoutConn struct { + conn net.Conn +} + +func (c *timeoutConn) Write(b []byte) (int, error) { + c.conn.SetWriteDeadline(time.Now().Add(100 * time.Millisecond)) + return c.conn.Write(b) +} + +type OutputProxyClient struct { + outConn net.Conn + errConn net.Conn + + Stdout *os.File + Stderr *os.File +} + +func NewOutputProxyClient() *OutputProxyClient { + return &OutputProxyClient{} +} + +func (p *OutputProxyClient) Connect(stdoutPort int, stderrPort int) (err error) { + p.outConn, err = net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", stdoutPort)) + if err != nil { + return + } + p.errConn, err = net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", stderrPort)) + if err != nil { + return + } + + outR, outW, err := os.Pipe() + if err != nil { + p.Close() + return err + } + errR, errW, err := os.Pipe() + if err != nil { + p.Close() + return err + } + + go func() { + dst := &timeoutConn{conn: p.outConn} + io.Copy(dst, outR) + }() + go func() { + dst := &timeoutConn{conn: p.errConn} + io.Copy(dst, errR) + }() + + p.Stdout = outW + p.Stderr = errW + + return +} + +func (p *OutputProxyClient) Close() (err error) { + if p.outConn != nil { + err = errors.NewMultiError(err, p.outConn.Close()) + } + if p.errConn != nil { + err = errors.NewMultiError(err, p.errConn.Close()) + } + return +} diff --git a/service/service_windows.go b/service/service_windows.go index 7d0303e..b88b180 100644 --- a/service/service_windows.go +++ b/service/service_windows.go @@ -3,6 +3,9 @@ package service import ( "fmt" "os" + "strconv" + + "github.com/fkie-cad/yapscan/service/output" "golang.org/x/sys/windows" @@ -97,15 +100,55 @@ func SvcMain(dwNumServicesArgs C.DWORD, lpServiceArgVectors **C.char) { return } + out, _ := os.OpenFile("C:\\yapscanOut.txt", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666) + defer out.Close() + args := make([]string, dwNumServicesArgs) for i := range args { args[i] = C.GoString(C.arg_index(lpServiceArgVectors, C.int(i))) } + fmt.Fprintf(out, "ARGS: %v\r\n", args) + + var outputProxyClient *output.OutputProxyClient + if args[1] != "0" && args[2] != "0" { + outputProxyClient = output.NewOutputProxyClient() + stdoutPort, err := strconv.Atoi(args[1]) + if err != nil { + outputProxyClient = nil + fmt.Fprintf(out, "SERVER CONNECT ERROR: %v\r\n", err) + } + stderrPort, err := strconv.Atoi(args[2]) + if err != nil { + outputProxyClient = nil + fmt.Fprintf(out, "SERVER CONNECT ERROR: %v\r\n", err) + } + + fmt.Fprintf(out, "Got server ports %d / %d\n\n", stdoutPort, stderrPort) + + if outputProxyClient != nil { + err = outputProxyClient.Connect(stdoutPort, stderrPort) + if err != nil { + outputProxyClient = nil + } + fmt.Fprintf(out, "SERVER CONNECT ERROR: %v\r\n", err) + } + + if outputProxyClient != nil { + os.Stdout = outputProxyClient.Stdout + os.Stderr = outputProxyClient.Stderr + cli.ErrWriter = outputProxyClient.Stderr + } + } + args = args[3:] + os.Args = args C.report_running() exiter := func(code int) { + if outputProxyClient != nil { + outputProxyClient.Close() + } C.report_stopped() } defer exiter(0) @@ -115,7 +158,10 @@ func SvcMain(dwNumServicesArgs C.DWORD, lpServiceArgVectors **C.char) { exiter(-1) }) - err := app.MakeApp(args).Run(args) + cliApp := app.MakeApp() + cliApp.Writer = os.Stdout + cliApp.ErrWriter = os.Stderr + err := cliApp.Run(args) if err != nil { fmt.Println(err) logrus.Error(err) diff --git a/version/version.go b/version/version.go index b510791..de52d31 100644 --- a/version/version.go +++ b/version/version.go @@ -9,7 +9,7 @@ import ( var YapscanVersion = Version{ Major: 0, - Minor: 13, + Minor: 14, Bugfix: 0, }