mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-26 17:42:27 +02:00
- Implement cloud provider infrastructure (DigitalOcean, AWS, GCP, Linode, Azure) with Pulumi integration for distributed scanning - Add nmap and tmux utility functions for port scanning results processing and long-running background session management - Introduce webhook-triggered run execution with unique UUID and authentication key support for external integrations
456 lines
12 KiB
Go
456 lines
12 KiB
Go
package functions
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/j3ssie/osmedeus/v5/internal/json"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// NmapRun represents the root element of nmap XML output
|
|
type NmapRun struct {
|
|
XMLName xml.Name `xml:"nmaprun"`
|
|
Hosts []NmapHost `xml:"host"`
|
|
}
|
|
|
|
// NmapHost represents a scanned host
|
|
type NmapHost struct {
|
|
Address NmapAddress `xml:"address"`
|
|
Ports []NmapPort `xml:"ports>port"`
|
|
}
|
|
|
|
// NmapAddress represents host address
|
|
type NmapAddress struct {
|
|
Addr string `xml:"addr,attr"`
|
|
AddrType string `xml:"addrtype,attr"`
|
|
}
|
|
|
|
// NmapPort represents a port entry
|
|
type NmapPort struct {
|
|
Protocol string `xml:"protocol,attr"`
|
|
PortID string `xml:"portid,attr"`
|
|
State NmapState `xml:"state"`
|
|
Service NmapService `xml:"service"`
|
|
}
|
|
|
|
// NmapState represents port state
|
|
type NmapState struct {
|
|
State string `xml:"state,attr"`
|
|
Reason string `xml:"reason,attr"`
|
|
}
|
|
|
|
// NmapService represents service information
|
|
type NmapService struct {
|
|
Name string `xml:"name,attr"`
|
|
Product string `xml:"product,attr"`
|
|
Version string `xml:"version,attr"`
|
|
ExtraInfo string `xml:"extrainfo,attr"`
|
|
Method string `xml:"method,attr"`
|
|
}
|
|
|
|
// PortDetail represents detailed port information
|
|
type PortDetail struct {
|
|
Protocol string `json:"protocol,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
Service string `json:"service,omitempty"`
|
|
Product string `json:"product,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
ServiceInfo string `json:"service_info,omitempty"`
|
|
}
|
|
|
|
// AssetOutput represents the JSONL output format
|
|
type AssetOutput struct {
|
|
AssetValue string `json:"asset_value"`
|
|
HostIP string `json:"host_ip"`
|
|
AssetType string `json:"asset_type"`
|
|
OpenPorts []string `json:"open_ports"`
|
|
Ports map[string]PortDetail `json:"ports"`
|
|
}
|
|
|
|
// parseNmapXML parses nmap XML format
|
|
func parseNmapXML(filePath string) ([]AssetOutput, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
}
|
|
|
|
var nmapRun NmapRun
|
|
if err := xml.Unmarshal(data, &nmapRun); err != nil {
|
|
return nil, fmt.Errorf("failed to parse XML: %w", err)
|
|
}
|
|
|
|
var results []AssetOutput
|
|
for _, host := range nmapRun.Hosts {
|
|
if host.Address.Addr == "" {
|
|
continue
|
|
}
|
|
|
|
asset := AssetOutput{
|
|
AssetValue: host.Address.Addr,
|
|
HostIP: host.Address.Addr,
|
|
AssetType: "ip",
|
|
OpenPorts: []string{},
|
|
Ports: make(map[string]PortDetail),
|
|
}
|
|
|
|
for _, port := range host.Ports {
|
|
portKey := port.PortID
|
|
portProto := fmt.Sprintf("%s/%s", port.PortID, port.Protocol)
|
|
|
|
// Add to open_ports array (only open/filtered ports)
|
|
if port.State.State == "open" || port.State.State == "filtered" {
|
|
asset.OpenPorts = append(asset.OpenPorts, portProto)
|
|
}
|
|
|
|
// Build service info
|
|
serviceInfo := port.Service.ExtraInfo
|
|
if serviceInfo == "" && port.Service.Method != "" {
|
|
serviceInfo = fmt.Sprintf("method=%s", port.Service.Method)
|
|
}
|
|
|
|
// Add detailed port information
|
|
detail := PortDetail{
|
|
Protocol: port.Protocol,
|
|
State: port.State.State,
|
|
Service: port.Service.Name,
|
|
Product: port.Service.Product,
|
|
Version: port.Service.Version,
|
|
ServiceInfo: serviceInfo,
|
|
}
|
|
|
|
asset.Ports[portKey] = detail
|
|
}
|
|
|
|
if len(asset.Ports) > 0 {
|
|
results = append(results, asset)
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// parseNmapGrepable parses nmap grepable format
|
|
func parseNmapGrepable(filePath string) ([]AssetOutput, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
var results []AssetOutput
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "Host:") || !strings.Contains(line, "Ports:") {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Split(line, "\t")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
|
|
// Extract host IP
|
|
hostPart := strings.TrimPrefix(parts[0], "Host: ")
|
|
hostIP := strings.Fields(hostPart)[0]
|
|
|
|
asset := AssetOutput{
|
|
AssetValue: hostIP,
|
|
HostIP: hostIP,
|
|
AssetType: "ip",
|
|
OpenPorts: []string{},
|
|
Ports: make(map[string]PortDetail),
|
|
}
|
|
|
|
// Parse ports
|
|
for _, part := range parts {
|
|
if !strings.HasPrefix(part, "Ports:") {
|
|
continue
|
|
}
|
|
|
|
portsPart := strings.TrimPrefix(part, "Ports: ")
|
|
portEntries := strings.Split(portsPart, ", ")
|
|
|
|
for _, entry := range portEntries {
|
|
// Format: portid/state/protocol//service//version
|
|
fields := strings.Split(entry, "/")
|
|
if len(fields) < 3 {
|
|
continue
|
|
}
|
|
|
|
portID := fields[0]
|
|
state := fields[1]
|
|
protocol := fields[2]
|
|
service := ""
|
|
version := ""
|
|
|
|
if len(fields) >= 5 {
|
|
service = fields[4]
|
|
}
|
|
if len(fields) >= 7 {
|
|
version = fields[6]
|
|
}
|
|
|
|
portProto := fmt.Sprintf("%s/%s", portID, protocol)
|
|
|
|
// Add to open_ports
|
|
if state == "open" || state == "filtered" {
|
|
asset.OpenPorts = append(asset.OpenPorts, portProto)
|
|
}
|
|
|
|
// Add detail
|
|
detail := PortDetail{
|
|
Protocol: protocol,
|
|
State: state,
|
|
Service: service,
|
|
Version: version,
|
|
}
|
|
|
|
asset.Ports[portID] = detail
|
|
}
|
|
}
|
|
|
|
if len(asset.Ports) > 0 {
|
|
results = append(results, asset)
|
|
}
|
|
}
|
|
|
|
return results, scanner.Err()
|
|
}
|
|
|
|
// nmapToJSONL converts nmap output to JSONL format
|
|
func (vf *vmFunc) nmapToJSONL(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
logger.Get().Warn("nmap_to_jsonl requires 2 arguments: input_path, output_path")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
inputPath := call.Argument(0).String()
|
|
outputPath := call.Argument(1).String()
|
|
|
|
if inputPath == "" || inputPath == "undefined" {
|
|
logger.Get().Warn("input_path is required")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
if outputPath == "" || outputPath == "undefined" {
|
|
logger.Get().Warn("output_path is required")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Auto-detect format based on extension
|
|
ext := strings.ToLower(filepath.Ext(inputPath))
|
|
var results []AssetOutput
|
|
var err error
|
|
|
|
switch ext {
|
|
case ".gnmap":
|
|
results, err = parseNmapGrepable(inputPath)
|
|
case ".nmap", ".txt":
|
|
// Text format not supported yet, default to XML
|
|
logger.Get().Warn("Text format (.nmap) not fully supported, attempting XML parse")
|
|
results, err = parseNmapXML(inputPath)
|
|
case ".xml", "":
|
|
// Default to XML
|
|
results, err = parseNmapXML(inputPath)
|
|
default:
|
|
// Try XML as fallback
|
|
results, err = parseNmapXML(inputPath)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Get().Warn("Failed to parse nmap file", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Write JSONL output
|
|
outFile, err := os.Create(outputPath)
|
|
if err != nil {
|
|
logger.Get().Warn("Failed to create output file", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
defer func() { _ = outFile.Close() }()
|
|
|
|
writer := bufio.NewWriter(outFile)
|
|
for _, asset := range results {
|
|
data, err := json.Marshal(asset)
|
|
if err != nil {
|
|
logger.Get().Warn("Failed to marshal asset", zap.Error(err))
|
|
continue
|
|
}
|
|
|
|
if _, err := writer.Write(data); err != nil {
|
|
logger.Get().Warn("Failed to write line", zap.Error(err))
|
|
continue
|
|
}
|
|
if _, err := writer.WriteString("\n"); err != nil {
|
|
logger.Get().Warn("Failed to write newline", zap.Error(err))
|
|
continue
|
|
}
|
|
}
|
|
|
|
if err := writer.Flush(); err != nil {
|
|
logger.Get().Warn("Failed to flush writer", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
logger.Get().Info("Converted hosts from nmap to JSONL", zap.Int("count", len(results)), zap.String("output", outputPath))
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// sanitizeTargetForPath converts target to filesystem-safe string
|
|
func sanitizeTargetForPath(target string) string {
|
|
s := strings.ReplaceAll(target, "/", "-")
|
|
s = strings.ReplaceAll(s, ":", "-")
|
|
s = strings.ReplaceAll(s, ".", "-")
|
|
s = strings.TrimSuffix(s, "-txt")
|
|
s = strings.TrimSuffix(s, "-xml")
|
|
return s
|
|
}
|
|
|
|
// runNmap executes nmap scan and converts output to JSONL
|
|
func (vf *vmFunc) runNmap(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
logger.Get().Warn("run_nmap requires at least 1 argument: target")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
target := call.Argument(0).String()
|
|
if target == "" || target == "undefined" {
|
|
logger.Get().Warn("target is required")
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Extract optional flags (default: -sV -T4)
|
|
flags := "-sV -T4"
|
|
if len(call.Arguments) >= 2 && call.Argument(1).String() != "undefined" && call.Argument(1).String() != "" {
|
|
flags = call.Argument(1).String()
|
|
}
|
|
|
|
// Extract optional output path
|
|
var outputPath string
|
|
if len(call.Arguments) >= 3 && call.Argument(2).String() != "undefined" && call.Argument(2).String() != "" {
|
|
outputPath = call.Argument(2).String()
|
|
} else {
|
|
// Auto-generate output path
|
|
baseDir := vf.getContext().workspacePath
|
|
if baseDir == "" {
|
|
baseDir = "."
|
|
}
|
|
sanitizedTarget := sanitizeTargetForPath(target)
|
|
outputPath = filepath.Join(baseDir, fmt.Sprintf("nmap-%s.jsonl", sanitizedTarget))
|
|
}
|
|
|
|
// Check if nmap command exists
|
|
nmapPath, err := exec.LookPath("nmap")
|
|
if err != nil {
|
|
logger.Get().Warn("nmap command not found in PATH", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Create temp directory for XML output
|
|
tmpDir, err := os.MkdirTemp("", "osm-nmap-*")
|
|
if err != nil {
|
|
logger.Get().Warn("Failed to create temp directory", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
xmlPath := filepath.Join(tmpDir, "scan.xml")
|
|
|
|
// Build nmap command with XML output
|
|
flagsList := strings.Fields(flags)
|
|
args := append(flagsList, "-oX", xmlPath, target)
|
|
|
|
logger.Get().Info("Executing nmap scan", zap.String("target", target), zap.Strings("args", args))
|
|
|
|
// Execute nmap
|
|
cmd := exec.Command(nmapPath, args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Check if XML file exists (partial results may still be available)
|
|
if _, statErr := os.Stat(xmlPath); statErr == nil {
|
|
logger.Get().Warn("nmap exited with error but XML output exists, using partial results",
|
|
zap.Error(err),
|
|
zap.String("output", string(output)))
|
|
} else {
|
|
logger.Get().Warn("nmap execution failed",
|
|
zap.Error(err),
|
|
zap.String("output", string(output)))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
}
|
|
|
|
// Parse XML
|
|
results, err := parseNmapXML(xmlPath)
|
|
if err != nil {
|
|
logger.Get().Warn("Failed to parse nmap XML output", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
logger.Get().Warn("No hosts found in nmap output")
|
|
return vf.vm.ToValue(outputPath) // Return path even if empty for consistency
|
|
}
|
|
|
|
// Create output directory if it doesn't exist
|
|
outputDir := filepath.Dir(outputPath)
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
logger.Get().Warn("Failed to create output directory", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
// Write JSONL output
|
|
outFile, err := os.Create(outputPath)
|
|
if err != nil {
|
|
logger.Get().Warn("Failed to create output file", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
defer func() { _ = outFile.Close() }()
|
|
|
|
writer := bufio.NewWriter(outFile)
|
|
for _, asset := range results {
|
|
data, err := json.Marshal(asset)
|
|
if err != nil {
|
|
logger.Get().Warn("Failed to marshal asset", zap.Error(err))
|
|
continue
|
|
}
|
|
|
|
if _, err := writer.Write(data); err != nil {
|
|
logger.Get().Warn("Failed to write line", zap.Error(err))
|
|
continue
|
|
}
|
|
if _, err := writer.WriteString("\n"); err != nil {
|
|
logger.Get().Warn("Failed to write newline", zap.Error(err))
|
|
continue
|
|
}
|
|
}
|
|
|
|
if err := writer.Flush(); err != nil {
|
|
logger.Get().Warn("Failed to flush writer", zap.Error(err))
|
|
return vf.vm.ToValue(false)
|
|
}
|
|
|
|
logger.Get().Info("Completed nmap scan and converted to JSONL",
|
|
zap.Int("hosts", len(results)),
|
|
zap.String("output", outputPath))
|
|
|
|
return vf.vm.ToValue(outputPath)
|
|
}
|