mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-29 19:29:45 +02:00
Vigolium http_record and finding entries can embed multi-MB response bodies, producing JSONL lines that exceed bufio.Scanner's max token size and fail with 'token too long'. Switch to bufio.Reader.ReadString, which grows as needed, and extract per-line logic into a processLine closure. Add a regression test that imports a finding with a 12MB response body.
3971 lines
110 KiB
Go
3971 lines
110 KiB
Go
package functions
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"github.com/j3ssie/osmedeus/v5/internal/json"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/google/uuid"
|
|
"github.com/j3ssie/osmedeus/v5/internal/database"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"github.com/j3ssie/osmedeus/v5/internal/state"
|
|
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// dbUpdate updates a specific field in a database table
|
|
// Usage: db_update('workspaces', 'example.com', 'total_urls', 100)
|
|
func (vf *vmFunc) dbUpdate(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbUpdate"))
|
|
|
|
if len(call.Arguments) < 4 {
|
|
logger.Get().Warn("dbUpdate: requires 4 arguments")
|
|
return vf.errorValue("db_update requires 4 arguments: table, key, field, value")
|
|
}
|
|
|
|
table := call.Argument(0).String()
|
|
key := call.Argument(1).String()
|
|
field := call.Argument(2).String()
|
|
value := call.Argument(3)
|
|
logger.Get().Debug(terminal.HiGreen("dbUpdate")+" params", zap.String("table", table), zap.String("key", key), zap.String("field", field))
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
logger.Get().Warn("dbUpdate: database not connected")
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var err error
|
|
|
|
switch table {
|
|
case "workspaces":
|
|
err = vf.updateWorkspaceField(ctx, key, field, value)
|
|
case "assets":
|
|
err = vf.updateAssetField(ctx, key, field, value)
|
|
case "runs":
|
|
err = vf.updateRunField(ctx, key, field, value)
|
|
default:
|
|
return vf.errorValue(fmt.Sprintf("unsupported table: %s", table))
|
|
}
|
|
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("update failed: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// updateWorkspaceField updates a field in the workspaces table
|
|
func (vf *vmFunc) updateWorkspaceField(ctx context.Context, name, field string, value goja.Value) error {
|
|
db := database.GetDB()
|
|
|
|
// Get current workspace
|
|
var ws database.Workspace
|
|
err := db.NewSelect().Model(&ws).Where("name = ?", name).Scan(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("workspace not found: %s", name)
|
|
}
|
|
|
|
// Update the specific field
|
|
switch field {
|
|
case "total_assets":
|
|
ws.TotalAssets, _ = strconv.Atoi(value.String())
|
|
case "total_subdomains":
|
|
ws.TotalSubdomains, _ = strconv.Atoi(value.String())
|
|
case "total_urls":
|
|
ws.TotalURLs, _ = strconv.Atoi(value.String())
|
|
case "total_vulns":
|
|
ws.TotalVulns, _ = strconv.Atoi(value.String())
|
|
case "vuln_critical":
|
|
ws.VulnCritical, _ = strconv.Atoi(value.String())
|
|
case "vuln_high":
|
|
ws.VulnHigh, _ = strconv.Atoi(value.String())
|
|
case "vuln_medium":
|
|
ws.VulnMedium, _ = strconv.Atoi(value.String())
|
|
case "vuln_low":
|
|
ws.VulnLow, _ = strconv.Atoi(value.String())
|
|
case "vuln_potential":
|
|
ws.VulnPotential, _ = strconv.Atoi(value.String())
|
|
case "risk_score":
|
|
ws.RiskScore, _ = strconv.ParseFloat(value.String(), 64)
|
|
case "run_workflow":
|
|
ws.RunWorkflow = value.String()
|
|
case "local_path":
|
|
ws.LocalPath = value.String()
|
|
default:
|
|
return fmt.Errorf("unsupported field: %s", field)
|
|
}
|
|
|
|
ws.UpdatedAt = time.Now()
|
|
|
|
_, err = db.NewUpdate().Model(&ws).WherePK().Exec(ctx)
|
|
return err
|
|
}
|
|
|
|
// updateAssetField updates a field in the assets table
|
|
func (vf *vmFunc) updateAssetField(ctx context.Context, idStr, field string, value goja.Value) error {
|
|
db := database.GetDB()
|
|
id, _ := strconv.ParseInt(idStr, 10, 64)
|
|
|
|
var asset database.Asset
|
|
err := db.NewSelect().Model(&asset).Where("id = ?", id).Scan(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("asset not found: %s", idStr)
|
|
}
|
|
|
|
switch field {
|
|
case "remarks":
|
|
asset.Remarks = []string{value.String()}
|
|
case "language":
|
|
asset.Language = value.String()
|
|
case "size":
|
|
asset.Size, _ = strconv.ParseInt(value.String(), 10, 64)
|
|
case "loc":
|
|
asset.LOC, _ = strconv.ParseInt(value.String(), 10, 64)
|
|
case "source":
|
|
asset.Source = value.String()
|
|
case "asset_type":
|
|
asset.AssetType = value.String()
|
|
default:
|
|
return fmt.Errorf("unsupported field: %s", field)
|
|
}
|
|
|
|
asset.UpdatedAt = time.Now()
|
|
_, err = db.NewUpdate().Model(&asset).WherePK().Exec(ctx)
|
|
return err
|
|
}
|
|
|
|
// updateRunField updates a field in the runs table
|
|
func (vf *vmFunc) updateRunField(ctx context.Context, id, field string, value goja.Value) error {
|
|
db := database.GetDB()
|
|
|
|
var run database.Run
|
|
err := db.NewSelect().Model(&run).Where("id = ?", id).Scan(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("run not found: %s", id)
|
|
}
|
|
|
|
switch field {
|
|
case "status":
|
|
run.Status = value.String()
|
|
case "error_message":
|
|
run.ErrorMessage = value.String()
|
|
default:
|
|
return fmt.Errorf("unsupported field: %s", field)
|
|
}
|
|
|
|
run.UpdatedAt = time.Now()
|
|
_, err = db.NewUpdate().Model(&run).WherePK().Exec(ctx)
|
|
return err
|
|
}
|
|
|
|
// unmarshalAssetJSON unmarshals JSON into an Asset with backward compatibility.
|
|
// Handles old format where "remarks" is a string instead of []string,
|
|
// and merges legacy "tags" array into Remarks.
|
|
// unwrapEnvelopeJSON detects the {"type":"...","data":{...}} envelope format
|
|
// used by tools like vigolium. Returns (extracted JSON bytes, should-skip).
|
|
// If the line is an envelope with a non-importable type (e.g. "scan", "module"),
|
|
// skip=true is returned. If it's an importable envelope (e.g. "http_record"),
|
|
// the inner data is extracted and field names are remapped to match Asset json tags.
|
|
// If the line is not an envelope, returns (nil, false) so the caller uses the original.
|
|
func unwrapEnvelopeJSON(raw []byte) (extracted []byte, skip bool) {
|
|
var envelope struct {
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Type == "" || len(envelope.Data) == 0 {
|
|
return nil, false // not an envelope
|
|
}
|
|
|
|
// Only import http_record types; skip metadata like scan, module
|
|
if envelope.Type != "http_record" {
|
|
return nil, true
|
|
}
|
|
|
|
// Remap vigolium field names to Asset json tags
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal(envelope.Data, &data); err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
fieldRemap := map[string]string{
|
|
"response_content_type": "content_type",
|
|
"response_content_length": "content_length",
|
|
"response_words": "words",
|
|
"response_title": "title",
|
|
"ip": "host_ip",
|
|
"hostname": "input",
|
|
}
|
|
for oldKey, newKey := range fieldRemap {
|
|
if val, ok := data[oldKey]; ok {
|
|
if _, exists := data[newKey]; !exists {
|
|
data[newKey] = val
|
|
}
|
|
delete(data, oldKey)
|
|
}
|
|
}
|
|
|
|
// Default asset_type to "web" for http_record
|
|
if _, ok := data["asset_type"]; !ok {
|
|
data["asset_type"] = "web"
|
|
}
|
|
|
|
result, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return result, false
|
|
}
|
|
|
|
func unmarshalAssetJSON(rawJSON []byte, asset *database.Asset) error {
|
|
// First try direct unmarshal
|
|
if err := json.Unmarshal(rawJSON, asset); err != nil {
|
|
// If direct unmarshal fails (e.g. "remarks" is string instead of []string),
|
|
// pre-process the JSON to fix known type mismatches
|
|
var raw map[string]interface{}
|
|
if mapErr := json.Unmarshal(rawJSON, &raw); mapErr != nil {
|
|
return err // return original error
|
|
}
|
|
// Convert "remarks" from string to []string
|
|
if remarksStr, ok := raw["remarks"].(string); ok {
|
|
raw["remarks"] = []string{remarksStr}
|
|
}
|
|
// Remove "tags" so it doesn't interfere with unmarshal
|
|
delete(raw, "tags")
|
|
fixed, marshalErr := json.Marshal(raw)
|
|
if marshalErr != nil {
|
|
return err
|
|
}
|
|
if err2 := json.Unmarshal(fixed, asset); err2 != nil {
|
|
return err2
|
|
}
|
|
}
|
|
|
|
// Merge legacy "tags" and handle string "remarks" into Remarks
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(rawJSON, &raw); err == nil {
|
|
if remarksStr, ok := raw["remarks"].(string); ok && remarksStr != "" {
|
|
asset.Remarks = []string{remarksStr}
|
|
}
|
|
if tagsArr, ok := raw["tags"].([]interface{}); ok {
|
|
for _, t := range tagsArr {
|
|
if s, ok := t.(string); ok {
|
|
asset.Remarks = append(asset.Remarks, s)
|
|
}
|
|
}
|
|
}
|
|
// Backward compat: accept "a" key for DNS records when "dns_records" is absent
|
|
if len(asset.DnsRecords) == 0 {
|
|
if aArr, ok := raw["a"].([]interface{}); ok {
|
|
for _, r := range aArr {
|
|
if s, ok := r.(string); ok {
|
|
asset.DnsRecords = append(asset.DnsRecords, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// CDN/WAF classification (OR into existing values)
|
|
isCDN, isCloud, isWAF := database.DetectCDNFlags(raw)
|
|
asset.IsCDN = asset.IsCDN || isCDN
|
|
asset.IsCloud = asset.IsCloud || isCloud
|
|
asset.IsWAF = asset.IsWAF || isWAF
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dbImportAsset imports an asset from JSON data
|
|
// Usage: db_import_asset('example.com', '{"host":"sub.example.com","url":"https://..."}')
|
|
func (vf *vmFunc) dbImportAsset(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_import_asset requires 2 arguments: workspace, json_data")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
jsonData := call.Argument(1).String()
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
var asset database.Asset
|
|
if err := unmarshalAssetJSON([]byte(jsonData), &asset); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("invalid JSON: %v", err))
|
|
}
|
|
|
|
asset.Workspace = workspace
|
|
now := time.Now()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Check if asset already exists
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ? AND asset_value = ? AND url = ?", workspace, asset.AssetValue, asset.URL).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// New asset - insert
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
_, insertErr := db.NewInsert().Model(&asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
return vf.errorValue(fmt.Sprintf("import failed: %v", insertErr))
|
|
}
|
|
} else {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeAssetFields(&existing, &asset)
|
|
asset.UpdatedAt = now
|
|
_, updateErr := db.NewUpdate().Model(&asset).WherePK().Exec(ctx)
|
|
if updateErr != nil {
|
|
return vf.errorValue(fmt.Sprintf("import failed: %v", updateErr))
|
|
}
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// dbQuickImportAsset imports an asset with just workspace and asset_value
|
|
// Usage: db_quick_import_asset('example.com', 'sub.example.com')
|
|
// Usage: db_quick_import_asset('example.com', 'sub.example.com', 'domain')
|
|
// Creates EventLog with topic "db.new.asset" when a new asset is created
|
|
func (vf *vmFunc) dbQuickImportAsset(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_quick_import_asset requires at least 2 arguments: workspace, asset_value")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
assetValue := call.Argument(1).String()
|
|
|
|
// Optional asset_type (3rd argument)
|
|
var assetType string
|
|
if len(call.Arguments) >= 3 {
|
|
assetType = call.Argument(2).String()
|
|
}
|
|
if assetType == "" {
|
|
assetType = database.ClassifyAssetType(assetValue)
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
asset := &database.Asset{
|
|
Workspace: workspace,
|
|
AssetValue: assetValue,
|
|
AssetType: assetType,
|
|
}
|
|
|
|
// Check if asset already exists
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ? AND asset_value = ?", workspace, assetValue).
|
|
Scan(ctx)
|
|
|
|
isNew := selectErr != nil
|
|
if isNew {
|
|
// New asset - insert
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
_, insertErr := db.NewInsert().Model(asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
return vf.errorValue(fmt.Sprintf("import failed: %v", insertErr))
|
|
}
|
|
} else {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeAssetFields(&existing, asset)
|
|
asset.UpdatedAt = now
|
|
_, updateErr := db.NewUpdate().Model(asset).WherePK().Exec(ctx)
|
|
if updateErr != nil {
|
|
return vf.errorValue(fmt.Sprintf("import failed: %v", updateErr))
|
|
}
|
|
}
|
|
|
|
// Create event log for new assets only
|
|
if isNew {
|
|
eventLog := &database.EventLog{
|
|
Topic: "db.new.asset",
|
|
EventID: uuid.New().String(),
|
|
Name: "New asset imported",
|
|
Source: "db_quick_import_asset",
|
|
SourceType: "function",
|
|
DataType: "asset",
|
|
Data: fmt.Sprintf(`{"workspace":"%s","asset_value":"%s","asset_type":"%s"}`, workspace, assetValue, assetType),
|
|
Workspace: workspace,
|
|
Processed: false,
|
|
CreatedAt: now,
|
|
}
|
|
_, _ = db.NewInsert().Model(eventLog).Exec(ctx)
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// dbPartialImportAsset imports an asset with only workspace, asset_type, and asset_value
|
|
// Usage: db_partial_import_asset('example.com', 'domain', 'sub.example.com')
|
|
func (vf *vmFunc) dbPartialImportAsset(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 3 {
|
|
return vf.errorValue("db_partial_import_asset requires 3 arguments: workspace, asset_type, asset_value")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
assetType := call.Argument(1).String()
|
|
assetValue := call.Argument(2).String()
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
asset := &database.Asset{
|
|
Workspace: workspace,
|
|
AssetValue: assetValue,
|
|
AssetType: assetType,
|
|
}
|
|
|
|
// Check if asset already exists
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ? AND asset_value = ?", workspace, assetValue).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// New asset - insert
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
_, insertErr := db.NewInsert().Model(asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
return vf.errorValue(fmt.Sprintf("import failed: %v", insertErr))
|
|
}
|
|
} else {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeAssetFields(&existing, asset)
|
|
asset.UpdatedAt = now
|
|
_, updateErr := db.NewUpdate().Model(asset).WherePK().Exec(ctx)
|
|
if updateErr != nil {
|
|
return vf.errorValue(fmt.Sprintf("import failed: %v", updateErr))
|
|
}
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// dbPartialImportAssetFile reads a file line by line and imports each line as an asset
|
|
// Usage: db_partial_import_asset_file('example.com', 'domain', '/path/to/subdomains.txt')
|
|
func (vf *vmFunc) dbPartialImportAssetFile(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 3 {
|
|
return vf.errorValue("db_partial_import_asset_file requires 3 arguments: workspace, asset_type, file_path")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
assetType := call.Argument(1).String()
|
|
filePath := call.Argument(2).String()
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to open file: %v", err))
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
ctx := context.Background()
|
|
count := 0
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
now := time.Now()
|
|
asset := &database.Asset{
|
|
Workspace: workspace,
|
|
AssetValue: line,
|
|
AssetType: assetType,
|
|
}
|
|
|
|
// Check if asset already exists
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ? AND asset_value = ?", workspace, line).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// New asset - insert
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
_, insertErr := db.NewInsert().Model(asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
logger.Get().Warn("dbPartialImportAssetFile: failed to import line",
|
|
zap.String("line", line), zap.Error(insertErr))
|
|
continue
|
|
}
|
|
} else {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeAssetFields(&existing, asset)
|
|
asset.UpdatedAt = now
|
|
_, updateErr := db.NewUpdate().Model(asset).WherePK().Exec(ctx)
|
|
if updateErr != nil {
|
|
logger.Get().Warn("dbPartialImportAssetFile: failed to update line",
|
|
zap.String("line", line), zap.Error(updateErr))
|
|
continue
|
|
}
|
|
}
|
|
count++
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("error reading file: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(count)
|
|
}
|
|
|
|
// dbRawInsertAsset inserts an asset from JSON data (pure insert, fails if duplicate exists)
|
|
// Usage: db_raw_insert_asset('example.com', '{"asset_value":"sub.example.com","asset_type":"subdomain","url":"https://..."}')
|
|
// Returns: asset ID (int) on success, error string on failure
|
|
func (vf *vmFunc) dbRawInsertAsset(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_raw_insert_asset requires 2 arguments: workspace, json_data")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
jsonData := call.Argument(1).String()
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
var asset database.Asset
|
|
if err := json.Unmarshal([]byte(jsonData), &asset); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("invalid JSON: %v", err))
|
|
}
|
|
|
|
asset.Workspace = workspace
|
|
asset.CreatedAt = time.Now()
|
|
asset.UpdatedAt = time.Now()
|
|
|
|
ctx := context.Background()
|
|
_, err := db.NewInsert().Model(&asset).Exec(ctx)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("insert failed: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(asset.ID)
|
|
}
|
|
|
|
// dbTotalURLs counts lines in a file and adds to workspace total_urls (+=)
|
|
// Usage: db_total_urls('/path/to/urls.txt') or db_total_urls(0) to reset
|
|
func (vf *vmFunc) dbTotalURLs(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_urls")
|
|
}
|
|
|
|
// dbTotalSubdomains counts lines in a file and adds to workspace total_subdomains (+=)
|
|
// Usage: db_total_subdomains('/path/to/subdomains.txt') or db_total_subdomains(0) to reset
|
|
func (vf *vmFunc) dbTotalSubdomains(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_subdomains")
|
|
}
|
|
|
|
// dbTotalAssets counts lines in a file and adds to workspace total_assets (+=)
|
|
// Usage: db_total_assets('/path/to/assets.txt') or db_total_assets(0) to reset
|
|
func (vf *vmFunc) dbTotalAssets(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_assets")
|
|
}
|
|
|
|
// dbTotalVulns counts lines in a file and adds to workspace total_vulns (+=)
|
|
// Usage: db_total_vulns('/path/to/vulns.txt') or db_total_vulns(0) to reset
|
|
func (vf *vmFunc) dbTotalVulns(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_vulns")
|
|
}
|
|
|
|
// dbVulnCritical counts lines in a file and adds to workspace vuln_critical (+=)
|
|
// Usage: db_vuln_critical('/path/to/critical.txt') or db_vuln_critical(0) to reset
|
|
func (vf *vmFunc) dbVulnCritical(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "vuln_critical")
|
|
}
|
|
|
|
// dbVulnHigh counts lines in a file and adds to workspace vuln_high (+=)
|
|
// Usage: db_vuln_high('/path/to/high.txt') or db_vuln_high(0) to reset
|
|
func (vf *vmFunc) dbVulnHigh(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "vuln_high")
|
|
}
|
|
|
|
// dbVulnMedium counts lines in a file and adds to workspace vuln_medium (+=)
|
|
// Usage: db_vuln_medium('/path/to/medium.txt') or db_vuln_medium(0) to reset
|
|
func (vf *vmFunc) dbVulnMedium(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "vuln_medium")
|
|
}
|
|
|
|
// dbVulnLow counts lines in a file and adds to workspace vuln_low (+=)
|
|
// Usage: db_vuln_low('/path/to/low.txt') or db_vuln_low(0) to reset
|
|
func (vf *vmFunc) dbVulnLow(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "vuln_low")
|
|
}
|
|
|
|
// dbTotalIPs counts lines in a file and adds to workspace total_ips (+=)
|
|
// Usage: db_total_ips('/path/to/ips.txt') or db_total_ips(0) to reset
|
|
func (vf *vmFunc) dbTotalIPs(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_ips")
|
|
}
|
|
|
|
// dbTotalLinks counts lines in a file and adds to workspace total_links (+=)
|
|
// Usage: db_total_links('/path/to/links.txt') or db_total_links(0) to reset
|
|
func (vf *vmFunc) dbTotalLinks(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_links")
|
|
}
|
|
|
|
// dbTotalContent counts lines in a file and adds to workspace total_content (+=)
|
|
// Usage: db_total_content('/path/to/content.txt') or db_total_content(0) to reset
|
|
func (vf *vmFunc) dbTotalContent(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_content")
|
|
}
|
|
|
|
// dbTotalArchive counts lines in a file and adds to workspace total_archive (+=)
|
|
// Usage: db_total_archive('/path/to/archive.txt') or db_total_archive(0) to reset
|
|
func (vf *vmFunc) dbTotalArchive(call goja.FunctionCall) goja.Value {
|
|
return vf.updateWorkspaceStatFromFile(call, "total_archive")
|
|
}
|
|
|
|
// updateWorkspaceStatFromFile is a helper that counts non-empty lines and updates workspace
|
|
// Supports += accumulation behavior and reset to 0 when called with 0 argument
|
|
func (vf *vmFunc) updateWorkspaceStatFromFile(call goja.FunctionCall, field string) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return vf.errorValue(fmt.Sprintf("%s requires 1 argument: file_path or 0 to reset", field))
|
|
}
|
|
|
|
arg := call.Argument(0)
|
|
|
|
// Check for reset: if arg is 0 (number) or "0" (string), reset to 0
|
|
isReset := false
|
|
argExport := arg.Export()
|
|
switch v := argExport.(type) {
|
|
case int64:
|
|
if v == 0 {
|
|
isReset = true
|
|
}
|
|
case float64:
|
|
if v == 0 {
|
|
isReset = true
|
|
}
|
|
case string:
|
|
if v == "0" {
|
|
isReset = true
|
|
}
|
|
}
|
|
|
|
var count int
|
|
if isReset {
|
|
count = 0
|
|
} else {
|
|
filePath := arg.String()
|
|
// Count non-empty lines in file
|
|
var err error
|
|
count, err = countNonEmptyLines(filePath)
|
|
if err != nil {
|
|
// If file doesn't exist, count is 0
|
|
count = 0
|
|
}
|
|
}
|
|
|
|
// Get workspace name from context
|
|
workspace := vf.getContext().workspaceName
|
|
if workspace == "" {
|
|
return vf.errorValue("workspace not set in context")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get existing workspace or create new
|
|
var ws database.Workspace
|
|
err := db.NewSelect().Model(&ws).Where("name = ?", workspace).Scan(ctx)
|
|
if err != nil {
|
|
// Create workspace if it doesn't exist
|
|
ws = database.Workspace{
|
|
Name: workspace,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// Set the appropriate field: reset to 0 or += count
|
|
switch field {
|
|
case "total_urls":
|
|
if isReset {
|
|
ws.TotalURLs = 0
|
|
} else {
|
|
ws.TotalURLs += count
|
|
}
|
|
case "total_subdomains":
|
|
if isReset {
|
|
ws.TotalSubdomains = 0
|
|
} else {
|
|
ws.TotalSubdomains += count
|
|
}
|
|
case "total_assets":
|
|
if isReset {
|
|
ws.TotalAssets = 0
|
|
} else {
|
|
ws.TotalAssets += count
|
|
}
|
|
case "total_vulns":
|
|
if isReset {
|
|
ws.TotalVulns = 0
|
|
} else {
|
|
ws.TotalVulns += count
|
|
}
|
|
case "total_ips":
|
|
if isReset {
|
|
ws.TotalIPs = 0
|
|
} else {
|
|
ws.TotalIPs += count
|
|
}
|
|
case "total_links":
|
|
if isReset {
|
|
ws.TotalLinks = 0
|
|
} else {
|
|
ws.TotalLinks += count
|
|
}
|
|
case "total_content":
|
|
if isReset {
|
|
ws.TotalContent = 0
|
|
} else {
|
|
ws.TotalContent += count
|
|
}
|
|
case "total_archive":
|
|
if isReset {
|
|
ws.TotalArchive = 0
|
|
} else {
|
|
ws.TotalArchive += count
|
|
}
|
|
case "vuln_critical":
|
|
if isReset {
|
|
ws.VulnCritical = 0
|
|
} else {
|
|
ws.VulnCritical += count
|
|
}
|
|
case "vuln_high":
|
|
if isReset {
|
|
ws.VulnHigh = 0
|
|
} else {
|
|
ws.VulnHigh += count
|
|
}
|
|
case "vuln_medium":
|
|
if isReset {
|
|
ws.VulnMedium = 0
|
|
} else {
|
|
ws.VulnMedium += count
|
|
}
|
|
case "vuln_low":
|
|
if isReset {
|
|
ws.VulnLow = 0
|
|
} else {
|
|
ws.VulnLow += count
|
|
}
|
|
}
|
|
|
|
ws.UpdatedAt = time.Now()
|
|
|
|
// Upsert workspace (include new fields)
|
|
_, err = db.NewInsert().Model(&ws).
|
|
On("CONFLICT (name) DO UPDATE").
|
|
Set("total_urls = EXCLUDED.total_urls").
|
|
Set("total_subdomains = EXCLUDED.total_subdomains").
|
|
Set("total_assets = EXCLUDED.total_assets").
|
|
Set("total_vulns = EXCLUDED.total_vulns").
|
|
Set("total_ips = EXCLUDED.total_ips").
|
|
Set("total_links = EXCLUDED.total_links").
|
|
Set("total_content = EXCLUDED.total_content").
|
|
Set("total_archive = EXCLUDED.total_archive").
|
|
Set("vuln_critical = EXCLUDED.vuln_critical").
|
|
Set("vuln_high = EXCLUDED.vuln_high").
|
|
Set("vuln_medium = EXCLUDED.vuln_medium").
|
|
Set("vuln_low = EXCLUDED.vuln_low").
|
|
Set("updated_at = EXCLUDED.updated_at").
|
|
Exec(ctx)
|
|
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("update failed: %v", err))
|
|
}
|
|
|
|
// Return the new value of the field
|
|
var resultValue int
|
|
switch field {
|
|
case "total_urls":
|
|
resultValue = ws.TotalURLs
|
|
case "total_subdomains":
|
|
resultValue = ws.TotalSubdomains
|
|
case "total_assets":
|
|
resultValue = ws.TotalAssets
|
|
case "total_vulns":
|
|
resultValue = ws.TotalVulns
|
|
case "total_ips":
|
|
resultValue = ws.TotalIPs
|
|
case "total_links":
|
|
resultValue = ws.TotalLinks
|
|
case "total_content":
|
|
resultValue = ws.TotalContent
|
|
case "total_archive":
|
|
resultValue = ws.TotalArchive
|
|
case "vuln_critical":
|
|
resultValue = ws.VulnCritical
|
|
case "vuln_high":
|
|
resultValue = ws.VulnHigh
|
|
case "vuln_medium":
|
|
resultValue = ws.VulnMedium
|
|
case "vuln_low":
|
|
resultValue = ws.VulnLow
|
|
default:
|
|
resultValue = count
|
|
}
|
|
|
|
return vf.vm.ToValue(resultValue)
|
|
}
|
|
|
|
// countNonEmptyLines counts non-empty lines in a file
|
|
func countNonEmptyLines(filePath string) (int, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
count := 0
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line != "" {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count, scanner.Err()
|
|
}
|
|
|
|
// runtimeExport exports the current run state to StateFile
|
|
// Usage: runtime_export()
|
|
func (vf *vmFunc) runtimeExport(call goja.FunctionCall) goja.Value {
|
|
if vf.getContext().stateFile == "" {
|
|
return vf.errorValue("StateFile not set in context")
|
|
}
|
|
|
|
ctx := &state.ExportContext{
|
|
RunUUID: vf.getContext().scanID,
|
|
WorkspaceName: vf.getContext().workspaceName,
|
|
WorkspacePath: vf.getContext().workspacePath,
|
|
WorkflowName: vf.getContext().workflowName,
|
|
WorkflowKind: vf.getContext().workflowKind,
|
|
Target: vf.getContext().target,
|
|
Status: "running",
|
|
}
|
|
|
|
err := state.Export(vf.getContext().stateFile, ctx)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("state export failed: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// errorValue creates an error return value
|
|
func (vf *vmFunc) errorValue(msg string) goja.Value {
|
|
return vf.vm.ToValue(fmt.Sprintf("error: %s", msg))
|
|
}
|
|
|
|
// Supported artifact types
|
|
var validArtifactTypes = map[string]bool{
|
|
"text": true,
|
|
"jsonl": true,
|
|
"markdown": true,
|
|
"html": true,
|
|
}
|
|
|
|
// validOutputFormats defines the allowed output formats for db_select functions
|
|
var validOutputFormats = map[string]bool{
|
|
"markdown": true,
|
|
"jsonl": true,
|
|
}
|
|
|
|
// validateOutputFormat checks if the format is valid
|
|
func validateOutputFormat(format string) error {
|
|
if !validOutputFormats[format] {
|
|
return fmt.Errorf("invalid format '%s'. Supported: markdown, jsonl", format)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// assetColumns defines the columns for asset markdown table
|
|
var assetColumns = []string{"id", "workspace", "asset_value", "url", "status_code", "title"}
|
|
|
|
// vulnerabilityColumns defines the columns for vulnerability markdown table
|
|
var vulnerabilityColumns = []string{"id", "workspace", "severity", "vuln_title", "asset_value"}
|
|
|
|
// runColumns defines the columns for run markdown table
|
|
var runColumns = []string{"run_uuid", "workflow_name", "target", "workspace", "status", "total_steps", "completed_steps", "started_at"}
|
|
|
|
// formatAsMarkdownTable converts a slice of maps to a markdown table
|
|
func formatAsMarkdownTable(rows []map[string]interface{}, columns []string) string {
|
|
if len(rows) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
// Header row
|
|
sb.WriteString("| ")
|
|
sb.WriteString(strings.Join(columns, " | "))
|
|
sb.WriteString(" |\n")
|
|
|
|
// Separator row
|
|
sb.WriteString("|")
|
|
for range columns {
|
|
sb.WriteString(" --- |")
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
// Data rows
|
|
for _, row := range rows {
|
|
sb.WriteString("| ")
|
|
for i, col := range columns {
|
|
if i > 0 {
|
|
sb.WriteString(" | ")
|
|
}
|
|
if val, ok := row[col]; ok {
|
|
sb.WriteString(formatMarkdownValue(val))
|
|
}
|
|
}
|
|
sb.WriteString(" |\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// formatMarkdownValue converts a value to a markdown-safe string
|
|
func formatMarkdownValue(val interface{}) string {
|
|
if val == nil {
|
|
return ""
|
|
}
|
|
switch v := val.(type) {
|
|
case string:
|
|
// Escape pipe characters and truncate long strings
|
|
escaped := strings.ReplaceAll(v, "|", "\\|")
|
|
escaped = strings.ReplaceAll(escaped, "\n", " ")
|
|
if len(escaped) > 100 {
|
|
escaped = escaped[:97] + "..."
|
|
}
|
|
return escaped
|
|
case time.Time:
|
|
return v.Format("2006-01-02 15:04:05")
|
|
case []string:
|
|
return strings.Join(v, ", ")
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// formatAsJSONL converts rows to JSONL format
|
|
func formatAsJSONL(rows []map[string]interface{}) string {
|
|
if len(rows) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for _, row := range rows {
|
|
jsonBytes, err := json.Marshal(row)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
sb.Write(jsonBytes)
|
|
sb.WriteString("\n")
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// assetToMap converts an Asset struct to a map for formatting
|
|
func assetToMap(asset *database.Asset) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"id": asset.ID,
|
|
"workspace": asset.Workspace,
|
|
"asset_value": asset.AssetValue,
|
|
"url": asset.URL,
|
|
"input": asset.Input,
|
|
"scheme": asset.Scheme,
|
|
"method": asset.Method,
|
|
"path": asset.Path,
|
|
"status_code": asset.StatusCode,
|
|
"content_type": asset.ContentType,
|
|
"content_length": asset.ContentLength,
|
|
"title": asset.Title,
|
|
"words": asset.Words,
|
|
"lines": asset.Lines,
|
|
"host_ip": asset.HostIP,
|
|
"dns_records": asset.DnsRecords,
|
|
"tls": asset.TLS,
|
|
"asset_type": asset.AssetType,
|
|
"technologies": asset.Technologies,
|
|
"response_time": asset.ResponseTime,
|
|
"remarks": asset.Remarks,
|
|
"language": asset.Language,
|
|
"size": asset.Size,
|
|
"loc": asset.LOC,
|
|
"blob_content": asset.BlobContent,
|
|
"source": asset.Source,
|
|
"created_at": asset.CreatedAt,
|
|
"updated_at": asset.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// vulnerabilityToMap converts a Vulnerability struct to a map for formatting
|
|
func vulnerabilityToMap(vuln *database.Vulnerability) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"id": vuln.ID,
|
|
"workspace": vuln.Workspace,
|
|
"vuln_info": vuln.VulnInfo,
|
|
"vuln_title": vuln.VulnTitle,
|
|
"vuln_desc": vuln.VulnDesc,
|
|
"vuln_poc": vuln.VulnPOC,
|
|
"severity": vuln.Severity,
|
|
"asset_type": vuln.AssetType,
|
|
"asset_value": vuln.AssetValue,
|
|
"tags": vuln.Tags,
|
|
"created_at": vuln.CreatedAt,
|
|
"updated_at": vuln.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// runToMap converts a Run struct to a map for formatting
|
|
func runToMap(run *database.Run) map[string]interface{} {
|
|
result := map[string]interface{}{
|
|
"run_uuid": run.RunUUID,
|
|
"workflow_name": run.WorkflowName,
|
|
"workflow_kind": run.WorkflowKind,
|
|
"target": run.Target,
|
|
"workspace": run.Workspace,
|
|
"status": run.Status,
|
|
"total_steps": run.TotalSteps,
|
|
"completed_steps": run.CompletedSteps,
|
|
"trigger_type": run.TriggerType,
|
|
}
|
|
if run.StartedAt != nil {
|
|
result["started_at"] = run.StartedAt.Format(time.RFC3339)
|
|
}
|
|
if run.CompletedAt != nil {
|
|
result["completed_at"] = run.CompletedAt.Format(time.RFC3339)
|
|
}
|
|
if run.ErrorMessage != "" {
|
|
result["error_message"] = run.ErrorMessage
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Forbidden SQL keywords for security
|
|
var forbiddenSQLKeywords = []string{
|
|
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER",
|
|
"TRUNCATE", "GRANT", "REVOKE", "EXECUTE", "EXEC",
|
|
"INTO OUTFILE", "INTO DUMPFILE", "LOAD_FILE",
|
|
}
|
|
|
|
// validateSelectQuery ensures the query is a safe SELECT statement
|
|
func validateSelectQuery(query string) error {
|
|
// Normalize query for checking
|
|
normalized := strings.ToUpper(strings.TrimSpace(query))
|
|
|
|
// Must start with SELECT
|
|
if !strings.HasPrefix(normalized, "SELECT") {
|
|
return fmt.Errorf("only SELECT queries are allowed")
|
|
}
|
|
|
|
// Check for forbidden keywords
|
|
for _, keyword := range forbiddenSQLKeywords {
|
|
if strings.Contains(normalized, keyword) {
|
|
return fmt.Errorf("query contains forbidden keyword: %s", keyword)
|
|
}
|
|
}
|
|
|
|
// Check for common SQL injection patterns
|
|
trimmedQuery := strings.TrimSpace(query)
|
|
if strings.Contains(normalized, ";") && !strings.HasSuffix(trimmedQuery, ";") {
|
|
return fmt.Errorf("multiple statements not allowed")
|
|
}
|
|
|
|
// Disallow comments that could hide malicious code
|
|
if strings.Contains(query, "--") || strings.Contains(query, "/*") {
|
|
return fmt.Errorf("SQL comments not allowed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// dbSelectAssets selects assets from the database and returns formatted output
|
|
// Usage: db_select_assets(workspace, format) -> string
|
|
// workspace: optional filter by workspace (empty = all)
|
|
// format: "markdown" or "jsonl"
|
|
func (vf *vmFunc) dbSelectAssets(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbSelectAssets"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_select_assets requires 2 arguments: workspace, format")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
format := call.Argument(1).String()
|
|
|
|
// Handle "undefined" from JavaScript
|
|
if workspace == "undefined" {
|
|
workspace = ""
|
|
}
|
|
|
|
if err := validateOutputFormat(format); err != nil {
|
|
return vf.errorValue(err.Error())
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Build query
|
|
var assets []*database.Asset
|
|
query := db.NewSelect().Model(&assets).Order("created_at DESC")
|
|
|
|
if workspace != "" {
|
|
query = query.Where("workspace = ?", workspace)
|
|
}
|
|
|
|
// Limit results to prevent memory issues
|
|
query = query.Limit(1000)
|
|
|
|
if err := query.Scan(ctx); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query failed: %v", err))
|
|
}
|
|
|
|
// Convert to maps
|
|
rows := make([]map[string]interface{}, len(assets))
|
|
for i, asset := range assets {
|
|
rows[i] = assetToMap(asset)
|
|
}
|
|
|
|
// Format output
|
|
var output string
|
|
switch format {
|
|
case "markdown":
|
|
output = formatAsMarkdownTable(rows, assetColumns)
|
|
case "jsonl":
|
|
output = formatAsJSONL(rows)
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// dbSelectAssetsFiltered selects assets from the database with multiple filters
|
|
// Usage: db_select_assets_filtered(workspace, status_code, asset_type, format) -> string
|
|
// workspace: filter by workspace (empty = all)
|
|
// status_code: filter by HTTP status code (0 = any)
|
|
// asset_type: filter by asset type (empty = any)
|
|
// format: "markdown" or "jsonl"
|
|
func (vf *vmFunc) dbSelectAssetsFiltered(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbSelectAssetsFiltered"))
|
|
|
|
if len(call.Arguments) < 4 {
|
|
return vf.errorValue("db_select_assets_filtered requires 4 arguments: workspace, status_code, asset_type, format")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
statusCodeStr := call.Argument(1).String()
|
|
assetType := call.Argument(2).String()
|
|
format := call.Argument(3).String()
|
|
|
|
// Handle "undefined" from JavaScript
|
|
if workspace == "undefined" {
|
|
workspace = ""
|
|
}
|
|
if assetType == "undefined" {
|
|
assetType = ""
|
|
}
|
|
|
|
statusCode := 0
|
|
if statusCodeStr != "" && statusCodeStr != "undefined" && statusCodeStr != "0" {
|
|
statusCode, _ = strconv.Atoi(statusCodeStr)
|
|
}
|
|
|
|
if err := validateOutputFormat(format); err != nil {
|
|
return vf.errorValue(err.Error())
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Build query with filters
|
|
var assets []*database.Asset
|
|
query := db.NewSelect().Model(&assets).Order("created_at DESC")
|
|
|
|
if workspace != "" {
|
|
query = query.Where("workspace = ?", workspace)
|
|
}
|
|
if statusCode != 0 {
|
|
query = query.Where("status_code = ?", statusCode)
|
|
}
|
|
if assetType != "" {
|
|
query = query.Where("asset_type = ?", assetType)
|
|
}
|
|
|
|
// Limit results to prevent memory issues
|
|
query = query.Limit(1000)
|
|
|
|
if err := query.Scan(ctx); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query failed: %v", err))
|
|
}
|
|
|
|
// Convert to maps
|
|
rows := make([]map[string]interface{}, len(assets))
|
|
for i, asset := range assets {
|
|
rows[i] = assetToMap(asset)
|
|
}
|
|
|
|
// Format output
|
|
var output string
|
|
switch format {
|
|
case "markdown":
|
|
output = formatAsMarkdownTable(rows, assetColumns)
|
|
case "jsonl":
|
|
output = formatAsJSONL(rows)
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// dbSelectVulnerabilities selects vulnerabilities from the database and returns formatted output
|
|
// Usage: db_select_vulnerabilities(workspace, format) -> string
|
|
// workspace: optional filter by workspace (empty = all)
|
|
// format: "markdown" or "jsonl"
|
|
func (vf *vmFunc) dbSelectVulnerabilities(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbSelectVulnerabilities"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_select_vulnerabilities requires 2 arguments: workspace, format")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
format := call.Argument(1).String()
|
|
|
|
// Handle "undefined" from JavaScript
|
|
if workspace == "undefined" {
|
|
workspace = ""
|
|
}
|
|
|
|
if err := validateOutputFormat(format); err != nil {
|
|
return vf.errorValue(err.Error())
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Build query
|
|
var vulns []*database.Vulnerability
|
|
query := db.NewSelect().Model(&vulns).Order("created_at DESC")
|
|
|
|
if workspace != "" {
|
|
query = query.Where("workspace = ?", workspace)
|
|
}
|
|
|
|
// Limit results to prevent memory issues
|
|
query = query.Limit(1000)
|
|
|
|
if err := query.Scan(ctx); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query failed: %v", err))
|
|
}
|
|
|
|
// Convert to maps
|
|
rows := make([]map[string]interface{}, len(vulns))
|
|
for i, vuln := range vulns {
|
|
rows[i] = vulnerabilityToMap(vuln)
|
|
}
|
|
|
|
// Format output
|
|
var output string
|
|
switch format {
|
|
case "markdown":
|
|
output = formatAsMarkdownTable(rows, vulnerabilityColumns)
|
|
case "jsonl":
|
|
output = formatAsJSONL(rows)
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// dbSelectVulnerabilitiesFiltered selects vulnerabilities from the database with multiple filters
|
|
// Usage: db_select_vulnerabilities_filtered(workspace, severity, asset_value, format) -> string
|
|
// workspace: filter by workspace (empty = all)
|
|
// severity: filter by severity level (empty = any) - critical, high, medium, low
|
|
// asset_value: filter by asset value (empty = any)
|
|
// format: "markdown" or "jsonl"
|
|
func (vf *vmFunc) dbSelectVulnerabilitiesFiltered(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbSelectVulnerabilitiesFiltered"))
|
|
|
|
if len(call.Arguments) < 4 {
|
|
return vf.errorValue("db_select_vulnerabilities_filtered requires 4 arguments: workspace, severity, asset_value, format")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
severity := call.Argument(1).String()
|
|
assetValue := call.Argument(2).String()
|
|
format := call.Argument(3).String()
|
|
|
|
// Handle "undefined" from JavaScript
|
|
if workspace == "undefined" {
|
|
workspace = ""
|
|
}
|
|
if severity == "undefined" {
|
|
severity = ""
|
|
}
|
|
if assetValue == "undefined" {
|
|
assetValue = ""
|
|
}
|
|
|
|
if err := validateOutputFormat(format); err != nil {
|
|
return vf.errorValue(err.Error())
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Build query with filters
|
|
var vulns []*database.Vulnerability
|
|
query := db.NewSelect().Model(&vulns).Order("created_at DESC")
|
|
|
|
if workspace != "" {
|
|
query = query.Where("workspace = ?", workspace)
|
|
}
|
|
if severity != "" {
|
|
query = query.Where("severity = ?", severity)
|
|
}
|
|
if assetValue != "" {
|
|
query = query.Where("asset_value = ?", assetValue)
|
|
}
|
|
|
|
// Limit results to prevent memory issues
|
|
query = query.Limit(1000)
|
|
|
|
if err := query.Scan(ctx); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query failed: %v", err))
|
|
}
|
|
|
|
// Convert to maps
|
|
rows := make([]map[string]interface{}, len(vulns))
|
|
for i, vuln := range vulns {
|
|
rows[i] = vulnerabilityToMap(vuln)
|
|
}
|
|
|
|
// Format output
|
|
var output string
|
|
switch format {
|
|
case "markdown":
|
|
output = formatAsMarkdownTable(rows, vulnerabilityColumns)
|
|
case "jsonl":
|
|
output = formatAsJSONL(rows)
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// dbSelect executes an arbitrary SELECT query and returns formatted output
|
|
// Usage: db_select(sql_query, format) -> string
|
|
// sql_query: SELECT SQL query (only SELECT allowed)
|
|
// format: "markdown" or "jsonl"
|
|
func (vf *vmFunc) dbSelect(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbSelect"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_select requires 2 arguments: sql_query, format")
|
|
}
|
|
|
|
sqlQuery := call.Argument(0).String()
|
|
format := call.Argument(1).String()
|
|
|
|
if sqlQuery == "" || sqlQuery == "undefined" {
|
|
return vf.errorValue("sql_query cannot be empty")
|
|
}
|
|
|
|
if err := validateOutputFormat(format); err != nil {
|
|
return vf.errorValue(err.Error())
|
|
}
|
|
|
|
// Security validation
|
|
if err := validateSelectQuery(sqlQuery); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("security check failed: %v", err))
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Execute raw query
|
|
sqlDB := db.DB
|
|
|
|
rawRows, err := sqlDB.QueryContext(ctx, sqlQuery)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query execution failed: %v", err))
|
|
}
|
|
defer func() { _ = rawRows.Close() }()
|
|
|
|
// Get column names
|
|
columns, err := rawRows.Columns()
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to get columns: %v", err))
|
|
}
|
|
|
|
// Scan all rows
|
|
var rows []map[string]interface{}
|
|
for rawRows.Next() {
|
|
// Create slice to hold column values
|
|
values := make([]interface{}, len(columns))
|
|
valuePtrs := make([]interface{}, len(columns))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := rawRows.Scan(valuePtrs...); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to scan row: %v", err))
|
|
}
|
|
|
|
// Convert to map
|
|
row := make(map[string]interface{})
|
|
for i, col := range columns {
|
|
val := values[i]
|
|
// Handle byte slices (common for TEXT columns)
|
|
if b, ok := val.([]byte); ok {
|
|
row[col] = string(b)
|
|
} else {
|
|
row[col] = val
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
|
|
// Limit results
|
|
if len(rows) >= 1000 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if err := rawRows.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("row iteration error: %v", err))
|
|
}
|
|
|
|
// Format output
|
|
var output string
|
|
switch format {
|
|
case "markdown":
|
|
output = formatAsMarkdownTable(rows, columns)
|
|
case "jsonl":
|
|
output = formatAsJSONL(rows)
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// dbRegisterArtifact registers a file as an artifact in the database
|
|
// Usage: register_artifact('/path/to/file.txt', 'text')
|
|
// type is optional, defaults to 'text'. Supported: text, jsonl, markdown, html
|
|
func (vf *vmFunc) dbRegisterArtifact(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return vf.errorValue("register_artifact requires at least 1 argument: file_path")
|
|
}
|
|
|
|
filePath := call.Argument(0).String()
|
|
if filePath == "" || filePath == "undefined" {
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
// Get optional type, default to "text"
|
|
artifactType := "text"
|
|
if len(call.Arguments) >= 2 {
|
|
typeArg := call.Argument(1).String()
|
|
if typeArg != "" && typeArg != "undefined" {
|
|
artifactType = typeArg
|
|
}
|
|
}
|
|
|
|
// Validate artifact type
|
|
if !validArtifactTypes[artifactType] {
|
|
return vf.errorValue(fmt.Sprintf("invalid artifact type '%s'. Supported: text, jsonl, markdown, html", artifactType))
|
|
}
|
|
|
|
// Check if file exists and get stats
|
|
fileInfo, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("file not found: %s", filePath))
|
|
}
|
|
|
|
// Get run ID from context (integer for database foreign key)
|
|
runID := vf.getContext().runID
|
|
if runID == 0 {
|
|
return vf.errorValue("run ID not set in context")
|
|
}
|
|
|
|
// Count lines in file
|
|
lineCount, _ := countNonEmptyLines(filePath)
|
|
|
|
// Generate unique ID
|
|
artifactID := fmt.Sprintf("artifact-%d", time.Now().UnixNano())
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create artifact record
|
|
artifact := database.Artifact{
|
|
ID: artifactID,
|
|
RunID: runID,
|
|
Name: filepath.Base(filePath),
|
|
ArtifactPath: filePath,
|
|
ArtifactType: artifactType,
|
|
ContentType: database.ContentTypeUnknown,
|
|
SizeBytes: fileInfo.Size(),
|
|
LineCount: lineCount,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// Insert or update artifact
|
|
_, err = db.NewInsert().Model(&artifact).
|
|
On("CONFLICT (id) DO UPDATE").
|
|
Set("artifact_path = EXCLUDED.artifact_path").
|
|
Set("artifact_type = EXCLUDED.artifact_type").
|
|
Set("content_type = EXCLUDED.content_type").
|
|
Set("size_bytes = EXCLUDED.size_bytes").
|
|
Set("line_count = EXCLUDED.line_count").
|
|
Exec(ctx)
|
|
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to register artifact: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
func (vf *vmFunc) storeArtifact(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return vf.errorValue("store_artifact requires at least 1 argument: file_path")
|
|
}
|
|
|
|
filePath := call.Argument(0).String()
|
|
if filePath == "" || filePath == "undefined" {
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
workspaceName := vf.getContext().workspaceName
|
|
if workspaceName == "" {
|
|
return vf.errorValue("workspace not set in context")
|
|
}
|
|
|
|
runID := vf.getContext().runID
|
|
if runID == 0 {
|
|
return vf.errorValue("run ID not set in context")
|
|
}
|
|
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("file not found: %s", filePath))
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
lineCount := 0
|
|
sizeBytes := int64(0)
|
|
var contentType string
|
|
if info.IsDir() {
|
|
contentType = database.ContentTypeFolder
|
|
} else {
|
|
sizeBytes = info.Size()
|
|
lineCount, _ = countNonEmptyLines(filePath)
|
|
contentType = inferContentTypeFromPath(filePath)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
artifact := database.Artifact{
|
|
ID: uuid.New().String(),
|
|
RunID: runID,
|
|
Workspace: workspaceName,
|
|
Name: filepath.Base(filePath),
|
|
ArtifactPath: filePath,
|
|
ArtifactType: database.ArtifactTypeOutput,
|
|
ContentType: contentType,
|
|
SizeBytes: sizeBytes,
|
|
LineCount: lineCount,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
_, err = db.NewInsert().Model(&artifact).
|
|
On("CONFLICT (id) DO UPDATE").
|
|
Set("run_id = EXCLUDED.run_id").
|
|
Set("workspace = EXCLUDED.workspace").
|
|
Set("name = EXCLUDED.name").
|
|
Set("artifact_path = EXCLUDED.artifact_path").
|
|
Set("artifact_type = EXCLUDED.artifact_type").
|
|
Set("content_type = EXCLUDED.content_type").
|
|
Set("size_bytes = EXCLUDED.size_bytes").
|
|
Set("line_count = EXCLUDED.line_count").
|
|
Set("description = EXCLUDED.description").
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to store artifact: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
func inferContentTypeFromPath(filePath string) string {
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
|
|
switch ext {
|
|
case "json":
|
|
return database.ContentTypeJSON
|
|
case "jsonl":
|
|
return database.ContentTypeJSONL
|
|
case "yaml", "yml":
|
|
return database.ContentTypeYAML
|
|
case "html", "htm":
|
|
return database.ContentTypeHTML
|
|
case "md", "markdown":
|
|
return database.ContentTypeMarkdown
|
|
case "log":
|
|
return database.ContentTypeLog
|
|
case "pdf":
|
|
return database.ContentTypePDF
|
|
case "png":
|
|
return database.ContentTypePNG
|
|
case "zip":
|
|
return database.ContentTypeZip
|
|
case "txt", "text":
|
|
return database.ContentTypeText
|
|
default:
|
|
return database.ContentTypeUnknown
|
|
}
|
|
}
|
|
|
|
// getWorkspaceStats retrieves workspace statistics from database
|
|
func (vf *vmFunc) getWorkspaceStats(workspaceName string) (*database.Workspace, error) {
|
|
if workspaceName == "" {
|
|
return nil, fmt.Errorf("workspace name not set")
|
|
}
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return nil, fmt.Errorf("database not connected")
|
|
}
|
|
var ws database.Workspace
|
|
err := db.NewSelect().Model(&ws).Where("name = ?", workspaceName).Scan(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ws, nil
|
|
}
|
|
|
|
// dbSelectTotalSubdomains returns the current total_subdomains from workspace
|
|
// Usage: db_select_total_subdomains() -> int
|
|
func (vf *vmFunc) dbSelectTotalSubdomains(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_total_subdomains"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_total_subdomains")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.TotalSubdomains)
|
|
}
|
|
|
|
// dbSelectTotalURLs returns the current total_urls from workspace
|
|
// Usage: db_select_total_urls() -> int
|
|
func (vf *vmFunc) dbSelectTotalURLs(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_total_urls"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_total_urls")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.TotalURLs)
|
|
}
|
|
|
|
// dbSelectTotalAssets returns the current total_assets from workspace
|
|
// Usage: db_select_total_assets() -> int
|
|
func (vf *vmFunc) dbSelectTotalAssets(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_total_assets"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_total_assets")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.TotalAssets)
|
|
}
|
|
|
|
// dbSelectTotalVulns returns the current total_vulns from workspace
|
|
// Usage: db_select_total_vulns() -> int
|
|
func (vf *vmFunc) dbSelectTotalVulns(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_total_vulns"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_total_vulns")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.TotalVulns)
|
|
}
|
|
|
|
// dbSelectVulnCritical returns the current vuln_critical count from workspace
|
|
// Usage: db_select_vuln_critical() -> int
|
|
func (vf *vmFunc) dbSelectVulnCritical(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_vuln_critical"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_vuln_critical")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.VulnCritical)
|
|
}
|
|
|
|
// dbSelectVulnHigh returns the current vuln_high count from workspace
|
|
// Usage: db_select_vuln_high() -> int
|
|
func (vf *vmFunc) dbSelectVulnHigh(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_vuln_high"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_vuln_high")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.VulnHigh)
|
|
}
|
|
|
|
// dbSelectVulnMedium returns the current vuln_medium count from workspace
|
|
// Usage: db_select_vuln_medium() -> int
|
|
func (vf *vmFunc) dbSelectVulnMedium(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_vuln_medium"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_vuln_medium")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.VulnMedium)
|
|
}
|
|
|
|
// dbSelectVulnLow returns the current vuln_low count from workspace
|
|
// Usage: db_select_vuln_low() -> int
|
|
func (vf *vmFunc) dbSelectVulnLow(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_select_vuln_low"))
|
|
ws, err := vf.getWorkspaceStats(vf.getContext().workspaceName)
|
|
if err != nil {
|
|
logger.Get().Debug(terminal.HiGreen("db_select_vuln_low")+" error", zap.Error(err))
|
|
return vf.vm.ToValue(0)
|
|
}
|
|
return vf.vm.ToValue(ws.VulnLow)
|
|
}
|
|
|
|
// dbSelectToFile executes a SELECT query and writes markdown output to a file
|
|
// Usage: db_select_to_file(sql_query, dest_file) -> bool
|
|
func (vf *vmFunc) dbSelectToFile(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbSelectToFile"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_select_to_file requires 2 arguments: sql_query, dest_file")
|
|
}
|
|
|
|
sqlQuery := call.Argument(0).String()
|
|
destFile := call.Argument(1).String()
|
|
|
|
if sqlQuery == "" || sqlQuery == "undefined" {
|
|
return vf.errorValue("sql_query cannot be empty")
|
|
}
|
|
|
|
if destFile == "" || destFile == "undefined" {
|
|
return vf.errorValue("dest_file cannot be empty")
|
|
}
|
|
|
|
// Security validation
|
|
if err := validateSelectQuery(sqlQuery); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("security check failed: %v", err))
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Execute raw query
|
|
sqlDB := db.DB
|
|
|
|
rawRows, err := sqlDB.QueryContext(ctx, sqlQuery)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query execution failed: %v", err))
|
|
}
|
|
defer func() { _ = rawRows.Close() }()
|
|
|
|
// Get column names
|
|
columns, err := rawRows.Columns()
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to get columns: %v", err))
|
|
}
|
|
|
|
// Scan all rows
|
|
var rows []map[string]interface{}
|
|
for rawRows.Next() {
|
|
// Create slice to hold column values
|
|
values := make([]interface{}, len(columns))
|
|
valuePtrs := make([]interface{}, len(columns))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := rawRows.Scan(valuePtrs...); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to scan row: %v", err))
|
|
}
|
|
|
|
// Convert to map
|
|
row := make(map[string]interface{})
|
|
for i, col := range columns {
|
|
val := values[i]
|
|
// Handle byte slices (common for TEXT columns)
|
|
if b, ok := val.([]byte); ok {
|
|
row[col] = string(b)
|
|
} else {
|
|
row[col] = val
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
|
|
// Limit results
|
|
if len(rows) >= 1000 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if err := rawRows.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("row iteration error: %v", err))
|
|
}
|
|
|
|
// Format as markdown
|
|
output := formatAsMarkdownTable(rows, columns)
|
|
|
|
// Ensure dest directory exists
|
|
if err := os.MkdirAll(filepath.Dir(destFile), 0755); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to create dest directory: %v", err))
|
|
}
|
|
|
|
// Write to file
|
|
if err := os.WriteFile(destFile, []byte(output), 0644); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
logger.Get().Debug("dbSelectToFile completed",
|
|
zap.String("dest", destFile),
|
|
zap.Int("rows", len(rows)))
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// dbSelectToJSONL executes a SELECT query and writes JSONL output with specified fields to a file
|
|
// Usage: db_select_to_jsonl(sql_query, fields, dest_file) -> bool
|
|
// fields: comma-separated field names to include in output (e.g., "host,port,status")
|
|
func (vf *vmFunc) dbSelectToJSONL(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbSelectToJSONL"))
|
|
|
|
if len(call.Arguments) < 3 {
|
|
return vf.errorValue("db_select_to_jsonl requires 3 arguments: sql_query, fields, dest_file")
|
|
}
|
|
|
|
sqlQuery := call.Argument(0).String()
|
|
fieldsArg := call.Argument(1).String()
|
|
destFile := call.Argument(2).String()
|
|
|
|
if sqlQuery == "" || sqlQuery == "undefined" {
|
|
return vf.errorValue("sql_query cannot be empty")
|
|
}
|
|
|
|
if destFile == "" || destFile == "undefined" {
|
|
return vf.errorValue("dest_file cannot be empty")
|
|
}
|
|
|
|
// Parse fields (comma-separated)
|
|
var fields []string
|
|
if fieldsArg != "" && fieldsArg != "undefined" {
|
|
for _, f := range strings.Split(fieldsArg, ",") {
|
|
f = strings.TrimSpace(f)
|
|
if f != "" {
|
|
fields = append(fields, f)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Security validation
|
|
if err := validateSelectQuery(sqlQuery); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("security check failed: %v", err))
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Execute raw query
|
|
sqlDB := db.DB
|
|
|
|
rawRows, err := sqlDB.QueryContext(ctx, sqlQuery)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query execution failed: %v", err))
|
|
}
|
|
defer func() { _ = rawRows.Close() }()
|
|
|
|
// Get column names
|
|
columns, err := rawRows.Columns()
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to get columns: %v", err))
|
|
}
|
|
|
|
// If no fields specified, use all columns
|
|
if len(fields) == 0 {
|
|
fields = columns
|
|
}
|
|
|
|
// Ensure dest directory exists
|
|
if err := os.MkdirAll(filepath.Dir(destFile), 0755); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to create dest directory: %v", err))
|
|
}
|
|
|
|
// Create output file
|
|
outFile, err := os.Create(destFile)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to create file: %v", err))
|
|
}
|
|
defer func() { _ = outFile.Close() }()
|
|
|
|
writer := bufio.NewWriter(outFile)
|
|
rowCount := 0
|
|
|
|
for rawRows.Next() {
|
|
// Create slice to hold column values
|
|
values := make([]interface{}, len(columns))
|
|
valuePtrs := make([]interface{}, len(columns))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := rawRows.Scan(valuePtrs...); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to scan row: %v", err))
|
|
}
|
|
|
|
// Convert to map with only requested fields
|
|
row := make(map[string]interface{})
|
|
for i, col := range columns {
|
|
// Check if this column is in the requested fields
|
|
for _, f := range fields {
|
|
if f == col {
|
|
val := values[i]
|
|
// Handle byte slices (common for TEXT columns)
|
|
if b, ok := val.([]byte); ok {
|
|
row[col] = string(b)
|
|
} else {
|
|
row[col] = val
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write as JSON line
|
|
jsonBytes, err := json.Marshal(row)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
_, _ = writer.Write(jsonBytes)
|
|
_, _ = writer.WriteString("\n")
|
|
rowCount++
|
|
|
|
// Limit results
|
|
if rowCount >= 1000 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if err := rawRows.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("row iteration error: %v", err))
|
|
}
|
|
|
|
if err := writer.Flush(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to flush file: %v", err))
|
|
}
|
|
|
|
logger.Get().Debug("dbSelectToJSONL completed",
|
|
zap.String("dest", destFile),
|
|
zap.Int("rows", rowCount),
|
|
zap.Strings("fields", fields))
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// dbImportAssetFromFile imports assets from a JSONL file (httpx format)
|
|
// Usage: db_import_asset_from_file(workspace, file_path[, source]) -> map with stats {new, updated, unchanged, errors, total}
|
|
func (vf *vmFunc) dbImportAssetFromFile(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbImportAssetFromFile"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_import_asset_from_file requires 2 arguments: workspace, file_path")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
filePath := call.Argument(1).String()
|
|
|
|
// Parse optional source (3rd arg), default to "web"
|
|
var defaultSource string
|
|
if len(call.Arguments) >= 3 {
|
|
defaultSource = call.Argument(2).String()
|
|
}
|
|
if defaultSource == "" || defaultSource == "undefined" {
|
|
defaultSource = "web"
|
|
}
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
|
|
if filePath == "" || filePath == "undefined" {
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
// Open file
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to open file: %v", err))
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
ctx := context.Background()
|
|
stats := database.ImportStats{}
|
|
|
|
// Use scanner with larger buffer for large JSONL files
|
|
scanner := bufio.NewScanner(file)
|
|
buf := make([]byte, 0, 10*1024*1024) // 10MB buffer
|
|
scanner.Buffer(buf, 10*1024*1024)
|
|
|
|
now := time.Now()
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse JSON into map
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal([]byte(line), &data); err != nil {
|
|
logger.Get().Debug("skipping invalid JSON line", zap.Error(err))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
// Map httpx fields to Asset model
|
|
asset := mapJSONToAsset(data, workspace, line)
|
|
asset.LastSeenAt = now
|
|
|
|
if asset.Source == "" && defaultSource != "" {
|
|
asset.Source = defaultSource
|
|
}
|
|
|
|
// Check if asset already exists
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ?", workspace).
|
|
Where("asset_value = ?", asset.AssetValue).
|
|
Where("url = ?", asset.URL).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// New asset - insert
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
_, insertErr := db.NewInsert().Model(&asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
logger.Get().Debug("failed to insert asset", zap.Error(insertErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.New++
|
|
} else {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeAssetFields(&existing, &asset)
|
|
asset.UpdatedAt = now
|
|
|
|
if hasAssetChanged(&existing, &asset) {
|
|
_, updateErr := db.NewUpdate().Model(&asset).WherePK().Exec(ctx)
|
|
if updateErr != nil {
|
|
logger.Get().Debug("failed to update asset", zap.Error(updateErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.Updated++
|
|
} else {
|
|
// Unchanged - only update last_seen_at
|
|
_, updateErr := db.NewUpdate().Model((*database.Asset)(nil)).
|
|
Set("last_seen_at = ?", now).
|
|
Where("id = ?", existing.ID).
|
|
Exec(ctx)
|
|
if updateErr != nil {
|
|
logger.Get().Debug("failed to update last_seen_at", zap.Error(updateErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.Unchanged++
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("error reading file: %v", err))
|
|
}
|
|
|
|
total := stats.New + stats.Updated + stats.Unchanged
|
|
logger.Get().Debug("dbImportAssetFromFile completed",
|
|
zap.String("workspace", workspace),
|
|
zap.String("file", filePath),
|
|
zap.Int("new", stats.New),
|
|
zap.Int("updated", stats.Updated),
|
|
zap.Int("unchanged", stats.Unchanged),
|
|
zap.Int("errors", stats.Errors),
|
|
zap.Int("total", total))
|
|
|
|
// Return stats as a map
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"new": stats.New,
|
|
"updated": stats.Updated,
|
|
"unchanged": stats.Unchanged,
|
|
"errors": stats.Errors,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// hasAssetChanged compares two assets for meaningful changes
|
|
func hasAssetChanged(existing, new *database.Asset) bool {
|
|
return existing.StatusCode != new.StatusCode ||
|
|
existing.Title != new.Title ||
|
|
existing.ContentType != new.ContentType ||
|
|
existing.ContentLength != new.ContentLength ||
|
|
existing.HostIP != new.HostIP ||
|
|
existing.TLS != new.TLS ||
|
|
existing.Words != new.Words ||
|
|
existing.Lines != new.Lines ||
|
|
existing.IsCDN != new.IsCDN ||
|
|
existing.IsCloud != new.IsCloud ||
|
|
existing.IsWAF != new.IsWAF ||
|
|
!slicesEqual(existing.Technologies, new.Technologies)
|
|
}
|
|
|
|
// slicesEqual compares two string slices for equality
|
|
func slicesEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// mapJSONToAsset maps httpx JSON fields to Asset model
|
|
func mapJSONToAsset(data map[string]interface{}, workspace, rawLine string) database.Asset {
|
|
asset := database.Asset{
|
|
Workspace: workspace,
|
|
RawJsonData: rawLine,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Map httpx fields to Asset fields
|
|
if v, ok := data["host"].(string); ok {
|
|
asset.AssetValue = v
|
|
}
|
|
if v, ok := data["url"].(string); ok {
|
|
asset.URL = v
|
|
}
|
|
if v, ok := data["input"].(string); ok {
|
|
asset.Input = v
|
|
}
|
|
if v, ok := data["scheme"].(string); ok {
|
|
asset.Scheme = v
|
|
}
|
|
if v, ok := data["method"].(string); ok {
|
|
asset.Method = v
|
|
}
|
|
if v, ok := data["path"].(string); ok {
|
|
asset.Path = v
|
|
}
|
|
if v, ok := data["status_code"].(float64); ok {
|
|
asset.StatusCode = int(v)
|
|
}
|
|
if v, ok := data["content_type"].(string); ok {
|
|
asset.ContentType = v
|
|
}
|
|
if v, ok := data["content_length"].(float64); ok {
|
|
asset.ContentLength = int64(v)
|
|
}
|
|
if v, ok := data["title"].(string); ok {
|
|
asset.Title = v
|
|
}
|
|
if v, ok := data["words"].(float64); ok {
|
|
asset.Words = int(v)
|
|
}
|
|
if v, ok := data["lines"].(float64); ok {
|
|
asset.Lines = int(v)
|
|
}
|
|
if v, ok := data["host_ip"].(string); ok {
|
|
asset.HostIP = v
|
|
}
|
|
|
|
// NEW: If host field contains an IP and HostIP wasn't explicitly set, store it
|
|
if asset.HostIP == "" && asset.AssetValue != "" {
|
|
if net.ParseIP(asset.AssetValue) != nil {
|
|
asset.HostIP = asset.AssetValue
|
|
}
|
|
}
|
|
|
|
// DNS records - prefer "dns_records" key, fall back to "a" for httpx compat
|
|
if aRecords, ok := data["dns_records"].([]interface{}); ok {
|
|
var records []string
|
|
for _, r := range aRecords {
|
|
if s, ok := r.(string); ok {
|
|
records = append(records, s)
|
|
}
|
|
}
|
|
asset.DnsRecords = records
|
|
} else if aRecords, ok := data["a"].([]interface{}); ok {
|
|
var records []string
|
|
for _, r := range aRecords {
|
|
if s, ok := r.(string); ok {
|
|
records = append(records, s)
|
|
}
|
|
}
|
|
asset.DnsRecords = records
|
|
}
|
|
|
|
// Technologies
|
|
if tech, ok := data["tech"].([]interface{}); ok {
|
|
var technologies []string
|
|
for _, t := range tech {
|
|
if s, ok := t.(string); ok {
|
|
technologies = append(technologies, s)
|
|
}
|
|
}
|
|
asset.Technologies = technologies
|
|
}
|
|
|
|
// Response time
|
|
if v, ok := data["time"].(string); ok {
|
|
asset.ResponseTime = v
|
|
}
|
|
|
|
// TLS info - serialize if object
|
|
if tls, ok := data["tls"]; ok {
|
|
switch t := tls.(type) {
|
|
case string:
|
|
asset.TLS = t
|
|
case map[string]interface{}:
|
|
if tlsBytes, err := json.Marshal(t); err == nil {
|
|
asset.TLS = string(tlsBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge webserver and cdn_name into remarks
|
|
if v, ok := data["webserver"].(string); ok && v != "" {
|
|
asset.Remarks = append(asset.Remarks, v)
|
|
}
|
|
if v, ok := data["cdn_name"].(string); ok && v != "" {
|
|
asset.Remarks = append(asset.Remarks, v)
|
|
}
|
|
|
|
// CDN/WAF classification
|
|
asset.IsCDN, asset.IsCloud, asset.IsWAF = database.DetectCDNFlags(data)
|
|
|
|
// Prefer URL over raw IP for asset_value (more descriptive identifier)
|
|
if asset.AssetValue != "" && asset.URL != "" && net.ParseIP(asset.AssetValue) != nil {
|
|
asset.AssetValue = asset.URL
|
|
}
|
|
|
|
// Fallback: if asset_value is still empty, derive from URL
|
|
if asset.AssetValue == "" && asset.URL != "" {
|
|
asset.AssetValue = asset.URL
|
|
}
|
|
|
|
// Auto-classify asset_type based on asset_value
|
|
if asset.AssetType == "" && asset.AssetValue != "" {
|
|
asset.AssetType = database.ClassifyAssetType(asset.AssetValue)
|
|
}
|
|
// Refine: URL with HTTP response data -> "http"
|
|
if asset.AssetType == "url" && (asset.StatusCode > 0 || asset.ContentLength > 0) {
|
|
asset.AssetType = "http"
|
|
}
|
|
|
|
return asset
|
|
}
|
|
|
|
// domainRecords holds grouped DNS records for a single domain
|
|
type domainRecords struct {
|
|
aRecords []string
|
|
otherRecords []string // stored as "TYPE:value"
|
|
}
|
|
|
|
// dbImportDNSAsset imports DNS records from a zone-style file and groups by domain.
|
|
// Each line: domain TYPE value (whitespace-separated, 3 fields).
|
|
// Returns count of domains imported.
|
|
// Usage: db_import_dns_asset(workspace, file_path) -> int
|
|
func (vf *vmFunc) dbImportDNSAsset(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbImportDNSAsset"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_import_dns_asset requires 2 arguments: workspace, file_path")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
filePath := call.Argument(1).String()
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
if filePath == "" || filePath == "undefined" {
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to open file: %v", err))
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
// Group records by domain
|
|
domains := make(map[string]*domainRecords)
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 {
|
|
continue
|
|
}
|
|
|
|
domain := strings.TrimRight(fields[0], ".")
|
|
recordType := strings.ToUpper(fields[1])
|
|
value := strings.TrimRight(fields[2], ".")
|
|
|
|
if domain == "" || value == "" {
|
|
continue
|
|
}
|
|
|
|
dr, exists := domains[domain]
|
|
if !exists {
|
|
dr = &domainRecords{}
|
|
domains[domain] = dr
|
|
}
|
|
|
|
if recordType == "A" || recordType == "AAAA" {
|
|
dr.aRecords = append(dr.aRecords, value)
|
|
} else {
|
|
dr.otherRecords = append(dr.otherRecords, recordType+":"+value)
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("error reading file: %v", err))
|
|
}
|
|
|
|
// Upsert each domain as an asset
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
imported := 0
|
|
|
|
for domain, dr := range domains {
|
|
// Build dns_records: A records as plain IPs, others as "TYPE:value"
|
|
var dnsRecords []string
|
|
dnsRecords = append(dnsRecords, dr.aRecords...)
|
|
dnsRecords = append(dnsRecords, dr.otherRecords...)
|
|
|
|
hostIP := ""
|
|
if len(dr.aRecords) > 0 {
|
|
hostIP = dr.aRecords[0]
|
|
}
|
|
|
|
// Check if asset already exists
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ?", workspace).
|
|
Where("asset_value = ?", domain).
|
|
Where("url = ?", "").
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// New asset - insert
|
|
asset := database.Asset{
|
|
Workspace: workspace,
|
|
AssetValue: domain,
|
|
AssetType: "domain",
|
|
Source: "dns",
|
|
HostIP: hostIP,
|
|
DnsRecords: dnsRecords,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
LastSeenAt: now,
|
|
}
|
|
_, insertErr := db.NewInsert().Model(&asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
logger.Get().Debug("failed to insert DNS asset", zap.Error(insertErr))
|
|
continue
|
|
}
|
|
} else {
|
|
// Update existing
|
|
_, updateErr := db.NewUpdate().Model((*database.Asset)(nil)).
|
|
Set("dns_records = ?", dnsRecords).
|
|
Set("host_ip = ?", hostIP).
|
|
Set("source = ?", "dns").
|
|
Set("updated_at = ?", now).
|
|
Set("last_seen_at = ?", now).
|
|
Where("id = ?", existing.ID).
|
|
Exec(ctx)
|
|
if updateErr != nil {
|
|
logger.Get().Debug("failed to update DNS asset", zap.Error(updateErr))
|
|
continue
|
|
}
|
|
}
|
|
imported++
|
|
}
|
|
|
|
logger.Get().Debug("dbImportDNSAsset completed",
|
|
zap.String("workspace", workspace),
|
|
zap.String("file", filePath),
|
|
zap.Int("domains", imported))
|
|
|
|
return vf.vm.ToValue(imported)
|
|
}
|
|
|
|
// dbImportCustomAsset imports assets from a JSONL file with direct field mapping.
|
|
// Each line is JSON that maps directly to Asset struct fields via json tags.
|
|
// Returns stats map {new, updated, errors, total}.
|
|
// Usage: db_import_custom_asset(workspace, file_path) -> map
|
|
func (vf *vmFunc) dbImportCustomAsset(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbImportCustomAsset"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_import_custom_asset requires at least 2 arguments: workspace, file_path")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
filePath := call.Argument(1).String()
|
|
|
|
var defaultAssetType, defaultSource string
|
|
if len(call.Arguments) >= 3 {
|
|
defaultAssetType = call.Argument(2).String()
|
|
if defaultAssetType == "undefined" {
|
|
defaultAssetType = ""
|
|
}
|
|
}
|
|
if len(call.Arguments) >= 4 {
|
|
defaultSource = call.Argument(3).String()
|
|
if defaultSource == "undefined" {
|
|
defaultSource = ""
|
|
}
|
|
}
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
if filePath == "" || filePath == "undefined" {
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to open file: %v", err))
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
ctx := context.Background()
|
|
stats := database.ImportStats{}
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
buf := make([]byte, 0, 10*1024*1024) // 10MB buffer
|
|
scanner.Buffer(buf, 10*1024*1024)
|
|
|
|
now := time.Now()
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Detect envelope format: {"type":"...","data":{...}}
|
|
// Used by tools like vigolium that wrap records in a typed envelope.
|
|
assetJSON := []byte(line)
|
|
if extracted, skip := unwrapEnvelopeJSON(assetJSON); skip {
|
|
continue // non-asset record type (e.g. "scan", "module")
|
|
} else if extracted != nil {
|
|
assetJSON = extracted
|
|
}
|
|
|
|
var asset database.Asset
|
|
if err := unmarshalAssetJSON(assetJSON, &asset); err != nil {
|
|
logger.Get().Debug("skipping invalid JSON line", zap.Error(err))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
// Store original line as raw_json_data when not already set
|
|
if asset.RawJsonData == "" {
|
|
asset.RawJsonData = string(assetJSON)
|
|
}
|
|
|
|
// Force workspace from function argument
|
|
asset.Workspace = workspace
|
|
asset.LastSeenAt = now
|
|
|
|
// Default asset_value to url when missing
|
|
if asset.AssetValue == "" && asset.URL != "" {
|
|
asset.AssetValue = asset.URL
|
|
}
|
|
|
|
// Merge webserver into remarks
|
|
var rawData map[string]interface{}
|
|
if json.Unmarshal([]byte(line), &rawData) == nil {
|
|
if ws, ok := rawData["webserver"].(string); ok && ws != "" {
|
|
asset.Remarks = append(asset.Remarks, ws)
|
|
}
|
|
// CDN/WAF classification (OR into existing values)
|
|
isCDN, isCloud, isWAF := database.DetectCDNFlags(rawData)
|
|
asset.IsCDN = asset.IsCDN || isCDN
|
|
asset.IsCloud = asset.IsCloud || isCloud
|
|
asset.IsWAF = asset.IsWAF || isWAF
|
|
}
|
|
|
|
// Apply optional defaults when the JSONL line has no value
|
|
if asset.AssetType == "" && defaultAssetType != "" {
|
|
asset.AssetType = defaultAssetType
|
|
}
|
|
if asset.Source == "" && defaultSource != "" {
|
|
asset.Source = defaultSource
|
|
}
|
|
|
|
// Check if asset already exists
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ?", workspace).
|
|
Where("asset_value = ?", asset.AssetValue).
|
|
Where("url = ?", asset.URL).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// New asset - insert
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
_, insertErr := db.NewInsert().Model(&asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
logger.Get().Debug("failed to insert custom asset", zap.Error(insertErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.New++
|
|
} else {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeAssetFields(&existing, &asset)
|
|
asset.UpdatedAt = now
|
|
_, updateErr := db.NewUpdate().Model(&asset).WherePK().Exec(ctx)
|
|
if updateErr != nil {
|
|
logger.Get().Debug("failed to update custom asset", zap.Error(updateErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.Updated++
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("error reading file: %v", err))
|
|
}
|
|
|
|
total := stats.New + stats.Updated
|
|
logger.Get().Debug("dbImportCustomAsset completed",
|
|
zap.String("workspace", workspace),
|
|
zap.String("file", filePath),
|
|
zap.Int("new", stats.New),
|
|
zap.Int("updated", stats.Updated),
|
|
zap.Int("errors", stats.Errors),
|
|
zap.Int("total", total))
|
|
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"new": stats.New,
|
|
"updated": stats.Updated,
|
|
"errors": stats.Errors,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// dbImportVuln imports a single vulnerability from JSON data (nuclei format)
|
|
// Usage: db_import_vuln(workspace, json_data) -> bool
|
|
func (vf *vmFunc) dbImportVuln(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbImportVuln"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_import_vuln requires 2 arguments: workspace, json_data")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
jsonData := call.Argument(1).String()
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
|
|
if jsonData == "" || jsonData == "undefined" {
|
|
return vf.errorValue("json_data cannot be empty")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
// Parse JSON into map
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal([]byte(jsonData), &data); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("invalid JSON: %v", err))
|
|
}
|
|
|
|
// Map nuclei fields to Vulnerability model
|
|
vuln := mapJSONToVuln(data, workspace, jsonData)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Check if vulnerability already exists
|
|
var existing database.Vulnerability
|
|
err := db.NewSelect().Model(&existing).
|
|
Where("workspace = ?", workspace).
|
|
Where("vuln_info = ?", vuln.VulnInfo).
|
|
Where("asset_value = ?", vuln.AssetValue).
|
|
Scan(ctx)
|
|
|
|
if err == nil {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeVulnFields(&existing, &vuln)
|
|
_, err = db.NewUpdate().Model(&vuln).WherePK().Exec(ctx)
|
|
} else {
|
|
// Insert new vulnerability
|
|
_, err = db.NewInsert().Model(&vuln).Exec(ctx)
|
|
}
|
|
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("import failed: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// dbImportPortAssets imports port scan data from JSONL into assets table
|
|
// JSONL format: {"asset_value":"IP","host_ip":"IP","open_ports":["80/tcp"],"ports":{...}}
|
|
// Sets asset_type="ip" and source="portscan" by default
|
|
// Usage: db_import_port_assets(workspace, file_path, source?) -> map with stats {new, updated, unchanged, errors, total}
|
|
func (vf *vmFunc) dbImportPortAssets(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbImportPortAssets"))
|
|
|
|
// 1. Parse arguments
|
|
if len(call.Arguments) < 2 {
|
|
logger.Get().Warn("db_import_port_assets requires at least 2 arguments: workspace, file_path")
|
|
return vf.errorValue("insufficient arguments")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
filePath := call.Argument(1).String()
|
|
|
|
// Optional source override (default: "portscan")
|
|
defaultSource := "portscan"
|
|
if len(call.Arguments) >= 3 && call.Argument(2).String() != "" {
|
|
defaultSource = call.Argument(2).String()
|
|
}
|
|
|
|
// 2. Validate inputs
|
|
if workspace == "" || workspace == "undefined" {
|
|
logger.Get().Warn("db_import_port_assets: workspace cannot be empty")
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
|
|
if filePath == "" || filePath == "undefined" {
|
|
logger.Get().Warn("db_import_port_assets: file_path cannot be empty")
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
// 3. Connect to database
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
logger.Get().Error("db_import_port_assets: database not connected")
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
// 4. Open JSONL file
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
logger.Get().Warn("db_import_port_assets: failed to open file", zap.String("path", filePath), zap.Error(err))
|
|
return vf.errorValue(fmt.Sprintf("failed to open file: %v", err))
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
ctx := context.Background()
|
|
stats := database.ImportStats{}
|
|
total := 0
|
|
|
|
// 5. Create scanner with large buffer for port data
|
|
scanner := bufio.NewScanner(file)
|
|
buf := make([]byte, 0, 10*1024*1024) // 10MB buffer
|
|
scanner.Buffer(buf, 10*1024*1024)
|
|
|
|
now := time.Now()
|
|
|
|
// 6. Process each JSONL line
|
|
for scanner.Scan() {
|
|
total++
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// 7. Parse JSON line
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal([]byte(line), &data); err != nil {
|
|
logger.Get().Warn("db_import_port_assets: failed to parse JSON line",
|
|
zap.Int("line", total),
|
|
zap.Error(err))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
// 8. Build Asset from port scan data
|
|
asset := database.Asset{
|
|
Workspace: workspace,
|
|
AssetType: "ip", // Force IP type for port scans
|
|
Source: defaultSource,
|
|
LastSeenAt: now,
|
|
}
|
|
|
|
// Map fields from JSON
|
|
if v, ok := data["asset_value"].(string); ok && v != "" {
|
|
asset.AssetValue = v
|
|
}
|
|
if v, ok := data["host_ip"].(string); ok && v != "" {
|
|
asset.HostIP = v
|
|
}
|
|
|
|
// Skip if no asset_value
|
|
if asset.AssetValue == "" {
|
|
logger.Get().Warn("db_import_port_assets: missing asset_value",
|
|
zap.Int("line", total))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
// 9. Extract open_ports array
|
|
if openPortsRaw, ok := data["open_ports"]; ok {
|
|
switch v := openPortsRaw.(type) {
|
|
case []interface{}:
|
|
for _, port := range v {
|
|
if portStr, ok := port.(string); ok {
|
|
asset.OpenPorts = append(asset.OpenPorts, portStr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 10. Serialize ports map to JSON string for PortJsonData
|
|
if portsData, ok := data["ports"]; ok {
|
|
portsJSON, err := json.Marshal(portsData)
|
|
if err != nil {
|
|
logger.Get().Warn("db_import_port_assets: failed to marshal ports",
|
|
zap.String("asset", asset.AssetValue),
|
|
zap.Error(err))
|
|
} else {
|
|
asset.PortJsonData = string(portsJSON)
|
|
}
|
|
}
|
|
|
|
// 11. Check if asset exists (match by workspace + asset_value)
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ?", workspace).
|
|
Where("asset_value = ?", asset.AssetValue).
|
|
Limit(1).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// 12. Insert new asset
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
|
|
_, insertErr := db.NewInsert().Model(&asset).Exec(ctx)
|
|
if insertErr != nil {
|
|
logger.Get().Warn("db_import_port_assets: failed to insert",
|
|
zap.String("asset", asset.AssetValue),
|
|
zap.Error(insertErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
stats.New++
|
|
logger.Get().Debug("db_import_port_assets: inserted new asset",
|
|
zap.String("asset", asset.AssetValue),
|
|
zap.Int("ports", len(asset.OpenPorts)))
|
|
} else {
|
|
// 13. Merge and update existing asset
|
|
mergePortAssetFields(&existing, &asset)
|
|
asset.UpdatedAt = now
|
|
|
|
// Check if port data changed
|
|
if hasPortAssetChanged(&existing, &asset) {
|
|
_, updateErr := db.NewUpdate().Model(&asset).
|
|
Column("host_ip", "open_ports", "port_json_data", "source", "last_seen_at", "updated_at").
|
|
Where("id = ?", existing.ID).
|
|
Exec(ctx)
|
|
|
|
if updateErr != nil {
|
|
logger.Get().Warn("db_import_port_assets: failed to update",
|
|
zap.String("asset", asset.AssetValue),
|
|
zap.Error(updateErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
stats.Updated++
|
|
logger.Get().Debug("db_import_port_assets: updated asset",
|
|
zap.String("asset", asset.AssetValue),
|
|
zap.Int("ports", len(asset.OpenPorts)))
|
|
} else {
|
|
// Only update last_seen_at
|
|
_, updateErr := db.NewUpdate().Model((*database.Asset)(nil)).
|
|
Set("last_seen_at = ?", now).
|
|
Where("id = ?", existing.ID).
|
|
Exec(ctx)
|
|
|
|
if updateErr != nil {
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
stats.Unchanged++
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
logger.Get().Error("db_import_port_assets: scanner error", zap.Error(err))
|
|
return vf.errorValue(fmt.Sprintf("scanner error: %v", err))
|
|
}
|
|
|
|
// 14. Return statistics
|
|
logger.Get().Info("db_import_port_assets: import completed",
|
|
zap.String("workspace", workspace),
|
|
zap.Int("new", stats.New),
|
|
zap.Int("updated", stats.Updated),
|
|
zap.Int("unchanged", stats.Unchanged),
|
|
zap.Int("errors", stats.Errors),
|
|
zap.Int("total", total))
|
|
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"new": stats.New,
|
|
"updated": stats.Updated,
|
|
"unchanged": stats.Unchanged,
|
|
"errors": stats.Errors,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// dbImportVulnFromFile imports vulnerabilities from a JSONL file (nuclei format)
|
|
// Usage: db_import_vuln_from_file(workspace, file_path) -> map with stats {new, updated, unchanged, errors, total}
|
|
func (vf *vmFunc) dbImportVulnFromFile(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbImportVulnFromFile"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_import_vuln_from_file requires 2 arguments: workspace, file_path")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
filePath := call.Argument(1).String()
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
|
|
if filePath == "" || filePath == "undefined" {
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
// Open file
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to open file: %v", err))
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
ctx := context.Background()
|
|
stats := database.ImportStats{}
|
|
|
|
// Use scanner with larger buffer for large JSONL files
|
|
scanner := bufio.NewScanner(file)
|
|
buf := make([]byte, 0, 10*1024*1024) // 10MB buffer
|
|
scanner.Buffer(buf, 10*1024*1024)
|
|
|
|
now := time.Now()
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse JSON into map
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal([]byte(line), &data); err != nil {
|
|
logger.Get().Debug("skipping invalid JSON line", zap.Error(err))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
|
|
// Map nuclei fields to Vulnerability model
|
|
vuln := mapJSONToVuln(data, workspace, line)
|
|
vuln.LastSeenAt = now
|
|
|
|
// Check if vulnerability already exists
|
|
var existing database.Vulnerability
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ?", workspace).
|
|
Where("vuln_info = ?", vuln.VulnInfo).
|
|
Where("asset_value = ?", vuln.AssetValue).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
// New vulnerability - insert
|
|
vuln.CreatedAt = now
|
|
vuln.UpdatedAt = now
|
|
_, insertErr := db.NewInsert().Model(&vuln).Exec(ctx)
|
|
if insertErr != nil {
|
|
logger.Get().Debug("failed to insert vulnerability", zap.Error(insertErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.New++
|
|
} else {
|
|
// Merge: preserve existing non-empty fields, fill gaps with incoming
|
|
mergeVulnFields(&existing, &vuln)
|
|
vuln.UpdatedAt = now
|
|
|
|
if hasVulnChanged(&existing, &vuln) {
|
|
_, updateErr := db.NewUpdate().Model(&vuln).WherePK().Exec(ctx)
|
|
if updateErr != nil {
|
|
logger.Get().Debug("failed to update vulnerability", zap.Error(updateErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.Updated++
|
|
} else {
|
|
// Unchanged - only update last_seen_at
|
|
_, updateErr := db.NewUpdate().Model((*database.Vulnerability)(nil)).
|
|
Set("last_seen_at = ?", now).
|
|
Where("id = ?", existing.ID).
|
|
Exec(ctx)
|
|
if updateErr != nil {
|
|
logger.Get().Debug("failed to update last_seen_at", zap.Error(updateErr))
|
|
stats.Errors++
|
|
continue
|
|
}
|
|
stats.Unchanged++
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("error reading file: %v", err))
|
|
}
|
|
|
|
total := stats.New + stats.Updated + stats.Unchanged
|
|
logger.Get().Debug("dbImportVulnFromFile completed",
|
|
zap.String("workspace", workspace),
|
|
zap.String("file", filePath),
|
|
zap.Int("new", stats.New),
|
|
zap.Int("updated", stats.Updated),
|
|
zap.Int("unchanged", stats.Unchanged),
|
|
zap.Int("errors", stats.Errors),
|
|
zap.Int("total", total))
|
|
|
|
// Return stats as a map
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"new": stats.New,
|
|
"updated": stats.Updated,
|
|
"unchanged": stats.Unchanged,
|
|
"errors": stats.Errors,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// hasVulnChanged compares two vulnerabilities for meaningful changes
|
|
func hasVulnChanged(existing, new *database.Vulnerability) bool {
|
|
return existing.Severity != new.Severity ||
|
|
existing.VulnTitle != new.VulnTitle ||
|
|
existing.VulnDesc != new.VulnDesc ||
|
|
existing.Confidence != new.Confidence ||
|
|
!slicesEqual(existing.Tags, new.Tags)
|
|
}
|
|
|
|
// mapJSONToVuln maps nuclei JSON fields to Vulnerability model
|
|
func mapJSONToVuln(data map[string]interface{}, workspace, rawLine string) database.Vulnerability {
|
|
vuln := database.Vulnerability{
|
|
Workspace: workspace,
|
|
RawVulnJSON: rawLine,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// template-id -> VulnInfo
|
|
if v, ok := data["template-id"].(string); ok {
|
|
vuln.VulnInfo = v
|
|
}
|
|
|
|
// Nested info fields
|
|
if info, ok := data["info"].(map[string]interface{}); ok {
|
|
if name, ok := info["name"].(string); ok {
|
|
vuln.VulnTitle = name
|
|
}
|
|
if desc, ok := info["description"].(string); ok {
|
|
vuln.VulnDesc = desc
|
|
}
|
|
if severity, ok := info["severity"].(string); ok {
|
|
vuln.Severity = severity
|
|
}
|
|
// Tags can be array or comma-separated string
|
|
if tags, ok := info["tags"].([]interface{}); ok {
|
|
var tagList []string
|
|
for _, t := range tags {
|
|
if s, ok := t.(string); ok {
|
|
tagList = append(tagList, s)
|
|
}
|
|
}
|
|
vuln.Tags = tagList
|
|
} else if tagsStr, ok := info["tags"].(string); ok {
|
|
vuln.Tags = strings.Split(tagsStr, ",")
|
|
}
|
|
}
|
|
|
|
// host -> AssetValue
|
|
if v, ok := data["host"].(string); ok {
|
|
vuln.AssetValue = v
|
|
}
|
|
|
|
// matched-at as fallback for AssetValue
|
|
if vuln.AssetValue == "" {
|
|
if v, ok := data["matched-at"].(string); ok {
|
|
vuln.AssetValue = v
|
|
}
|
|
}
|
|
|
|
// type -> AssetType
|
|
if v, ok := data["type"].(string); ok {
|
|
vuln.AssetType = v
|
|
}
|
|
|
|
// request -> DetailHTTPRequest
|
|
if v, ok := data["request"].(string); ok {
|
|
vuln.DetailHTTPRequest = v
|
|
}
|
|
|
|
// response -> DetailHTTPResponse
|
|
if v, ok := data["response"].(string); ok {
|
|
vuln.DetailHTTPResponse = v
|
|
}
|
|
|
|
// Set default confidence to "firm" if not specified
|
|
if vuln.Confidence == "" {
|
|
vuln.Confidence = "firm"
|
|
}
|
|
|
|
return vuln
|
|
}
|
|
|
|
// mapVigoliumFindingToVuln maps a vigolium "finding" record to the Vulnerability model.
|
|
func mapVigoliumFindingToVuln(data map[string]interface{}, workspace, rawLine string) database.Vulnerability {
|
|
vuln := database.Vulnerability{
|
|
Workspace: workspace,
|
|
AssetType: "http",
|
|
RawVulnJSON: rawLine,
|
|
}
|
|
|
|
if v, ok := data["finding_hash"].(string); ok {
|
|
vuln.FindingHash = v
|
|
}
|
|
if v, ok := data["module_id"].(string); ok {
|
|
vuln.VulnInfo = v
|
|
}
|
|
if v, ok := data["module_name"].(string); ok {
|
|
vuln.VulnTitle = v
|
|
}
|
|
if v, ok := data["description"].(string); ok {
|
|
vuln.VulnDesc = v
|
|
}
|
|
if v, ok := data["severity"].(string); ok {
|
|
vuln.Severity = v
|
|
}
|
|
if v, ok := data["confidence"].(string); ok {
|
|
vuln.Confidence = v
|
|
}
|
|
|
|
// Asset value: prefer url, fall back to hostname
|
|
if v, ok := data["url"].(string); ok && v != "" {
|
|
vuln.AssetValue = v
|
|
} else if v, ok := data["hostname"].(string); ok {
|
|
vuln.AssetValue = v
|
|
}
|
|
|
|
if v, ok := data["request"].(string); ok {
|
|
vuln.DetailHTTPRequest = v
|
|
}
|
|
if v, ok := data["response"].(string); ok {
|
|
vuln.DetailHTTPResponse = v
|
|
}
|
|
|
|
// Tags from module_type, finding_source, status
|
|
for _, key := range []string{"module_type", "finding_source", "status"} {
|
|
if v, ok := data[key].(string); ok && v != "" {
|
|
vuln.Tags = append(vuln.Tags, v)
|
|
}
|
|
}
|
|
|
|
// POC from matched_at + extracted_results
|
|
var pocParts []string
|
|
for _, key := range []string{"matched_at", "extracted_results"} {
|
|
if arr, ok := data[key].([]interface{}); ok {
|
|
for _, m := range arr {
|
|
if s, ok := m.(string); ok && s != "" {
|
|
pocParts = append(pocParts, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
vuln.VulnPOC = strings.Join(pocParts, "\n")
|
|
|
|
if vuln.Confidence == "" {
|
|
vuln.Confidence = "firm"
|
|
}
|
|
|
|
return vuln
|
|
}
|
|
|
|
// dbImportVigolium imports a vigolium JSONL file, routing each record by its envelope type.
|
|
// http_record -> assets table, finding -> vulnerabilities table; scan, oast_interaction and
|
|
// any other types are skipped. Findings are deduplicated on (workspace, finding_hash), falling
|
|
// back to (workspace, vuln_info, asset_value) when finding_hash is absent.
|
|
// Usage: db_import_vigolium(workspace, file_path, [source]) -> nested stats map
|
|
//
|
|
// { assets: {new, updated, unchanged, errors}, vulns: {...}, skipped, total }
|
|
func (vf *vmFunc) dbImportVigolium(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("dbImportVigolium"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_import_vigolium requires at least 2 arguments: workspace, file_path")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
filePath := call.Argument(1).String()
|
|
|
|
source := "vigolium"
|
|
if len(call.Arguments) >= 3 {
|
|
if s := call.Argument(2).String(); s != "" && s != "undefined" {
|
|
source = s
|
|
}
|
|
}
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
if filePath == "" || filePath == "undefined" {
|
|
return vf.errorValue("file_path cannot be empty")
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to open file: %v", err))
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
ctx := context.Background()
|
|
var assetStats, vulnStats database.ImportStats
|
|
skipped := 0
|
|
total := 0
|
|
|
|
now := time.Now()
|
|
|
|
// processLine handles a single JSONL record. Extracted into a closure so the
|
|
// streaming reader below can stay a tight loop; early-outs use return.
|
|
processLine := func(line string) {
|
|
total++
|
|
|
|
// Peek at the envelope: {"type":"...","data":{...}}
|
|
var envelope struct {
|
|
Type string `json:"type"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &envelope); err != nil || envelope.Type == "" || len(envelope.Data) == 0 {
|
|
skipped++
|
|
return
|
|
}
|
|
|
|
switch envelope.Type {
|
|
case "http_record":
|
|
// Reuse the shared envelope unwrap + asset mapping path
|
|
extracted, skip := unwrapEnvelopeJSON([]byte(line))
|
|
if skip || extracted == nil {
|
|
assetStats.Errors++
|
|
return
|
|
}
|
|
|
|
var asset database.Asset
|
|
if err := unmarshalAssetJSON(extracted, &asset); err != nil {
|
|
logger.Get().Debug("vigolium: skipping invalid http_record", zap.Error(err))
|
|
assetStats.Errors++
|
|
return
|
|
}
|
|
|
|
asset.Workspace = workspace
|
|
asset.LastSeenAt = now
|
|
asset.RawJsonData = line
|
|
if asset.AssetValue == "" && asset.URL != "" {
|
|
asset.AssetValue = asset.URL
|
|
}
|
|
if asset.Source == "" {
|
|
asset.Source = source
|
|
}
|
|
|
|
var existing database.Asset
|
|
selectErr := db.NewSelect().Model(&existing).
|
|
Where("workspace = ?", workspace).
|
|
Where("asset_value = ?", asset.AssetValue).
|
|
Where("url = ?", asset.URL).
|
|
Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
asset.CreatedAt = now
|
|
asset.UpdatedAt = now
|
|
if _, insertErr := db.NewInsert().Model(&asset).Exec(ctx); insertErr != nil {
|
|
logger.Get().Debug("vigolium: failed to insert asset", zap.Error(insertErr))
|
|
assetStats.Errors++
|
|
return
|
|
}
|
|
assetStats.New++
|
|
} else {
|
|
mergeAssetFields(&existing, &asset)
|
|
asset.UpdatedAt = now
|
|
if _, updateErr := db.NewUpdate().Model(&asset).WherePK().Exec(ctx); updateErr != nil {
|
|
logger.Get().Debug("vigolium: failed to update asset", zap.Error(updateErr))
|
|
assetStats.Errors++
|
|
return
|
|
}
|
|
assetStats.Updated++
|
|
}
|
|
|
|
case "finding":
|
|
var fields map[string]interface{}
|
|
if err := json.Unmarshal(envelope.Data, &fields); err != nil {
|
|
logger.Get().Debug("vigolium: skipping invalid finding", zap.Error(err))
|
|
vulnStats.Errors++
|
|
return
|
|
}
|
|
|
|
vuln := mapVigoliumFindingToVuln(fields, workspace, line)
|
|
vuln.LastSeenAt = now
|
|
|
|
var existing database.Vulnerability
|
|
query := db.NewSelect().Model(&existing).Where("workspace = ?", workspace)
|
|
if vuln.FindingHash != "" {
|
|
query = query.Where("finding_hash = ?", vuln.FindingHash)
|
|
} else {
|
|
query = query.Where("vuln_info = ?", vuln.VulnInfo).
|
|
Where("asset_value = ?", vuln.AssetValue)
|
|
}
|
|
selectErr := query.Scan(ctx)
|
|
|
|
if selectErr != nil {
|
|
vuln.CreatedAt = now
|
|
vuln.UpdatedAt = now
|
|
if _, insertErr := db.NewInsert().Model(&vuln).Exec(ctx); insertErr != nil {
|
|
logger.Get().Debug("vigolium: failed to insert vuln", zap.Error(insertErr))
|
|
vulnStats.Errors++
|
|
return
|
|
}
|
|
vulnStats.New++
|
|
} else {
|
|
mergeVulnFields(&existing, &vuln)
|
|
vuln.UpdatedAt = now
|
|
if hasVulnChanged(&existing, &vuln) {
|
|
if _, updateErr := db.NewUpdate().Model(&vuln).WherePK().Exec(ctx); updateErr != nil {
|
|
logger.Get().Debug("vigolium: failed to update vuln", zap.Error(updateErr))
|
|
vulnStats.Errors++
|
|
return
|
|
}
|
|
vulnStats.Updated++
|
|
} else {
|
|
if _, updateErr := db.NewUpdate().Model((*database.Vulnerability)(nil)).
|
|
Set("last_seen_at = ?", now).
|
|
Where("id = ?", existing.ID).
|
|
Exec(ctx); updateErr != nil {
|
|
vulnStats.Errors++
|
|
return
|
|
}
|
|
vulnStats.Unchanged++
|
|
}
|
|
}
|
|
|
|
default:
|
|
// scan, oast_interaction, and any unknown types
|
|
skipped++
|
|
}
|
|
}
|
|
|
|
// Stream the file with a bufio.Reader rather than bufio.Scanner: vigolium
|
|
// http_record lines can embed multi-MB response bodies, which overflow the
|
|
// Scanner's max-token limit ("token too long"). ReadString grows as needed.
|
|
reader := bufio.NewReaderSize(file, 1024*1024) // 1MB read buffer
|
|
for {
|
|
lineStr, readErr := reader.ReadString('\n')
|
|
if line := strings.TrimSpace(lineStr); line != "" {
|
|
processLine(line)
|
|
}
|
|
if readErr != nil {
|
|
if readErr != io.EOF {
|
|
return vf.errorValue(fmt.Sprintf("error reading file: %v", readErr))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
logger.Get().Info("db_import_vigolium: import completed",
|
|
zap.String("workspace", workspace),
|
|
zap.Int("assets_new", assetStats.New),
|
|
zap.Int("assets_updated", assetStats.Updated),
|
|
zap.Int("vulns_new", vulnStats.New),
|
|
zap.Int("vulns_updated", vulnStats.Updated),
|
|
zap.Int("skipped", skipped),
|
|
zap.Int("total", total))
|
|
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"assets": map[string]interface{}{
|
|
"new": assetStats.New,
|
|
"updated": assetStats.Updated,
|
|
"unchanged": assetStats.Unchanged,
|
|
"errors": assetStats.Errors,
|
|
},
|
|
"vulns": map[string]interface{}{
|
|
"new": vulnStats.New,
|
|
"updated": vulnStats.Updated,
|
|
"unchanged": vulnStats.Unchanged,
|
|
"errors": vulnStats.Errors,
|
|
},
|
|
"skipped": skipped,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// getAssetDiffInternal is a helper that retrieves asset diff data
|
|
func (vf *vmFunc) getAssetDiffInternal(workspace string) (*database.AssetDiff, error) {
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return nil, fmt.Errorf("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get workspace to determine fromTime
|
|
var fromTime time.Time
|
|
ws, err := database.GetWorkspaceByName(ctx, workspace)
|
|
if err == nil && ws != nil {
|
|
fromTime = ws.CreatedAt
|
|
} else {
|
|
// Fallback: use oldest asset's created_at or 24 hours ago
|
|
var oldestAsset database.Asset
|
|
err := db.NewSelect().Model(&oldestAsset).
|
|
Where("workspace = ?", workspace).
|
|
Order("created_at ASC").
|
|
Limit(1).
|
|
Scan(ctx)
|
|
if err == nil {
|
|
fromTime = oldestAsset.CreatedAt
|
|
} else {
|
|
// Default to 24 hours ago if no data
|
|
fromTime = time.Now().Add(-24 * time.Hour)
|
|
}
|
|
}
|
|
|
|
toTime := time.Now()
|
|
|
|
logger.Get().Debug(terminal.HiGreen("db_asset_diff")+" params",
|
|
zap.String("workspace", workspace),
|
|
zap.Time("from_time", fromTime),
|
|
zap.Time("to_time", toTime))
|
|
|
|
return database.GetAssetDiff(ctx, workspace, fromTime, toTime)
|
|
}
|
|
|
|
// assetDiffToJSONL converts asset diff to JSONL format string
|
|
func assetDiffToJSONL(diff *database.AssetDiff) string {
|
|
var sb strings.Builder
|
|
|
|
// Write added assets
|
|
for _, asset := range diff.Added {
|
|
row := assetToMap(&asset)
|
|
row["diff_type"] = "added"
|
|
row["workspace_name"] = diff.WorkspaceName
|
|
if jsonBytes, err := json.Marshal(row); err == nil {
|
|
sb.Write(jsonBytes)
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Write removed assets
|
|
for _, asset := range diff.Removed {
|
|
row := assetToMap(&asset)
|
|
row["diff_type"] = "removed"
|
|
row["workspace_name"] = diff.WorkspaceName
|
|
if jsonBytes, err := json.Marshal(row); err == nil {
|
|
sb.Write(jsonBytes)
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Write changed assets
|
|
for _, change := range diff.Changed {
|
|
row := map[string]interface{}{
|
|
"diff_type": "changed",
|
|
"workspace_name": diff.WorkspaceName,
|
|
"asset_id": change.AssetID,
|
|
"asset_value": change.AssetValue,
|
|
"url": change.URL,
|
|
"changes": fieldChangeSliceToMaps(change.Changes),
|
|
}
|
|
if jsonBytes, err := json.Marshal(row); err == nil {
|
|
sb.Write(jsonBytes)
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// dbAssetDiff gets asset diff with auto-populated time range
|
|
// Usage: db_asset_diff(workspace) -> string (JSONL format)
|
|
// workspace: workspace name
|
|
// Returns: JSONL string with each line containing diff_type (added/removed/changed)
|
|
func (vf *vmFunc) dbAssetDiff(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_asset_diff"))
|
|
|
|
if len(call.Arguments) < 1 {
|
|
return vf.errorValue("db_asset_diff requires 1 argument: workspace")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
|
|
diff, err := vf.getAssetDiffInternal(workspace)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to get asset diff: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(assetDiffToJSONL(diff))
|
|
}
|
|
|
|
// dbAssetDiffToFile gets asset diff and writes to file
|
|
// Usage: db_asset_diff_to_file(workspace, dest) -> bool
|
|
// workspace: workspace name
|
|
// dest: output file path
|
|
func (vf *vmFunc) dbAssetDiffToFile(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_asset_diff_to_file"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_asset_diff_to_file requires 2 arguments: workspace, dest")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
dest := call.Argument(1).String()
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
if dest == "" || dest == "undefined" {
|
|
return vf.errorValue("dest cannot be empty")
|
|
}
|
|
|
|
diff, err := vf.getAssetDiffInternal(workspace)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to get asset diff: %v", err))
|
|
}
|
|
|
|
jsonl := assetDiffToJSONL(diff)
|
|
|
|
// Ensure dest directory exists
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to create dest directory: %v", err))
|
|
}
|
|
|
|
// Write to file
|
|
if err := os.WriteFile(dest, []byte(jsonl), 0644); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
logger.Get().Debug("db_asset_diff_to_file completed",
|
|
zap.String("dest", dest),
|
|
zap.Int("added", diff.Summary.TotalAdded),
|
|
zap.Int("removed", diff.Summary.TotalRemoved),
|
|
zap.Int("changed", diff.Summary.TotalChanged))
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// getVulnDiffInternal is a helper that retrieves vulnerability diff data
|
|
func (vf *vmFunc) getVulnDiffInternal(workspace string) (*database.VulnerabilityDiff, error) {
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return nil, fmt.Errorf("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get workspace to determine fromTime
|
|
var fromTime time.Time
|
|
ws, err := database.GetWorkspaceByName(ctx, workspace)
|
|
if err == nil && ws != nil {
|
|
fromTime = ws.CreatedAt
|
|
} else {
|
|
// Fallback: use oldest vulnerability's created_at or 24 hours ago
|
|
var oldestVuln database.Vulnerability
|
|
err := db.NewSelect().Model(&oldestVuln).
|
|
Where("workspace = ?", workspace).
|
|
Order("created_at ASC").
|
|
Limit(1).
|
|
Scan(ctx)
|
|
if err == nil {
|
|
fromTime = oldestVuln.CreatedAt
|
|
} else {
|
|
// Default to 24 hours ago if no data
|
|
fromTime = time.Now().Add(-24 * time.Hour)
|
|
}
|
|
}
|
|
|
|
toTime := time.Now()
|
|
|
|
logger.Get().Debug(terminal.HiGreen("db_vuln_diff")+" params",
|
|
zap.String("workspace", workspace),
|
|
zap.Time("from_time", fromTime),
|
|
zap.Time("to_time", toTime))
|
|
|
|
return database.GetVulnerabilityDiff(ctx, workspace, fromTime, toTime)
|
|
}
|
|
|
|
// vulnDiffToJSONL converts vulnerability diff to JSONL format string
|
|
func vulnDiffToJSONL(diff *database.VulnerabilityDiff) string {
|
|
var sb strings.Builder
|
|
|
|
// Write added vulnerabilities
|
|
for _, vuln := range diff.Added {
|
|
row := vulnerabilityToMap(&vuln)
|
|
row["diff_type"] = "added"
|
|
row["workspace_name"] = diff.WorkspaceName
|
|
if jsonBytes, err := json.Marshal(row); err == nil {
|
|
sb.Write(jsonBytes)
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Write removed vulnerabilities
|
|
for _, vuln := range diff.Removed {
|
|
row := vulnerabilityToMap(&vuln)
|
|
row["diff_type"] = "removed"
|
|
row["workspace_name"] = diff.WorkspaceName
|
|
if jsonBytes, err := json.Marshal(row); err == nil {
|
|
sb.Write(jsonBytes)
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Write changed vulnerabilities
|
|
for _, change := range diff.Changed {
|
|
row := map[string]interface{}{
|
|
"diff_type": "changed",
|
|
"workspace_name": diff.WorkspaceName,
|
|
"vuln_id": change.VulnID,
|
|
"vuln_info": change.VulnInfo,
|
|
"asset_value": change.AssetValue,
|
|
"changes": fieldChangeSliceToMaps(change.Changes),
|
|
}
|
|
if jsonBytes, err := json.Marshal(row); err == nil {
|
|
sb.Write(jsonBytes)
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// dbVulnDiff gets vulnerability diff with auto-populated time range
|
|
// Usage: db_vuln_diff(workspace) -> string (JSONL format)
|
|
// workspace: workspace name
|
|
// Returns: JSONL string with each line containing diff_type (added/removed/changed)
|
|
func (vf *vmFunc) dbVulnDiff(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_vuln_diff"))
|
|
|
|
if len(call.Arguments) < 1 {
|
|
return vf.errorValue("db_vuln_diff requires 1 argument: workspace")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
|
|
diff, err := vf.getVulnDiffInternal(workspace)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to get vulnerability diff: %v", err))
|
|
}
|
|
|
|
return vf.vm.ToValue(vulnDiffToJSONL(diff))
|
|
}
|
|
|
|
// dbVulnDiffToFile gets vulnerability diff and writes to file
|
|
// Usage: db_vuln_diff_to_file(workspace, dest) -> bool
|
|
// workspace: workspace name
|
|
// dest: output file path
|
|
func (vf *vmFunc) dbVulnDiffToFile(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen("db_vuln_diff_to_file"))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue("db_vuln_diff_to_file requires 2 arguments: workspace, dest")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
dest := call.Argument(1).String()
|
|
|
|
if workspace == "" || workspace == "undefined" {
|
|
return vf.errorValue("workspace cannot be empty")
|
|
}
|
|
if dest == "" || dest == "undefined" {
|
|
return vf.errorValue("dest cannot be empty")
|
|
}
|
|
|
|
diff, err := vf.getVulnDiffInternal(workspace)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to get vulnerability diff: %v", err))
|
|
}
|
|
|
|
jsonl := vulnDiffToJSONL(diff)
|
|
|
|
// Ensure dest directory exists
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to create dest directory: %v", err))
|
|
}
|
|
|
|
// Write to file
|
|
if err := os.WriteFile(dest, []byte(jsonl), 0644); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
logger.Get().Debug("db_vuln_diff_to_file completed",
|
|
zap.String("dest", dest),
|
|
zap.Int("added", diff.Summary.TotalAdded),
|
|
zap.Int("removed", diff.Summary.TotalRemoved),
|
|
zap.Int("changed", diff.Summary.TotalChanged))
|
|
|
|
return vf.vm.ToValue(true)
|
|
}
|
|
|
|
// fieldChangeSliceToMaps converts a slice of FieldChanges to slice of maps
|
|
func fieldChangeSliceToMaps(changes []database.FieldChange) []map[string]interface{} {
|
|
result := make([]map[string]interface{}, len(changes))
|
|
for i, change := range changes {
|
|
result[i] = map[string]interface{}{
|
|
"field": change.Field,
|
|
"old_value": change.OldValue,
|
|
"new_value": change.NewValue,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// dbSelectRuns queries run records by workspace
|
|
// Usage: run_status(workspace, format) -> string
|
|
func (vf *vmFunc) dbSelectRuns(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen(FnDBSelectRuns))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue(FnDBSelectRuns + " requires 2 arguments: workspace, format")
|
|
}
|
|
|
|
workspace := call.Argument(0).String()
|
|
format := call.Argument(1).String()
|
|
|
|
if workspace == "undefined" {
|
|
workspace = ""
|
|
}
|
|
|
|
if err := validateOutputFormat(format); err != nil {
|
|
return vf.errorValue(err.Error())
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var runs []*database.Run
|
|
query := db.NewSelect().Model(&runs).Order("started_at DESC")
|
|
|
|
if workspace != "" {
|
|
query = query.Where("workspace = ?", workspace)
|
|
}
|
|
|
|
query = query.Limit(1000)
|
|
|
|
if err := query.Scan(ctx); err != nil {
|
|
return vf.errorValue(fmt.Sprintf("query failed: %v", err))
|
|
}
|
|
|
|
rows := make([]map[string]interface{}, len(runs))
|
|
for i, run := range runs {
|
|
rows[i] = runToMap(run)
|
|
}
|
|
|
|
var output string
|
|
switch format {
|
|
case "markdown":
|
|
output = formatAsMarkdownTable(rows, runColumns)
|
|
case "jsonl":
|
|
output = formatAsJSONL(rows)
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// dbSelectRunByUUID queries a run record by UUID
|
|
// Usage: run_status_by_uuid(uuid, format) -> string
|
|
func (vf *vmFunc) dbSelectRunByUUID(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen(FnDBSelectRunByUUID))
|
|
|
|
if len(call.Arguments) < 2 {
|
|
return vf.errorValue(FnDBSelectRunByUUID + " requires 2 arguments: uuid, format")
|
|
}
|
|
|
|
runUUID := call.Argument(0).String()
|
|
format := call.Argument(1).String()
|
|
|
|
if runUUID == "" || runUUID == "undefined" {
|
|
return vf.errorValue("uuid argument is required")
|
|
}
|
|
|
|
if err := validateOutputFormat(format); err != nil {
|
|
return vf.errorValue(err.Error())
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var run database.Run
|
|
err := db.NewSelect().Model(&run).Where("run_uuid = ?", runUUID).Scan(ctx)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("run not found: %v", err))
|
|
}
|
|
|
|
rows := []map[string]interface{}{runToMap(&run)}
|
|
|
|
var output string
|
|
switch format {
|
|
case "markdown":
|
|
output = formatAsMarkdownTable(rows, runColumns)
|
|
case "jsonl":
|
|
output = formatAsJSONL(rows)
|
|
}
|
|
|
|
return vf.vm.ToValue(output)
|
|
}
|
|
|
|
// dbResetEventLogs resets the processed status of event logs from true to false
|
|
// Usage: db_reset_event_logs(workspace?, topic_pattern?) -> {reset: int, total: int}
|
|
func (vf *vmFunc) dbResetEventLogs(call goja.FunctionCall) goja.Value {
|
|
logger.Get().Debug("Calling " + terminal.HiGreen(FnDBResetEventLogs))
|
|
|
|
db := database.GetDB()
|
|
if db == nil {
|
|
return vf.errorValue("database not connected")
|
|
}
|
|
|
|
// Extract optional parameters
|
|
workspace := ""
|
|
topicPattern := ""
|
|
if len(call.Arguments) >= 1 {
|
|
workspace = call.Argument(0).String()
|
|
if workspace == "undefined" {
|
|
workspace = ""
|
|
}
|
|
}
|
|
if len(call.Arguments) >= 2 {
|
|
topicPattern = call.Argument(1).String()
|
|
if topicPattern == "undefined" {
|
|
topicPattern = ""
|
|
}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Build query to count matching processed=true events
|
|
countQuery := db.NewSelect().
|
|
Model((*database.EventLog)(nil)).
|
|
Where("processed = ?", true)
|
|
|
|
if workspace != "" {
|
|
countQuery = countQuery.Where("workspace = ?", workspace)
|
|
}
|
|
if topicPattern != "" {
|
|
likePattern := globToSQLLike(topicPattern)
|
|
countQuery = countQuery.Where("topic LIKE ?", likePattern)
|
|
}
|
|
|
|
total, err := countQuery.Count(ctx)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to count events: %v", err))
|
|
}
|
|
|
|
// Build update query
|
|
updateQuery := db.NewUpdate().
|
|
Model((*database.EventLog)(nil)).
|
|
Set("processed = ?", false).
|
|
Set("processed_at = NULL").
|
|
Where("processed = ?", true)
|
|
|
|
if workspace != "" {
|
|
updateQuery = updateQuery.Where("workspace = ?", workspace)
|
|
}
|
|
if topicPattern != "" {
|
|
likePattern := globToSQLLike(topicPattern)
|
|
updateQuery = updateQuery.Where("topic LIKE ?", likePattern)
|
|
}
|
|
|
|
result, err := updateQuery.Exec(ctx)
|
|
if err != nil {
|
|
return vf.errorValue(fmt.Sprintf("failed to reset events: %v", err))
|
|
}
|
|
|
|
resetCount, _ := result.RowsAffected()
|
|
|
|
return vf.vm.ToValue(map[string]interface{}{
|
|
"reset": resetCount,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// globToSQLLike converts a glob pattern to SQL LIKE pattern
|
|
// * -> % (match any characters)
|
|
// ? -> _ (match single character)
|
|
func globToSQLLike(pattern string) string {
|
|
var sb strings.Builder
|
|
for _, c := range pattern {
|
|
switch c {
|
|
case '*':
|
|
sb.WriteRune('%')
|
|
case '?':
|
|
sb.WriteRune('_')
|
|
case '%', '_':
|
|
// Escape SQL LIKE special chars
|
|
sb.WriteRune('\\')
|
|
sb.WriteRune(c)
|
|
default:
|
|
sb.WriteRune(c)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|