Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 99 additions & 5 deletions cmd/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,52 @@ returned in chunks and written locally as JSON.`,
return
}
}
betweenUpdatedAtStr, err := cmd.Flags().GetString("between-updated-at")
if err != nil {
a.OutputSignal.AddError(err)
return
}
if betweenUpdatedAtStr != "" {
// Parse the date range format: 2025-11-25T16:05:22Z-2025-11-25T16:05:22Z
// Split by finding the last occurrence of '-' that separates the two dates
// We need to be careful because RFC3339 dates contain '-' characters
lastDashIdx := -1
// RFC3339 format is like 2025-11-25T16:05:22Z (20 chars minimum)
// So we look for a dash that has a valid RFC3339 date before and after it
for i := 20; i < len(betweenUpdatedAtStr)-20; i++ {
if betweenUpdatedAtStr[i] == '-' {
// Check if this could be the separator
beforePart := betweenUpdatedAtStr[:i]
afterPart := betweenUpdatedAtStr[i+1:]
_, err1 := time.Parse(time.RFC3339, beforePart)
_, err2 := time.Parse(time.RFC3339, afterPart)
if err1 == nil && err2 == nil {
lastDashIdx = i
break
}
}
}

if lastDashIdx == -1 {
a.OutputSignal.AddError(fmt.Errorf("invalid between-updated-at format: expected RFC3339-RFC3339 (e.g., 2025-11-25T16:05:22Z-2025-12-01T16:05:22Z)"))
return
}

startDate := betweenUpdatedAtStr[:lastDashIdx]
endDate := betweenUpdatedAtStr[lastDashIdx+1:]

// Validate both dates
_, err := time.Parse(time.RFC3339, startDate)
if err != nil {
a.OutputSignal.AddError(fmt.Errorf("invalid start date in between-updated-at: %v", err))
return
}
_, err = time.Parse(time.RFC3339, endDate)
if err != nil {
a.OutputSignal.AddError(fmt.Errorf("invalid end date in between-updated-at: %v", err))
return
}
}
tags, err := cmd.Flags().GetStringSlice("tags")
if err != nil {
a.OutputSignal.AddError(err)
Expand Down Expand Up @@ -229,7 +275,7 @@ returned in chunks and written locally as JSON.`,
sourcesEnum = append(sourcesEnum, sourceType)
}

config := getAssetExportConfig(chunkSize, createdAtStr, updatedAtStr, lastAssessedStr, deletedAtStr, terminatedAtStr, tags, sourcesEnum, typesEnum, ipv4s, hostnames, operatingSystems, hasAgent, servicenowSysid, maxWaitTime, timeout, sleepTime, hideRawOutput)
config := getAssetExportConfig(chunkSize, createdAtStr, updatedAtStr, lastAssessedStr, deletedAtStr, terminatedAtStr, betweenUpdatedAtStr, tags, sourcesEnum, typesEnum, ipv4s, hostnames, operatingSystems, hasAgent, servicenowSysid, maxWaitTime, timeout, sleepTime, hideRawOutput)

