The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
462 lines
11 KiB
Go
462 lines
11 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package checks
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
)
|
|
|
|
func init() {
|
|
Register(KeyDiskEncryption, windowsDiskEncryption)
|
|
Register(KeyScreenLock, windowsScreenLock)
|
|
Register(KeyFirewallEnabled, windowsFirewall)
|
|
Register(KeyTimeSync, windowsTimeSync)
|
|
Register(KeyOSVersion, windowsOSVersion)
|
|
Register(KeyAutoUpdate, windowsAutoUpdate)
|
|
Register(KeyPasswordPolicy, windowsPasswordPolicy)
|
|
Register(KeyRemoteLogin, windowsRemoteLogin)
|
|
Register(KeyMalwareProtection, windowsMalwareProtection)
|
|
}
|
|
|
|
const psNoProfile = "-NoProfile"
|
|
|
|
func powershell(ctx context.Context, script string) CmdResult {
|
|
return RunCommand(ctx, "powershell.exe", psNoProfile, "-Command", script)
|
|
}
|
|
|
|
func windowsDiskEncryption(ctx context.Context) Result {
|
|
if !CommandExists("manage-bde.exe") && !CommandExists("manage-bde") {
|
|
return unknown(map[string]any{"note": "manage-bde not found"})
|
|
}
|
|
|
|
out := RunCommand(ctx, "manage-bde", "-status")
|
|
|
|
ev := map[string]any{"raw": truncate(out.Stdout, 600)}
|
|
if out.Err != nil {
|
|
return unknown(ev)
|
|
}
|
|
|
|
lower := strings.ToLower(out.Stdout)
|
|
if strings.Contains(lower, "percentage encrypted: 100") ||
|
|
strings.Contains(lower, "fully encrypted") ||
|
|
strings.Contains(lower, "protection on") {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
func windowsScreenLock(ctx context.Context) Result {
|
|
// HKCU resolves to the SYSTEM hive when the agent runs as LocalSystem,
|
|
// so we first look for a machine-wide policy and then enumerate every
|
|
// loaded interactive user hive under HKU.
|
|
machine := powershell(
|
|
ctx,
|
|
`(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Control Panel\Desktop' `+
|
|
`-ErrorAction SilentlyContinue).ScreenSaverIsSecure`,
|
|
)
|
|
if machine.Err == nil {
|
|
v := strings.TrimSpace(machine.Stdout)
|
|
if v != "" {
|
|
ev := map[string]any{
|
|
"backend": "machine_policy",
|
|
"screen_saver_is_secure": v,
|
|
}
|
|
if v == "1" {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
}
|
|
|
|
users := powershell(
|
|
ctx,
|
|
`Get-ChildItem 'Registry::HKEY_USERS' | `+
|
|
`Where-Object { $_.PSChildName -match '^S-1-5-21-' } | `+
|
|
`ForEach-Object { `+
|
|
` $path = "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop"; `+
|
|
` $key = Get-ItemProperty $path -ErrorAction SilentlyContinue; `+
|
|
` "$($_.PSChildName)=$($key.ScreenSaverIsSecure)" `+
|
|
`}`,
|
|
)
|
|
if users.Err != nil {
|
|
return unknown(
|
|
map[string]any{
|
|
"backend": "hkey_users",
|
|
"error": users.Err.Error(),
|
|
"stderr": users.Stderr,
|
|
"machine_policy_error": errString(machine.Err),
|
|
},
|
|
)
|
|
}
|
|
|
|
ev := map[string]any{
|
|
"backend": "hkey_users",
|
|
"raw": truncate(users.Stdout, 400),
|
|
}
|
|
users_, anyDisabled, anyEnabled := parseWindowsUserScreenLock(users.Stdout)
|
|
|
|
ev["users"] = users_
|
|
if len(users_) == 0 {
|
|
ev["note"] = "no interactive user hives loaded"
|
|
return unknown(ev)
|
|
}
|
|
|
|
if anyEnabled && !anyDisabled {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
// parseWindowsUserScreenLock parses one "SID=<value>" line per user from
|
|
// the registry enumeration and reports whether each user has screen
|
|
// saver locking enabled.
|
|
func parseWindowsUserScreenLock(s string) (map[string]string, bool, bool) {
|
|
users := map[string]string{}
|
|
|
|
var anyEnabled, anyDisabled bool
|
|
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
idx := strings.LastIndex(line, "=")
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
|
|
sid := strings.TrimSpace(line[:idx])
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
|
|
if sid == "" {
|
|
continue
|
|
}
|
|
|
|
users[sid] = value
|
|
switch value {
|
|
case "1":
|
|
anyEnabled = true
|
|
default:
|
|
anyDisabled = true
|
|
}
|
|
}
|
|
|
|
return users, anyDisabled, anyEnabled
|
|
}
|
|
|
|
func windowsFirewall(ctx context.Context) Result {
|
|
primary := powershell(
|
|
ctx,
|
|
`(Get-NetFirewallProfile -PolicyStore ActiveStore | `+
|
|
`Sort-Object Name | `+
|
|
`ForEach-Object { "$($_.Name)=$($_.Enabled)" }) -join ";"`,
|
|
)
|
|
if primary.Err == nil && strings.TrimSpace(primary.Stdout) != "" {
|
|
ev := map[string]any{
|
|
"backend": "Get-NetFirewallProfile",
|
|
"raw": primary.Stdout,
|
|
}
|
|
profiles, allEnabled := parseWindowsFirewallProfiles(primary.Stdout)
|
|
|
|
ev["profiles"] = profiles
|
|
if allEnabled {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
fallback := RunCommand(ctx, "netsh", "advfirewall", "show", "allprofiles", "state")
|
|
if fallback.Err != nil {
|
|
return unknown(
|
|
map[string]any{
|
|
"error": errString(fallback.Err),
|
|
"stderr": fallback.Stderr,
|
|
"powershell_error": errString(primary.Err),
|
|
},
|
|
)
|
|
}
|
|
|
|
ev := map[string]any{
|
|
"backend": "netsh",
|
|
"raw": truncate(fallback.Stdout, 600),
|
|
}
|
|
stateLines, anyOff := parseNetshFirewallStates(fallback.Stdout)
|
|
|
|
ev["state_lines"] = stateLines
|
|
if len(stateLines) > 0 && !anyOff {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
// parseWindowsFirewallProfiles parses "Domain=True;Private=True;Public=True"
|
|
// from Get-NetFirewallProfile output, returning per-profile state and
|
|
// whether every profile is enabled.
|
|
func parseWindowsFirewallProfiles(s string) (map[string]string, bool) {
|
|
profiles := map[string]string{}
|
|
allEnabled := true
|
|
any := false
|
|
|
|
for profile := range strings.SplitSeq(s, ";") {
|
|
parts := strings.SplitN(strings.TrimSpace(profile), "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
|
|
name := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
profiles[name] = value
|
|
any = true
|
|
|
|
if !strings.EqualFold(value, "true") {
|
|
allEnabled = false
|
|
}
|
|
}
|
|
|
|
return profiles, any && allEnabled
|
|
}
|
|
|
|
// parseNetshFirewallStates extracts per-profile "State <ON|OFF>" lines
|
|
// from `netsh advfirewall show allprofiles state`. It is whitespace- and
|
|
// case-insensitive.
|
|
func parseNetshFirewallStates(s string) ([]string, bool) {
|
|
var states []string
|
|
|
|
anyOff := false
|
|
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
lower := strings.ToLower(trimmed)
|
|
if !strings.HasPrefix(lower, "state") {
|
|
continue
|
|
}
|
|
|
|
fields := strings.Fields(lower)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
|
|
value := fields[len(fields)-1]
|
|
|
|
states = append(states, value)
|
|
if value != "on" {
|
|
anyOff = true
|
|
}
|
|
}
|
|
|
|
return states, anyOff
|
|
}
|
|
|
|
func windowsTimeSync(ctx context.Context) Result {
|
|
out := RunCommand(ctx, "w32tm", "/query", "/status")
|
|
if out.Err != nil {
|
|
return unknown(
|
|
map[string]any{
|
|
"error": out.Err.Error(),
|
|
"stderr": out.Stderr,
|
|
},
|
|
)
|
|
}
|
|
|
|
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
|
|
|
lower := strings.ToLower(out.Stdout)
|
|
if strings.Contains(lower, "source:") && !strings.Contains(lower, "local cmos clock") {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
func windowsOSVersion(ctx context.Context) Result {
|
|
out := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Version`)
|
|
if out.Err != nil {
|
|
return unknown(map[string]any{"error": out.Err.Error()})
|
|
}
|
|
|
|
caption := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Caption`)
|
|
|
|
return pass(
|
|
map[string]any{
|
|
"version": out.Stdout,
|
|
"caption": caption.Stdout,
|
|
},
|
|
)
|
|
}
|
|
|
|
func windowsAutoUpdate(ctx context.Context) Result {
|
|
out := powershell(
|
|
ctx,
|
|
`$au = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `+
|
|
`-ErrorAction SilentlyContinue; `+
|
|
`"$($au.NoAutoUpdate);$($au.AUOptions)"`,
|
|
)
|
|
|
|
ev := map[string]any{}
|
|
if out.Err != nil {
|
|
ev["error"] = out.Err.Error()
|
|
ev["stderr"] = out.Stderr
|
|
|
|
return unknown(ev)
|
|
}
|
|
|
|
parts := strings.SplitN(strings.TrimSpace(out.Stdout), ";", 2)
|
|
|
|
var noAutoUpdate, auOptions string
|
|
if len(parts) >= 1 {
|
|
noAutoUpdate = strings.TrimSpace(parts[0])
|
|
}
|
|
|
|
if len(parts) >= 2 {
|
|
auOptions = strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
ev["no_auto_update"] = noAutoUpdate
|
|
ev["au_options"] = auOptions
|
|
|
|
// NoAutoUpdate=1 explicitly disables automatic updates via policy.
|
|
if noAutoUpdate == "1" {
|
|
return fail(ev)
|
|
}
|
|
|
|
// AUOptions semantics:
|
|
// 2 — notify before download (no auto-install)
|
|
// 3 — auto download, prompt to install
|
|
// 4 — auto download + auto install (target SOC posture)
|
|
// 5 — managed by local administrators
|
|
switch auOptions {
|
|
case "3", "4", "5":
|
|
return pass(ev)
|
|
case "2":
|
|
return fail(ev)
|
|
}
|
|
|
|
// No managed policy. The Windows Update service must at least be
|
|
// running for the OS default of auto-install to take effect.
|
|
svc := RunCommand(ctx, "sc.exe", "query", "wuauserv")
|
|
if svc.Err != nil {
|
|
ev["wuauserv_error"] = svc.Err.Error()
|
|
return unknown(ev)
|
|
}
|
|
|
|
if strings.Contains(svc.Stdout, "RUNNING") {
|
|
ev["wuauserv"] = "running"
|
|
return pass(ev)
|
|
}
|
|
|
|
ev["wuauserv"] = "stopped"
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
func windowsPasswordPolicy(ctx context.Context) Result {
|
|
out := RunCommand(ctx, "net", "accounts")
|
|
if out.Err != nil {
|
|
return unknown(
|
|
map[string]any{
|
|
"error": out.Err.Error(),
|
|
"stderr": out.Stderr,
|
|
},
|
|
)
|
|
}
|
|
|
|
ev := map[string]any{"raw": truncate(out.Stdout, 400)}
|
|
|
|
lower := strings.ToLower(out.Stdout)
|
|
if strings.Contains(lower, "minimum password length") && !strings.Contains(lower, "length: 0") {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
func windowsMalwareProtection(ctx context.Context) Result {
|
|
out := powershell(
|
|
ctx,
|
|
`$s = Get-MpComputerStatus; `+
|
|
`"$($s.AntivirusEnabled);$($s.RealTimeProtectionEnabled);`+
|
|
`$($s.AMServiceEnabled);$($s.AntivirusSignatureLastUpdated)"`,
|
|
)
|
|
if out.Err != nil {
|
|
return unknown(
|
|
map[string]any{
|
|
"error": out.Err.Error(),
|
|
"stderr": out.Stderr,
|
|
},
|
|
)
|
|
}
|
|
|
|
parts := strings.Split(out.Stdout, ";")
|
|
|
|
ev := map[string]any{"raw": out.Stdout}
|
|
if len(parts) < 3 {
|
|
return unknown(ev)
|
|
}
|
|
|
|
antivirusOn := strings.EqualFold(strings.TrimSpace(parts[0]), "True")
|
|
realtimeOn := strings.EqualFold(strings.TrimSpace(parts[1]), "True")
|
|
serviceOn := strings.EqualFold(strings.TrimSpace(parts[2]), "True")
|
|
|
|
ev["antivirus_enabled"] = antivirusOn
|
|
ev["real_time_protection"] = realtimeOn
|
|
|
|
ev["am_service_enabled"] = serviceOn
|
|
if len(parts) >= 4 {
|
|
ev["signatures_last_updated"] = strings.TrimSpace(parts[3])
|
|
}
|
|
|
|
if antivirusOn && (realtimeOn || serviceOn) {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|
|
|
|
func windowsRemoteLogin(ctx context.Context) Result {
|
|
out := powershell(
|
|
ctx,
|
|
`(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server').fDenyTSConnections`,
|
|
)
|
|
if out.Err != nil {
|
|
return unknown(map[string]any{"error": out.Err.Error()})
|
|
}
|
|
|
|
ev := map[string]any{"fdeny_ts_connections": out.Stdout}
|
|
if strings.TrimSpace(out.Stdout) == "1" {
|
|
return pass(ev)
|
|
}
|
|
|
|
return fail(ev)
|
|
}
|