diff --git a/providers/os/resources/date/date.go b/providers/os/resources/date/date.go new file mode 100644 index 0000000000..e8f505a4f2 --- /dev/null +++ b/providers/os/resources/date/date.go @@ -0,0 +1,35 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "errors" + "time" + + "go.mondoo.com/mql/v13/providers-sdk/v1/inventory" + "go.mondoo.com/mql/v13/providers/os/connection/shared" +) + +type Result struct { + Time *time.Time + Timezone string +} + +type Date interface { + Name() string + Get() (*Result, error) +} + +func New(conn shared.Connection) (Date, error) { + pf := conn.Asset().Platform + + switch { + case pf.IsFamily(inventory.FAMILY_UNIX): + return &Unix{conn: conn}, nil + case pf.IsFamily(inventory.FAMILY_WINDOWS): + return &Windows{conn: conn}, nil + default: + return nil, errors.New("your platform is not supported by the date resource") + } +} diff --git a/providers/os/resources/date/unix.go b/providers/os/resources/date/unix.go new file mode 100644 index 0000000000..3cce281202 --- /dev/null +++ b/providers/os/resources/date/unix.go @@ -0,0 +1,399 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/afero" + "go.mondoo.com/mql/v13/providers/os/connection/shared" +) + +// unixDateCmd gets the current UTC time. Used when RunCommand is available. +const unixDateCmd = `date -u +%Y-%m-%dT%H:%M:%SZ` + +type Unix struct { + conn shared.Connection +} + +func (u *Unix) Name() string { + return "Unix Date" +} + +func (u *Unix) Get() (*Result, error) { + canRunCmd := u.conn.Capabilities().Has(shared.Capability_RunCommand) + + // Get UTC time only if we can actually ask the remote system. + // For static targets (EBS snapshots, Docker images) there is no + // meaningful current time, so we leave it nil. + var utcTime *time.Time + if canRunCmd { + cmd, err := u.conn.RunCommand(unixDateCmd) + if err != nil { + return nil, fmt.Errorf("failed to get system date: %w", err) + } + t, err := parseUTCTime(cmd.Stdout) + if err != nil { + return nil, err + } + utcTime = &t + } + + // Get timezone: try filesystem first (works on EBS snapshots, Docker images), + // fall back to command if filesystem detection fails + tz, err := timezoneFromFS(u.conn.FileSystem()) + if err != nil && canRunCmd { + tz, err = timezoneFromCmd(u.conn) + } + if err != nil { + // If all methods fail, default to UTC + return &Result{ + Time: utcTime, + Timezone: "UTC", + }, nil + } + + loc, err := time.LoadLocation(tz) + if err != nil { + return &Result{ + Time: utcTime, + Timezone: tz, + }, nil + } + + if utcTime != nil { + t := utcTime.In(loc) + utcTime = &t + } + + return &Result{ + Time: utcTime, + Timezone: tz, + }, nil +} + +// timezoneFromFS detects the IANA timezone by reading filesystem artifacts. +// It tries these approaches in order: +// 1. readlink /etc/localtime → extract IANA name from symlink target +// 2. Read /etc/timezone (Debian/Ubuntu) +// 3. Parse TZ= from /etc/TIMEZONE (Solaris/AIX) +// 4. Match /etc/localtime contents against the system zoneinfo database +func timezoneFromFS(fs afero.Fs) (string, error) { + // 1. Try readlink on /etc/localtime + if lr, ok := fs.(afero.LinkReader); ok { + if target, err := lr.ReadlinkIfPossible("/etc/localtime"); err == nil { + if tz := extractTZFromPath(target); tz != "" { + return tz, nil + } + } + } + + // 2. Try /etc/timezone (Debian/Ubuntu) + if f, err := fs.Open("/etc/timezone"); err == nil { + defer f.Close() + content, err := io.ReadAll(f) + if err == nil { + if tz := strings.TrimSpace(string(content)); tz != "" { + return tz, nil + } + } + } + + // 3. Try /etc/TIMEZONE (Solaris/AIX) - look for TZ= + if f, err := fs.Open("/etc/TIMEZONE"); err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if tz, ok := strings.CutPrefix(line, "TZ="); ok && tz != "" { + return tz, nil + } + } + } + + // 4. Try matching /etc/localtime binary content against zoneinfo database + if tz, err := matchLocaltimeToZoneinfo(fs); err == nil { + return tz, nil + } + + return "", fmt.Errorf("could not detect timezone from filesystem") +} + +// extractTZFromPath extracts the IANA timezone name from a symlink target path. +// e.g., "/usr/share/zoneinfo/America/New_York" → "America/New_York" +func extractTZFromPath(path string) string { + const marker = "zoneinfo/" + if idx := strings.LastIndex(path, marker); idx >= 0 { + tz := path[idx+len(marker):] + if tz == "" || tz == "localtime" { + return "" + } + // Strip posix/ and right/ prefixes — these are alternate + // representations of the same zones, not valid IANA names. + tz = strings.TrimPrefix(tz, "posix/") + tz = strings.TrimPrefix(tz, "right/") + if tz == "" { + return "" + } + return tz + } + return "" +} + +// matchLocaltimeToZoneinfo reads /etc/localtime and tries to find a matching +// zoneinfo file. This handles cases where /etc/localtime is a regular file +// (copy, not symlink), common in Docker images. +func matchLocaltimeToZoneinfo(fs afero.Fs) (string, error) { + localtime, err := afero.ReadFile(fs, "/etc/localtime") + if err != nil { + return "", err + } + if len(localtime) < 4 || string(localtime[:4]) != "TZif" { + return "", fmt.Errorf("/etc/localtime is not a valid TZif file") + } + + // Fast path: parse the TZif v2/v3 footer for a POSIX TZ string. + // This avoids walking the entire zoneinfo tree, which is extremely + // expensive on tar-backed filesystems (Docker images, EBS snapshots). + if tz, err := tzFromTZifFooter(localtime); err == nil { + return tz, nil + } + + // Slow path: compare file contents against known zoneinfo files. + // Use direct reads of common timezone paths instead of walking the + // entire directory tree, which would extract every file from a tar. + if tz, err := matchLocaltimeByCommonPaths(fs, localtime); err == nil { + return tz, nil + } + + // Last resort: walk the zoneinfo tree with a file count cap. + for _, base := range []string{"/usr/share/zoneinfo", "/usr/share/lib/zoneinfo"} { + tz, err := findMatchingZoneinfo(fs, base, localtime) + if err == nil { + return tz, nil + } + } + return "", fmt.Errorf("no matching zoneinfo file found") +} + +// tzFromTZifFooter extracts the POSIX TZ string from a TZif version 2 or 3 +// file's footer and maps it to an IANA timezone name when possible. +func tzFromTZifFooter(data []byte) (string, error) { + if len(data) < 5 { + return "", fmt.Errorf("data too short") + } + + version := data[4] + if version != '2' && version != '3' && version != '4' { + return "", fmt.Errorf("TZif version %c has no footer", version) + } + + // The v2/v3 footer is at the very end: \n\n + // Search backwards for the pattern. + end := len(data) + if data[end-1] != '\n' { + return "", fmt.Errorf("no trailing newline in TZif footer") + } + // Find the preceding newline + start := end - 2 + for start >= 0 && data[start] != '\n' { + start-- + } + if start < 0 { + return "", fmt.Errorf("no footer found") + } + + posixTZ := strings.TrimSpace(string(data[start+1 : end-1])) + if posixTZ == "" || posixTZ == "UTC" || posixTZ == "UTC0" || posixTZ == "UTC-0" { + return "UTC", nil + } + + // Try mapping common POSIX TZ strings to IANA names + if iana, ok := posixToIANA[posixTZ]; ok { + return iana, nil + } + + return "", fmt.Errorf("unmapped POSIX TZ string: %s", posixTZ) +} + +// posixToIANA maps common POSIX TZ strings (from TZif footers) to IANA names. +// This covers the most common timezones; the walk fallback handles the rest. +// Note: some mappings are ambiguous (e.g., "CST-8" matches Asia/Shanghai, +// Asia/Taipei, and Asia/Hong_Kong). We pick a representative zone for each +// POSIX string. This is a best-effort fast path — the walk fallback will +// find the exact match if the footer mapping is wrong for a given system. +var posixToIANA = map[string]string{ + "EST5EDT,M3.2.0,M11.1.0": "America/New_York", + "CST6CDT,M3.2.0,M11.1.0": "America/Chicago", + "MST7MDT,M3.2.0,M11.1.0": "America/Denver", + "PST8PDT,M3.2.0,M11.1.0": "America/Los_Angeles", + "MST7": "America/Phoenix", + "HST10": "Pacific/Honolulu", + "AKST9AKDT,M3.2.0,M11.1.0": "America/Anchorage", + "GMT0BST,M3.5.0/1,M10.5.0": "Europe/London", + "CET-1CEST,M3.5.0,M10.5.0/3": "Europe/Berlin", + "EET-2EEST,M3.5.0/3,M10.5.0/4": "Europe/Helsinki", + "IST-5:30": "Asia/Kolkata", + "JST-9": "Asia/Tokyo", + "CST-8": "Asia/Shanghai", + "KST-9": "Asia/Seoul", + "AEST-10AEDT,M10.1.0,M4.1.0/3": "Australia/Sydney", + "NZST-12NZDT,M9.5.0,M4.1.0/3": "Pacific/Auckland", + "<-03>3": "America/Sao_Paulo", + "WET0WEST,M3.5.0/1,M10.5.0": "Europe/Lisbon", + "<+07>-7": "Asia/Bangkok", + "<+05>-5": "Asia/Tashkent", + "<+04>-4": "Asia/Dubai", + "<+03>-3": "Europe/Moscow", + "<+02>-2": "Africa/Cairo", + "<+01>-1": "Africa/Lagos", + "<-05>5": "America/Bogota", + "<-06>6": "America/Mexico_City", +} + +// commonTimezones is a list of frequently-used IANA timezone names, tried +// before falling back to a full directory walk. +var commonTimezones = []string{ + "UTC", "US/Eastern", "US/Central", "US/Mountain", "US/Pacific", + "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Phoenix", "America/Anchorage", "Pacific/Honolulu", + "Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Moscow", + "Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Dubai", + "Australia/Sydney", "Pacific/Auckland", + "America/Toronto", "America/Vancouver", "America/Sao_Paulo", "America/Mexico_City", + "America/Argentina/Buenos_Aires", "America/Bogota", "America/Lima", + "Europe/Rome", "Europe/Madrid", "Europe/Amsterdam", "Europe/Helsinki", + "Europe/Istanbul", "Europe/Warsaw", "Europe/Zurich", + "Asia/Seoul", "Asia/Singapore", "Asia/Hong_Kong", "Asia/Bangkok", + "Asia/Jakarta", "Asia/Taipei", "Asia/Tashkent", + "Africa/Cairo", "Africa/Lagos", "Africa/Johannesburg", + "Australia/Melbourne", "Australia/Perth", "Australia/Brisbane", + "Etc/UTC", "Etc/GMT", +} + +// matchLocaltimeByCommonPaths checks /etc/localtime against a curated list of +// common timezone files by direct path, avoiding a full directory walk. +func matchLocaltimeByCommonPaths(fs afero.Fs, localtime []byte) (string, error) { + for _, base := range []string{"/usr/share/zoneinfo", "/usr/share/lib/zoneinfo"} { + for _, tz := range commonTimezones { + candidate, err := afero.ReadFile(fs, base+"/"+tz) + if err != nil { + continue + } + if bytes.Equal(candidate, localtime) { + return tz, nil + } + } + } + return "", fmt.Errorf("no common timezone matched") +} + +// errWalkDone is a sentinel error used to stop walking the zoneinfo tree +// early, either because a match was found or the file count limit was reached. +var errWalkDone = errors.New("walk done") + +// maxZoneinfoFiles limits the number of files compared during a full zoneinfo +// tree walk. This prevents pathological performance on tar-backed filesystems +// where each file read requires extraction. 600 covers the ~350 real IANA +// zones plus overhead for legacy aliases and alternate directory layouts. +const maxZoneinfoFiles = 600 + +// findMatchingZoneinfo walks a zoneinfo directory tree comparing file contents +// to the given localtime data. +func findMatchingZoneinfo(fs afero.Fs, base string, localtime []byte) (string, error) { + var match string + var filesChecked int + err := afero.Walk(fs, base, func(path string, info os.FileInfo, err error) error { + if err != nil || match != "" { + return err + } + if info.IsDir() { + // Skip directories that aren't timezone data + name := info.Name() + if name == "posix" || name == "right" { + return filepath.SkipDir + } + return nil + } + // Only compare regular files + if !info.Mode().IsRegular() { + return nil + } + + filesChecked++ + if filesChecked > maxZoneinfoFiles { + return errWalkDone // bail out, we've checked enough + } + + candidate, err := afero.ReadFile(fs, path) + if err != nil { + return nil // skip unreadable files + } + if bytes.Equal(candidate, localtime) { + rel := strings.TrimPrefix(path, base+"/") + // Validate it looks like an IANA name (contains a slash, e.g. "America/New_York") + if strings.Contains(rel, "/") { + match = rel + return errWalkDone + } + } + return nil + }) + if err != nil && !errors.Is(err, errWalkDone) { + return "", err + } + if match == "" { + return "", fmt.Errorf("no match found in %s", base) + } + return match, nil +} + +// timezoneFromCmd gets the abbreviated timezone name via `date +%Z`. +// This is a last resort — it only returns short names like "EST", not IANA names. +func timezoneFromCmd(conn shared.Connection) (string, error) { + cmd, err := conn.RunCommand("date +%Z") + if err != nil { + return "", fmt.Errorf("failed to get system timezone: %w", err) + } + return parseTimezone(cmd.Stdout) +} + +func parseUTCTime(r io.Reader) (time.Time, error) { + content, err := io.ReadAll(r) + if err != nil { + return time.Time{}, fmt.Errorf("failed to read date output: %w", err) + } + + s := strings.TrimSpace(string(content)) + if s == "" { + return time.Time{}, fmt.Errorf("empty date output") + } + + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse date %q: %w", s, err) + } + return t, nil +} + +func parseTimezone(r io.Reader) (string, error) { + content, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("failed to read timezone output: %w", err) + } + + s := strings.TrimSpace(string(content)) + if s == "" { + return "UTC", nil + } + return s, nil +} diff --git a/providers/os/resources/date/unix_test.go b/providers/os/resources/date/unix_test.go new file mode 100644 index 0000000000..02861d7b06 --- /dev/null +++ b/providers/os/resources/date/unix_test.go @@ -0,0 +1,354 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "strings" + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseUTCTime(t *testing.T) { + tests := []struct { + name string + input string + want time.Time + wantErr bool + }{ + { + name: "valid UTC time", + input: "2026-03-17T14:30:00Z\n", + want: time.Date(2026, 3, 17, 14, 30, 0, 0, time.UTC), + }, + { + name: "empty output", + input: "", + wantErr: true, + }, + { + name: "invalid format", + input: "not a date", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseUTCTime(strings.NewReader(tt.input)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseTimezone(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "IANA timezone from readlink", + input: "America/New_York\n", + want: "America/New_York", + }, + { + name: "timezone from /etc/timezone", + input: "Europe/London\n", + want: "Europe/London", + }, + { + name: "abbreviated timezone fallback", + input: "EST\n", + want: "EST", + }, + { + name: "empty defaults to UTC", + input: "", + want: "UTC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTimezone(strings.NewReader(tt.input)) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExtractTZFromPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "standard Linux path", + path: "/usr/share/zoneinfo/America/New_York", + want: "America/New_York", + }, + { + name: "macOS path", + path: "/var/db/timezone/zoneinfo/Europe/London", + want: "Europe/London", + }, + { + name: "posix subdirectory path stripped", + path: "/usr/share/zoneinfo/posix/Asia/Tokyo", + want: "Asia/Tokyo", + }, + { + name: "right subdirectory path stripped", + path: "/usr/share/zoneinfo/right/Europe/Berlin", + want: "Europe/Berlin", + }, + { + name: "no zoneinfo marker", + path: "/some/other/path", + want: "", + }, + { + name: "zoneinfo at end with nothing after", + path: "/usr/share/zoneinfo/", + want: "", + }, + { + name: "localtime self-reference", + path: "/usr/share/zoneinfo/localtime", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, extractTZFromPath(tt.path)) + }) + } +} + +func TestTimezoneFromFS_EtcTimezone(t *testing.T) { + // Simulate a Debian/Ubuntu system with /etc/timezone + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/etc/timezone", []byte("Europe/Berlin\n"), 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "Europe/Berlin", tz) +} + +func TestTimezoneFromFS_EtcTIMEZONE(t *testing.T) { + // Simulate a Solaris/AIX system with /etc/TIMEZONE + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/etc/TIMEZONE", []byte("# timezone config\nTZ=US/Eastern\n"), 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "US/Eastern", tz) +} + +func TestTimezoneFromFS_LocaltimeBinaryMatch(t *testing.T) { + // Simulate a Docker image where /etc/localtime is a copied TZif file + fs := afero.NewMemMapFs() + + // Create a fake but valid TZif file + tzifData := []byte("TZif" + strings.Repeat("\x00", 40) + "some timezone data here") + + // Write it as /etc/localtime (a regular file, not a symlink) + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", tzifData, 0o644)) + + // Create a matching file in the zoneinfo tree + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Chicago", tzifData, 0o644)) + + // Create a non-matching file to make sure we don't false-positive + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Denver", []byte("TZif"+strings.Repeat("\x00", 40)+"different data"), 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "America/Chicago", tz) +} + +func TestTimezoneFromFS_NoTimezoneInfo(t *testing.T) { + // Empty filesystem - should fail + fs := afero.NewMemMapFs() + + _, err := timezoneFromFS(fs) + require.Error(t, err) +} + +func TestTimezoneFromFS_SkipsPosixAndRight(t *testing.T) { + fs := afero.NewMemMapFs() + + tzifData := []byte("TZif" + strings.Repeat("\x00", 40) + "unique tz data") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", tzifData, 0o644)) + + // Put matching file only under posix/ - should be skipped + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/posix/America/Chicago", tzifData, 0o644)) + + // Put real match under proper path + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Chicago", tzifData, 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "America/Chicago", tz) +} + +func TestTimezoneFromFS_InvalidLocaltime(t *testing.T) { + fs := afero.NewMemMapFs() + + // /etc/localtime exists but isn't a valid TZif file + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", []byte("not a tzif file"), 0o644)) + + _, err := timezoneFromFS(fs) + require.Error(t, err) +} + +// buildTZifV2 creates a minimal valid TZif v2 file with the given POSIX TZ +// footer string. The data section is minimal (no transitions) but structurally +// valid, so it can be used for both footer parsing and binary matching tests. +func buildTZifV2(posixTZ string) []byte { + // V1 header (44 bytes) + V1 data section + v1Header := make([]byte, 44) + copy(v1Header[0:4], "TZif") + v1Header[4] = '2' // version + // All counts are zero → minimal v1 data section is just the header + // plus the ttinfo (6 bytes) and the designation (\x00) + // But the simplest valid v1 section: 0 transitions, 1 ttinfo, 1 char + v1Header[32] = 0 // tzh_timecnt = 0 (big-endian uint32, high bytes) + v1Header[35] = 0 + v1Header[36] = 0 // tzh_typecnt = 0 + v1Header[39] = 1 // need at least 1 for valid file + v1Header[40] = 0 // tzh_charcnt = 0 + v1Header[43] = 4 // 4 chars ("UTC\0") + + // V1 data: 1 ttinfo (6 bytes: 4 offset + 1 dst + 1 idx) + 4 chars + v1Data := []byte{0, 0, 0, 0, 0, 0, 'U', 'T', 'C', 0} + + // V2 header (same as v1 header) + v2Header := make([]byte, 44) + copy(v2Header, v1Header) + + // V2 data (same minimal structure) + v2Data := make([]byte, len(v1Data)) + copy(v2Data, v1Data) + + // Footer: \n\n + footer := []byte("\n" + posixTZ + "\n") + + result := make([]byte, 0, len(v1Header)+len(v1Data)+len(v2Header)+len(v2Data)+len(footer)) + result = append(result, v1Header...) + result = append(result, v1Data...) + result = append(result, v2Header...) + result = append(result, v2Data...) + result = append(result, footer...) + return result +} + +func TestTzFromTZifFooter(t *testing.T) { + tests := []struct { + name string + posixTZ string + want string + wantErr bool + }{ + {name: "UTC0", posixTZ: "UTC0", want: "UTC"}, + {name: "empty string means UTC", posixTZ: "", want: "UTC"}, + {name: "UTC", posixTZ: "UTC", want: "UTC"}, + {name: "EST5EDT mapped", posixTZ: "EST5EDT,M3.2.0,M11.1.0", want: "America/New_York"}, + {name: "CST6CDT mapped", posixTZ: "CST6CDT,M3.2.0,M11.1.0", want: "America/Chicago"}, + {name: "PST8PDT mapped", posixTZ: "PST8PDT,M3.2.0,M11.1.0", want: "America/Los_Angeles"}, + {name: "JST-9 mapped", posixTZ: "JST-9", want: "Asia/Tokyo"}, + {name: "unmapped string", posixTZ: "WEIRD3", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := buildTZifV2(tt.posixTZ) + got, err := tzFromTZifFooter(data) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTzFromTZifFooter_V1Only(t *testing.T) { + // A v1 TZif file has no footer → should error + v1 := make([]byte, 44+10) // header + minimal data + copy(v1[0:4], "TZif") + v1[4] = 0 // version 1 (no version byte) + v1[39] = 1 + v1[43] = 4 + copy(v1[44:], []byte{0, 0, 0, 0, 0, 0, 'U', 'T', 'C', 0}) + + _, err := tzFromTZifFooter(v1) + require.Error(t, err) +} + +func TestMatchLocaltimeByCommonPaths(t *testing.T) { + fs := afero.NewMemMapFs() + + // Create a TZif file for /etc/localtime + localtime := buildTZifV2("CST6CDT,M3.2.0,M11.1.0") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", localtime, 0o644)) + + // Put a matching file at America/Chicago (which is in commonTimezones) + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Chicago", localtime, 0o644)) + + // Put a non-matching file at a different path + other := buildTZifV2("PST8PDT,M3.2.0,M11.1.0") + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/New_York", other, 0o644)) + + tz, err := matchLocaltimeByCommonPaths(fs, localtime) + require.NoError(t, err) + assert.Equal(t, "America/Chicago", tz) +} + +func TestMatchLocaltimeByCommonPaths_NoMatch(t *testing.T) { + fs := afero.NewMemMapFs() + localtime := buildTZifV2("WEIRD3") + + // No matching file in common paths + _, err := matchLocaltimeByCommonPaths(fs, localtime) + require.Error(t, err) +} + +func TestTimezoneFromFS_UTCDockerImage(t *testing.T) { + // Simulates a Docker image with UTC timezone: /etc/localtime is a + // regular file (not symlink), no /etc/timezone. The TZif footer + // should be parsed directly without walking the zoneinfo tree. + fs := afero.NewMemMapFs() + + utcData := buildTZifV2("UTC0") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", utcData, 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "UTC", tz) +} + +func TestTimezoneFromFS_NonUTCDockerImage(t *testing.T) { + // Docker image with a non-UTC timezone, /etc/localtime is a copied + // TZif file with a recognized POSIX TZ footer. + fs := afero.NewMemMapFs() + + tokyoData := buildTZifV2("JST-9") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", tokyoData, 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "Asia/Tokyo", tz) +} diff --git a/providers/os/resources/date/windows.go b/providers/os/resources/date/windows.go new file mode 100644 index 0000000000..657ecf2cc5 --- /dev/null +++ b/providers/os/resources/date/windows.go @@ -0,0 +1,76 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "encoding/json" + "fmt" + "io" + "time" + + "go.mondoo.com/mql/v13/providers/os/connection/shared" + "go.mondoo.com/mql/v13/providers/os/resources/powershell" +) + +type windowsDateResult struct { + DateTime string `json:"DateTime"` + Timezone string `json:"Timezone"` +} + +// PowerShell command that returns both the current UTC time and the system timezone ID. +const windowsDateCmd = `@{DateTime=(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ');Timezone=(Get-TimeZone).Id} | ConvertTo-Json` + +type Windows struct { + conn shared.Connection +} + +func (w *Windows) Name() string { + return "Windows Date" +} + +func (w *Windows) Get() (*Result, error) { + if !w.conn.Capabilities().Has(shared.Capability_RunCommand) { + return &Result{Timezone: "UTC"}, nil + } + + cmd, err := w.conn.RunCommand(powershell.Wrap(windowsDateCmd)) + if err != nil { + return nil, fmt.Errorf("failed to get system date: %w", err) + } + + return w.parse(cmd.Stdout) +} + +func (w *Windows) parse(r io.Reader) (*Result, error) { + content, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("failed to read date output: %w", err) + } + + var res windowsDateResult + if err := json.Unmarshal(content, &res); err != nil { + return nil, fmt.Errorf("failed to parse date output: %w", err) + } + + t, err := time.Parse(time.RFC3339, res.DateTime) + if err != nil { + return nil, fmt.Errorf("failed to parse datetime %q: %w", res.DateTime, err) + } + + // Windows returns IANA-compatible timezone IDs on modern systems + loc, err := time.LoadLocation(res.Timezone) + if err != nil { + // Fall back to returning UTC time with the Windows timezone ID + return &Result{ + Time: &t, + Timezone: res.Timezone, + }, nil + } + + locT := t.In(loc) + return &Result{ + Time: &locT, + Timezone: res.Timezone, + }, nil +} diff --git a/providers/os/resources/date/windows_test.go b/providers/os/resources/date/windows_test.go new file mode 100644 index 0000000000..74dc8cb42d --- /dev/null +++ b/providers/os/resources/date/windows_test.go @@ -0,0 +1,60 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseWindowsDate(t *testing.T) { + w := &Windows{} + + tests := []struct { + name string + input string + wantTZ string + wantYear int + wantErr bool + }{ + { + name: "valid output", + input: `{"DateTime":"2026-03-17T14:30:00Z","Timezone":"America/New_York"}`, + wantTZ: "America/New_York", + wantYear: 2026, + }, + { + name: "UTC timezone", + input: `{"DateTime":"2026-03-17T14:30:00Z","Timezone":"UTC"}`, + wantTZ: "UTC", + wantYear: 2026, + }, + { + name: "invalid json", + input: `not json`, + wantErr: true, + }, + { + name: "invalid datetime", + input: `{"DateTime":"not-a-date","Timezone":"UTC"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := w.parse(strings.NewReader(tt.input)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantTZ, got.Timezone) + assert.Equal(t, tt.wantYear, got.Time.Year()) + }) + } +} diff --git a/providers/os/resources/os.lr b/providers/os/resources/os.lr index 2e73147ab5..b408937199 100644 --- a/providers/os/resources/os.lr +++ b/providers/os/resources/os.lr @@ -257,6 +257,16 @@ os { hypervisor() string // Machine ID for this OS machineid() string + // Current date and timezone of the system + date() os.date +} + +// Operating system date and timezone information +os.date @defaults("time timezone") { + // Current system time + time() time + // System timezone (e.g., "America/New_York", "UTC") + timezone() string } // Operating system update information diff --git a/providers/os/resources/os.lr.go b/providers/os/resources/os.lr.go index 6921523885..111b70165a 100644 --- a/providers/os/resources/os.lr.go +++ b/providers/os/resources/os.lr.go @@ -36,6 +36,7 @@ const ( ResourceMachineChassis string = "machine.chassis" ResourceMachineCpu string = "machine.cpu" ResourceOs string = "os" + ResourceOsDate string = "os.date" ResourceOsUpdate string = "os.update" ResourceOsBase string = "os.base" ResourceOsUnix string = "os.unix" @@ -290,6 +291,10 @@ func init() { // to override args, implement: initOs(runtime *plugin.Runtime, args map[string]*llx.RawData) (map[string]*llx.RawData, plugin.Resource, error) Create: createOs, }, + "os.date": { + // to override args, implement: initOsDate(runtime *plugin.Runtime, args map[string]*llx.RawData) (map[string]*llx.RawData, plugin.Resource, error) + Create: createOsDate, + }, "os.update": { // to override args, implement: initOsUpdate(runtime *plugin.Runtime, args map[string]*llx.RawData) (map[string]*llx.RawData, plugin.Resource, error) Create: createOsUpdate, @@ -1316,6 +1321,15 @@ var getDataFields = map[string]func(r plugin.Resource) *plugin.DataRes{ "os.machineid": func(r plugin.Resource) *plugin.DataRes { return (r.(*mqlOs).GetMachineid()).ToDataRes(types.String) }, + "os.date": func(r plugin.Resource) *plugin.DataRes { + return (r.(*mqlOs).GetDate()).ToDataRes(types.Resource("os.date")) + }, + "os.date.time": func(r plugin.Resource) *plugin.DataRes { + return (r.(*mqlOsDate).GetTime()).ToDataRes(types.Time) + }, + "os.date.timezone": func(r plugin.Resource) *plugin.DataRes { + return (r.(*mqlOsDate).GetTimezone()).ToDataRes(types.String) + }, "os.update.name": func(r plugin.Resource) *plugin.DataRes { return (r.(*mqlOsUpdate).GetName()).ToDataRes(types.String) }, @@ -4466,6 +4480,22 @@ var setDataFields = map[string]func(r plugin.Resource, v *llx.RawData) bool{ r.(*mqlOs).Machineid, ok = plugin.RawToTValue[string](v.Value, v.Error) return }, + "os.date": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOs).Date, ok = plugin.RawToTValue[*mqlOsDate](v.Value, v.Error) + return + }, + "os.date.__id": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOsDate).__id, ok = v.Value.(string) + return + }, + "os.date.time": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOsDate).Time, ok = plugin.RawToTValue[*time.Time](v.Value, v.Error) + return + }, + "os.date.timezone": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOsDate).Timezone, ok = plugin.RawToTValue[string](v.Value, v.Error) + return + }, "os.update.__id": func(r plugin.Resource, v *llx.RawData) (ok bool) { r.(*mqlOsUpdate).__id, ok = v.Value.(string) return @@ -10053,6 +10083,7 @@ type mqlOs struct { Hostname plugin.TValue[string] Hypervisor plugin.TValue[string] Machineid plugin.TValue[string] + Date plugin.TValue[*mqlOsDate] } // createOs creates a new instance of this resource @@ -10156,6 +10187,80 @@ func (c *mqlOs) GetMachineid() *plugin.TValue[string] { }) } +func (c *mqlOs) GetDate() *plugin.TValue[*mqlOsDate] { + return plugin.GetOrCompute[*mqlOsDate](&c.Date, func() (*mqlOsDate, error) { + if c.MqlRuntime.HasRecording { + d, err := c.MqlRuntime.FieldResourceFromRecording("os", c.__id, "date") + if err != nil { + return nil, err + } + if d != nil { + return d.Value.(*mqlOsDate), nil + } + } + + return c.date() + }) +} + +// mqlOsDate for the os.date resource +type mqlOsDate struct { + MqlRuntime *plugin.Runtime + __id string + mqlOsDateInternal + Time plugin.TValue[*time.Time] + Timezone plugin.TValue[string] +} + +// createOsDate creates a new instance of this resource +func createOsDate(runtime *plugin.Runtime, args map[string]*llx.RawData) (plugin.Resource, error) { + res := &mqlOsDate{ + MqlRuntime: runtime, + } + + err := SetAllData(res, args) + if err != nil { + return res, err + } + + if res.__id == "" { + res.__id, err = res.id() + if err != nil { + return nil, err + } + } + + if runtime.HasRecording { + args, err = runtime.ResourceFromRecording("os.date", res.__id) + if err != nil || args == nil { + return res, err + } + return res, SetAllData(res, args) + } + + return res, nil +} + +func (c *mqlOsDate) MqlName() string { + return "os.date" +} + +func (c *mqlOsDate) MqlID() string { + return c.__id +} + +func (c *mqlOsDate) GetTime() *plugin.TValue[*time.Time] { + return plugin.GetOrCompute[*time.Time](&c.Time, func() (*time.Time, error) { + return c.time() + }) +} + +func (c *mqlOsDate) GetTimezone() *plugin.TValue[string] { + return plugin.GetOrCompute[string](&c.Timezone, func() (string, error) { + return c.timezone() + }) +} + // mqlOsUpdate for the os.update resource type mqlOsUpdate struct { MqlRuntime *plugin.Runtime diff --git a/providers/os/resources/os.lr.versions b/providers/os/resources/os.lr.versions index 0b60bf7ca3..e53e51c2c2 100644 --- a/providers/os/resources/os.lr.versions +++ b/providers/os/resources/os.lr.versions @@ -701,6 +701,9 @@ os.base.rebootpending 9.0.0 os.base.updates 9.0.0 os.base.uptime 9.0.0 os.base.users 9.0.0 +os.date 13.2.6 +os.date.time 13.2.6 +os.date.timezone 13.2.6 os.env 9.0.0 os.hostname 9.0.0 os.hypervisor 11.3.35 diff --git a/providers/os/resources/os_date.go b/providers/os/resources/os_date.go new file mode 100644 index 0000000000..21f06340dc --- /dev/null +++ b/providers/os/resources/os_date.go @@ -0,0 +1,78 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package resources + +import ( + "sync" + "time" + + "go.mondoo.com/mql/v13/llx" + "go.mondoo.com/mql/v13/providers-sdk/v1/plugin" + "go.mondoo.com/mql/v13/providers/os/connection/shared" + "go.mondoo.com/mql/v13/providers/os/resources/date" +) + +type mqlOsDateInternal struct { + lock sync.Mutex + fetched bool + result *date.Result +} + +func (p *mqlOs) date() (*mqlOsDate, error) { + o, err := CreateResource(p.MqlRuntime, "os.date", map[string]*llx.RawData{}) + if err != nil { + return nil, err + } + return o.(*mqlOsDate), nil +} + +func (d *mqlOsDate) id() (string, error) { + return "os.date", nil +} + +func (d *mqlOsDate) fetch() (*date.Result, error) { + if d.fetched { + return d.result, nil + } + d.lock.Lock() + defer d.lock.Unlock() + if d.fetched { + return d.result, nil + } + + conn := d.MqlRuntime.Connection.(shared.Connection) + dt, err := date.New(conn) + if err != nil { + return nil, err + } + + res, err := dt.Get() + if err != nil { + return nil, err + } + + d.fetched = true + d.result = res + return res, nil +} + +func (d *mqlOsDate) time() (*time.Time, error) { + res, err := d.fetch() + if err != nil { + return nil, err + } + if res.Time == nil { + d.Time.State = plugin.StateIsNull | plugin.StateIsSet + return nil, nil + } + return res.Time, nil +} + +func (d *mqlOsDate) timezone() (string, error) { + res, err := d.fetch() + if err != nil { + return "", err + } + return res.Timezone, nil +}