// Generate Report
report := assets.ExportAssets(ctx, *secretConfig, *config)
Expand All @@ -244,7 +290,8 @@ returned in chunks and written locally as JSON.`,
assetExportCmd.Flags().String("last-assessed", "", "ISO datetime: only assets last_assessed (last seen) at or after this time (e.g., 2025-11-25T16:05:22Z)")
assetExportCmd.Flags().String("deleted-at", "", "ISO datetime: only assets deleted at or after this time (e.g., 2025-11-25T16:05:22Z)")
assetExportCmd.Flags().String("terminated-at", "", "ISO datetime: only assets terminated at or after this time (e.g., 2025-11-25T16:05:22Z)")
assetExportCmd.Flags().StringSlice("tags", []string{}, "Filter by asset tag in format Category:Value (repeatable, comma-separated supported)") // Client Side filter
assetExportCmd.Flags().String("between-updated-at", "", "Client-side filter: date range for updated_at in RFC3339-RFC3339 format (e.g., 2025-11-25T16:05:22Z-2025-12-01T16:05:22Z)") // Client Side filter
assetExportCmd.Flags().StringSlice("tags", []string{}, "Filter by asset tag in format Category:Value (repeatable, comma-separated supported)") // Client Side filter
assetExportCmd.Flags().StringSlice("sources", []string{}, "Filter by asset source (e.g., NESSUS_SCAN, AWS, WAS) (comma-separated supported)")
assetExportCmd.Flags().StringSlice("types", []string{"HOST", "WEBAPP"}, "Filter by asset type (e.g., HOST, WEBAPP) (repeatable, comma-separated supported)")
assetExportCmd.Flags().StringSlice("ipv4s", []string{}, "Filter by IPv4 address or CIDR (repeatable, comma-separated supported)") // Client Side filter
Expand Down Expand Up @@ -381,6 +428,46 @@ locally as JSON.`,
return
}
}
betweenSinceStr, err := cmd.Flags().GetString("between-since")
if err != nil {
a.OutputSignal.AddError(err)
return
}
if betweenSinceStr != "" {
// Parse the date range format: 2025-11-25T16:05:22Z-2025-11-25T16:05:22Z
lastDashIdx := -1
for i := 20; i < len(betweenSinceStr)-20; i++ {
if betweenSinceStr[i] == '-' {
beforePart := betweenSinceStr[:i]
afterPart := betweenSinceStr[i+1:]
_, err1 := time.Parse(time.RFC3339, beforePart)
_, err2 := time.Parse(time.RFC3339, afterPart)
if err1 == nil && err2 == nil {
lastDashIdx = i
break
}
}
}

if lastDashIdx == -1 {
a.OutputSignal.AddError(fmt.Errorf("invalid between-since format: expected RFC3339-RFC3339 (e.g., 2025-11-25T16:05:22Z-2025-12-01T16:05:22Z)"))
return
}

startDate := betweenSinceStr[:lastDashIdx]
endDate := betweenSinceStr[lastDashIdx+1:]

_, err = time.Parse(time.RFC3339, startDate)
if err != nil {
a.OutputSignal.AddError(fmt.Errorf("invalid start date in between-since: %v", err))
return
}
_, err = time.Parse(time.RFC3339, endDate)
if err != nil {
a.OutputSignal.AddError(fmt.Errorf("invalid end date in between-since: %v", err))
return
}
}
state, err := cmd.Flags().GetStringSlice("state")
if err != nil {
a.OutputSignal.AddError(err)
Expand Down Expand Up @@ -441,7 +528,7 @@ locally as JSON.`,
}

// Set configs
config := getVulnerabilityExportConfig(numAssets, chunkSize, sinceStr, lastFoundStr, lastFixedStr, firstFoundStr, indexedAtStr, stateEnum, severityEnum, includeUnlicensed, tags, maxWaitTime, timeout, sleepTime, hideRawOutput)
config := getVulnerabilityExportConfig(numAssets, chunkSize, sinceStr, lastFoundStr, lastFixedStr, firstFoundStr, indexedAtStr, betweenSinceStr, stateEnum, severityEnum, includeUnlicensed, tags, maxWaitTime, timeout, sleepTime, hideRawOutput)

// Generate Report
report := vulnerabilities.ExportVulnerabilities(ctx, *secretConfig, *config)
Expand All @@ -456,6 +543,7 @@ locally as JSON.`,
vulnExportCmd.Flags().String("last-fixed", "", "Server-side filter: only vulnerabilities with last_fixed at or after this time (e.g., 2025-11-25T16:05:22Z)")
vulnExportCmd.Flags().String("first-found", "", "Server-side filter: only vulnerabilities first_found at or after this time (e.g., 2025-11-25T16:05:22Z)")
vulnExportCmd.Flags().String("indexed-at", "", "Server-side filter: only vulnerabilities indexed at or after this time (e.g., 2025-11-25T16:05:22Z)")
vulnExportCmd.Flags().String("between-since", "", "Client-side filter: date range for last_found in RFC3339-RFC3339 format (e.g., 2025-11-25T16:05:22Z-2025-12-01T16:05:22Z)") // Client Side filter
vulnExportCmd.Flags().StringSlice("state", []string{}, "Server-side filter: Vulnerability state filter (OPEN, REOPENED, FIXED) (comma-separated supported)")
vulnExportCmd.Flags().StringSlice("severity", []string{}, "Server-side filter: Severity filter (INFO, LOW, MEDIUM, HIGH, CRITICAL) (comma-separated supported)")
vulnExportCmd.Flags().Bool("include-unlicensed", false, "Server-side filter: Include vulnerabilities on unlicensed assets")
Expand All @@ -479,7 +567,7 @@ locally as JSON.`,
}

// getAssetExportConfig returns a new VmAssetExportConfig struct with the given parameters
func getAssetExportConfig(chunkSize int, createdAt string, updatedAt string, lastAssessed string, deletedAt string, terminatedAt string, tags []string, sources []assetfern.SourceType, types []assetfern.AssetType, ipv4s []string, hostnames []string, operatingSystems []string, hasAgent bool, servicenowSysid bool, maxWaitTime int, timeout int, sleepTime int, hideRawOutput bool) *assetfern.VmAssetExportConfig {
func getAssetExportConfig(chunkSize int, createdAt string, updatedAt string, lastAssessed string, deletedAt string, terminatedAt string, betweenUpdatedAt string, tags []string, sources []assetfern.SourceType, types []assetfern.AssetType, ipv4s []string, hostnames []string, operatingSystems []string, hasAgent bool, servicenowSysid bool, maxWaitTime int, timeout int, sleepTime int, hideRawOutput bool) *assetfern.VmAssetExportConfig {
config := &assetfern.VmAssetExportConfig{
ChunkSize: chunkSize,
Tags: tags,
Expand Down Expand Up @@ -512,11 +600,14 @@ func getAssetExportConfig(chunkSize int, createdAt string, updatedAt string, las
if terminatedAt != "" {
config.TerminatedAt = &terminatedAt
}
if betweenUpdatedAt != "" {
config.BetweenUpdatedAt = &betweenUpdatedAt
}
return config
}

// getVulnerabilityExportConfig returns a new VmVulnerabilityExportConfig struct with the given parameters
func getVulnerabilityExportConfig(numAssets int, chunkSize int, since string, lastFound string, lastFixed string, firstFound string, indexedAt string, state []methodtenablefern.State, severity []methodtenablefern.Severity, includeUnlicensed bool, tags []string, maxWaitTime int, timeout int, sleepTime int, hideRawOutput bool) *vulnfern.VmVulnerabilityExportConfig {
func getVulnerabilityExportConfig(numAssets int, chunkSize int, since string, lastFound string, lastFixed string, firstFound string, indexedAt string, betweenSince string, state []methodtenablefern.State, severity []methodtenablefern.Severity, includeUnlicensed bool, tags []string, maxWaitTime int, timeout int, sleepTime int, hideRawOutput bool) *vulnfern.VmVulnerabilityExportConfig {
config := &vulnfern.VmVulnerabilityExportConfig{
NumAssets: max(numAssets, 50),
ChunkSize: chunkSize,
Expand Down Expand Up @@ -546,6 +637,9 @@ func getVulnerabilityExportConfig(numAssets int, chunkSize int, since string, la
if indexedAt != "" {
config.IndexedAt = &indexedAt
}
if betweenSince != "" {
config.BetweenSince = &betweenSince
}

return config
}
48 changes: 46 additions & 2 deletions cmd/was.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,46 @@ Data is returned in chunks and written locally as JSON.`,
return
}
}
betweenSinceStr, err := cmd.Flags().GetString("between-since")
if err != nil {
a.OutputSignal.AddError(err)
return
}
if betweenSinceStr != "" {
// Parse the date range format: 2025-11-25T16:05:22Z-2025-11-25T16:05:22Z
lastDashIdx := -1
for i := 20; i < len(betweenSinceStr)-20; i++ {
if betweenSinceStr[i] == '-' {
beforePart := betweenSinceStr[:i]
afterPart := betweenSinceStr[i+1:]
_, err1 := time.Parse(time.RFC3339, beforePart)
_, err2 := time.Parse(time.RFC3339, afterPart)
if err1 == nil && err2 == nil {
lastDashIdx = i
break
}
}
}

if lastDashIdx == -1 {
a.OutputSignal.AddError(fmt.Errorf("invalid between-since format: expected RFC3339-RFC3339 (e.g., 2025-11-25T16:05:22Z-2025-12-01T16:05:22Z)"))
return
}

startDate := betweenSinceStr[:lastDashIdx]
endDate := betweenSinceStr[lastDashIdx+1:]

_, err = time.Parse(time.RFC3339, startDate)
if err != nil {
a.OutputSignal.AddError(fmt.Errorf("invalid start date in between-since: %v", err))
return
}
_, err = time.Parse(time.RFC3339, endDate)
if err != nil {
a.OutputSignal.AddError(fmt.Errorf("invalid end date in between-since: %v", err))
return
}
}
severity, err := cmd.Flags().GetStringSlice("severity")
if err != nil {
a.OutputSignal.AddError(err)
Expand All @@ -162,7 +202,7 @@ Data is returned in chunks and written locally as JSON.`,
}

// Set config
config := getWasFindingsExportConfig(timeout, maxWaitTime, sleepTime, numAssets, sinceStr, firstFoundStr, lastFixedStr, lastFoundStr, severityEnum, hideRawOutput)
config := getWasFindingsExportConfig(timeout, maxWaitTime, sleepTime, numAssets, sinceStr, firstFoundStr, lastFixedStr, lastFoundStr, betweenSinceStr, severityEnum, hideRawOutput)

// Generate Report
report := wasfinding.ExportFindings(ctx, *secretConfig, config)
Expand All @@ -180,6 +220,7 @@ Data is returned in chunks and written locally as JSON.`,
findingsExportCmd.Flags().String("first-found", "", "Server-side filter: findings first found at or after this time (e.g., 2025-11-25T16:05:22Z)")
findingsExportCmd.Flags().String("last-fixed", "", "Server-side filter: findings fixed at or after this time (e.g., 2025-11-25T16:05:22Z)")
findingsExportCmd.Flags().String("last-found", "", "Server-side filter: findings last found at or after this time (e.g., 2025-11-25T16:05:22Z)")
findingsExportCmd.Flags().String("between-since", "", "Client-side filter: date range for last_found in RFC3339-RFC3339 format (e.g., 2025-11-25T16:05:22Z-2025-12-01T16:05:22Z)") // Client Side filter
findingsExportCmd.Flags().StringSlice("severity", []string{}, "Server-side filter: severity levels (CRITICAL, HIGH, MEDIUM, LOW, INFO) (comma-separated supported)")
findingsExportCmd.Flags().Bool("hide-raw-output", false, "Do not include raw output in the report")

Expand All @@ -194,7 +235,7 @@ Data is returned in chunks and written locally as JSON.`,
}

// getWasFindingsExportConfig creates a WAS findings export configuration
func getWasFindingsExportConfig(timeout, maxWaitTime, sleepTime, numAssets int, since, firstFound, lastFixed, lastFound string, severity []methodtenablefern.Severity, hideRawOutput bool) wasfern.WasFindingsExportConfig {
func getWasFindingsExportConfig(timeout, maxWaitTime, sleepTime, numAssets int, since, firstFound, lastFixed, lastFound, betweenSince string, severity []methodtenablefern.Severity, hideRawOutput bool) wasfern.WasFindingsExportConfig {
config := wasfern.WasFindingsExportConfig{
IncludeUnlicensed: false,
MaxWaitTime: maxWaitTime,
Expand Down Expand Up @@ -222,6 +263,9 @@ func getWasFindingsExportConfig(timeout, maxWaitTime, sleepTime, numAssets int,
if lastFound != "" {
config.LastFound = &lastFound
}
if betweenSince != "" {
config.BetweenSince = &betweenSince
}

// Set severity if provided
if len(severity) > 0 {
Expand Down
1 change: 1 addition & 0 deletions fern/definition/vm/asset/export.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ types:
updatedAt: optional<string>
lastAssessed: optional<string>
deletedAt: optional<string>
betweenUpdatedAt: optional<string>
terminatedAt: optional<string>
tags: optional<list<string>>
sources: optional<list<sourceType>>
Expand Down
1 change: 1 addition & 0 deletions fern/definition/vm/vulnerability/export.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ types:
lastFixed: optional<string>
firstFound: optional<string>
indexedAt: optional<string>
betweenSince: optional<string>
state: optional<list<common.State>>
severity: optional<list<common.Severity>>
includeUnlicensed: boolean
Expand Down
1 change: 1 addition & 0 deletions fern/definition/was/finding/export.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ types:
firstFound: optional<string>
lastFixed: optional<string>
lastFound: optional<string>
betweenSince: optional<string>
maxWaitTime: integer
sleepTime: integer
timeout: integer
Expand Down
61 changes: 60 additions & 1 deletion internal/vm/asset/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"net"
"strings"
"time"

// Generated
methodtenablefern "github.com/Method-Security/methodtenable/generated/go"
Expand Down Expand Up @@ -125,7 +126,7 @@ func filterAssets(assets []*apiassetfern.TenableAsset, config *assetfern.VmAsset

// matchesAllFilters checks if an asset matches all the specified CLIENT-SIDE-ONLY filters
// Note: Most filters are handled at API level. Only these are still client-side:
// tags, ipv4, hostname, operating_system
// tags, ipv4, hostname, operating_system, betweenUpdatedAt
func matchesAllFilters(asset *apiassetfern.TenableAsset, config *assetfern.VmAssetExportConfig) bool {

// Tag filter (not supported by API)
Expand Down Expand Up @@ -156,6 +157,13 @@ func matchesAllFilters(asset *apiassetfern.TenableAsset, config *assetfern.VmAss
}
}

// Between Updated At filter (client-side date range filter)
if config.GetBetweenUpdatedAt() != nil && *config.GetBetweenUpdatedAt() != "" {
if !matchesBetweenUpdatedAtFilter(asset, *config.GetBetweenUpdatedAt()) {
return false
}
}

return true
}

Expand Down Expand Up @@ -270,6 +278,57 @@ func matchesOperatingSystemFilter(asset *apiassetfern.TenableAsset, operatingSys
return false
}

// matchesBetweenUpdatedAtFilter checks if asset's updated_at timestamp falls within the specified date range
func matchesBetweenUpdatedAtFilter(asset *apiassetfern.TenableAsset, betweenUpdatedAt string) bool {
// Check if asset has timestamps
if asset.Timestamps == nil || asset.Timestamps.UpdatedAt == nil {
return false
}

// Parse the date range: 2025-11-25T16:05:22Z-2025-12-01T16:05:22Z
// Find the separator between the two RFC3339 dates
lastDashIdx := -1
for i := 20; i < len(betweenUpdatedAt)-20; i++ {
if betweenUpdatedAt[i] == '-' {
beforePart := betweenUpdatedAt[:i]
afterPart := betweenUpdatedAt[i+1:]
_, err1 := time.Parse(time.RFC3339, beforePart)
_, err2 := time.Parse(time.RFC3339, afterPart)
if err1 == nil && err2 == nil {
lastDashIdx = i
break
}
}
}

if lastDashIdx == -1 {
return false
}

startDateStr := betweenUpdatedAt[:lastDashIdx]
endDateStr := betweenUpdatedAt[lastDashIdx+1:]

startDate, err := time.Parse(time.RFC3339, startDateStr)
if err != nil {
return false
}

endDate, err := time.Parse(time.RFC3339, endDateStr)
if err != nil {
return false
}

// Parse the asset's updated_at timestamp
assetUpdatedAt, err := time.Parse(time.RFC3339, *asset.Timestamps.UpdatedAt)
if err != nil {
return false
}

// Check if the asset's updated_at falls within the range (inclusive)
return (assetUpdatedAt.Equal(startDate) || assetUpdatedAt.After(startDate)) &&
(assetUpdatedAt.Equal(endDate) || assetUpdatedAt.Before(endDate))
}

// transformTenableAssets transforms the filtered Tenable API response into our Asset structure
// Creates one Asset per IP address for each Tenable asset that has IP addresses
// Uses originalResult for raw data to preserve unfiltered state
Expand Down
Loading
Loading