Files
Juan FernandezandKonstantin Sykulev 621ede7584 Software > OS: Show Android versions and vulnerabilities (#49399)
Resolves #35075 

- Added vulnerability (CVE) reporting for Android OS versions.
- Folded the Android security patch level into the host's OS version.

---------

Co-authored-by: Konstantin Sykulev <konst@sykulev.com>
2026-07-16 10:37:57 -04:00

366 lines
11 KiB
Go

package android
import (
"compress/gzip"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/stretchr/testify/require"
)
func TestParseAndroidVersion(t *testing.T) {
tests := []struct {
version string
wantMajor string
wantSPL string
}{
{"16 (2026-05-01)", "16", "2026-05-01"},
{"14 (2024-09-01)", "14", "2024-09-01"},
{"16", "16", ""},
{"8.1 (2021-01-01)", "8.1", "2021-01-01"},
{"12L (2022-12-01)", "12L", "2022-12-01"},
{"", "", ""},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
major, spl := parseAndroidVersion(tt.version)
require.Equal(t, tt.wantMajor, major)
require.Equal(t, tt.wantSPL, spl)
})
}
}
func TestResolvedVersion(t *testing.T) {
got := resolvedVersion("16", "2026-06-01")
require.NotNil(t, got)
require.Equal(t, "16 (2026-06-01)", *got)
}
func writeTestArtifact(t *testing.T, dir string, artifact *AndroidArtifact) string {
t.Helper()
filename := filepath.Join(dir, "osv-android-"+artifact.AndroidVersion+"-2026-07-14.json.gz")
f, err := os.Create(filename)
require.NoError(t, err)
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
require.NoError(t, json.NewEncoder(gz).Encode(artifact))
return filename
}
func TestAnalyze(t *testing.T) {
ctx := t.Context()
vulnDir := t.TempDir()
// Create artifact for Android 16 with two CVEs.
writeTestArtifact(t, vulnDir, &AndroidArtifact{
SchemaVersion: "1.0",
AndroidVersion: "16",
Generated: "2026-07-14T00:00:00Z",
TotalCVEs: 2,
Vulnerabilities: []AndroidVuln{
{CVE: "CVE-2026-1111", FixedSPL: "2026-05-01", Severity: "High"},
{CVE: "CVE-2026-2222", FixedSPL: "2026-06-01", Severity: "Critical"},
},
})
t.Run("host with old SPL is vulnerable to both", func(t *testing.T) {
ds := new(mock.Store)
ds.ListOSVulnerabilitiesByOSFunc = func(ctx context.Context, osID uint) ([]fleet.OSVulnerability, error) {
return nil, nil // no existing vulns
}
ds.DeleteOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability) error {
return nil
}
var inserted []fleet.OSVulnerability
ds.InsertOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability, source fleet.VulnerabilitySource) (int64, error) {
inserted = append(inserted, vulns...)
return int64(len(vulns)), nil
}
os := fleet.OperatingSystem{
ID: 1,
Name: "Android",
Version: "16 (2026-04-01)", // SPL before both fixes
Platform: "android",
}
result, err := Analyze(ctx, ds, os, vulnDir, true, nil, NewArtifactCache())
require.NoError(t, err)
require.Len(t, result, 2)
cves := map[string]string{}
for _, v := range result {
require.Equal(t, uint(1), v.OSID)
require.Equal(t, fleet.AndroidOSVSource, v.Source)
cves[v.CVE] = *v.ResolvedInVersion
}
require.Equal(t, "16 (2026-05-01)", cves["CVE-2026-1111"])
require.Equal(t, "16 (2026-06-01)", cves["CVE-2026-2222"])
})
t.Run("host with recent SPL is only vulnerable to later fix", func(t *testing.T) {
ds := new(mock.Store)
ds.ListOSVulnerabilitiesByOSFunc = func(ctx context.Context, osID uint) ([]fleet.OSVulnerability, error) {
return nil, nil
}
ds.DeleteOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability) error {
return nil
}
var inserted []fleet.OSVulnerability
ds.InsertOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability, source fleet.VulnerabilitySource) (int64, error) {
inserted = append(inserted, vulns...)
return int64(len(vulns)), nil
}
os := fleet.OperatingSystem{
ID: 2,
Name: "Android",
Version: "16 (2026-05-01)", // SPL after first fix, before second
Platform: "android",
}
result, err := Analyze(ctx, ds, os, vulnDir, true, nil, NewArtifactCache())
require.NoError(t, err)
require.Len(t, result, 1)
require.Equal(t, "CVE-2026-2222", result[0].CVE)
})
t.Run("fully patched host has no vulnerabilities", func(t *testing.T) {
ds := new(mock.Store)
ds.ListOSVulnerabilitiesByOSFunc = func(ctx context.Context, osID uint) ([]fleet.OSVulnerability, error) {
return nil, nil
}
ds.DeleteOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability) error {
return nil
}
ds.InsertOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability, source fleet.VulnerabilitySource) (int64, error) {
return 0, nil
}
os := fleet.OperatingSystem{
ID: 3,
Name: "Android",
Version: "16 (2026-06-01)", // SPL at the latest fix
Platform: "android",
}
result, err := Analyze(ctx, ds, os, vulnDir, true, nil, NewArtifactCache())
require.NoError(t, err)
require.Empty(t, result)
})
t.Run("bare version with no SPL skips matching", func(t *testing.T) {
ds := new(mock.Store)
ds.ListOSVulnerabilitiesByOSFunc = func(ctx context.Context, osID uint) ([]fleet.OSVulnerability, error) {
return nil, nil
}
ds.DeleteOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability) error {
return nil
}
ds.InsertOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability, source fleet.VulnerabilitySource) (int64, error) {
return 0, nil
}
os := fleet.OperatingSystem{
ID: 4,
Name: "Android",
Version: "16", // No SPL
Platform: "android",
}
result, err := Analyze(ctx, ds, os, vulnDir, true, nil, NewArtifactCache())
require.NoError(t, err)
require.Empty(t, result)
})
t.Run("delta removes stale vulns when host is patched", func(t *testing.T) {
ds := new(mock.Store)
// Simulate existing vuln from a previous scan
ds.ListOSVulnerabilitiesByOSFunc = func(ctx context.Context, osID uint) ([]fleet.OSVulnerability, error) {
return []fleet.OSVulnerability{
{
OSID: 5,
CVE: "CVE-2026-1111",
Source: fleet.AndroidOSVSource,
ResolvedInVersion: resolvedVersion("16", "2026-05-01"),
},
{
OSID: 5,
CVE: "CVE-2026-2222",
Source: fleet.AndroidOSVSource,
ResolvedInVersion: resolvedVersion("16", "2026-06-01"),
},
}, nil
}
var deleted []fleet.OSVulnerability
ds.DeleteOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability) error {
deleted = append(deleted, vulns...)
return nil
}
ds.InsertOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability, source fleet.VulnerabilitySource) (int64, error) {
return 0, nil
}
// Host is now fully patched
os := fleet.OperatingSystem{
ID: 5,
Name: "Android",
Version: "16 (2026-06-01)",
Platform: "android",
}
_, err := Analyze(ctx, ds, os, vulnDir, false, nil, NewArtifactCache())
require.NoError(t, err)
// Both previously existing vulns should be deleted
require.Len(t, deleted, 2)
})
t.Run("bare version (no SPL) leaves existing vulns untouched", func(t *testing.T) {
ds := new(mock.Store)
// Existing findings from a previous scan of a patch-level version.
ds.ListOSVulnerabilitiesByOSFunc = func(ctx context.Context, osID uint) ([]fleet.OSVulnerability, error) {
return []fleet.OSVulnerability{
{
OSID: 8,
CVE: "CVE-2026-1111",
Source: fleet.AndroidOSVSource,
ResolvedInVersion: resolvedVersion("16", "2026-05-01"),
},
}, nil
}
var deleted []fleet.OSVulnerability
ds.DeleteOSVulnerabilitiesFunc = func(ctx context.Context, vulns []fleet.OSVulnerability) error {
deleted = append(deleted, vulns...)
return nil
}
// Host reports a bare version with no security patch level, so we
// can't determine vulnerability status.
os := fleet.OperatingSystem{
ID: 8,
Name: "Android",
Version: "16",
Platform: "android",
}
result, err := Analyze(ctx, ds, os, vulnDir, true, nil, NewArtifactCache())
require.NoError(t, err)
require.Nil(t, result)
require.Empty(t, deleted, "existing findings must not be deleted for a bare version")
require.False(t, ds.DeleteOSVulnerabilitiesFuncInvoked)
})
t.Run("no artifact for version returns nil", func(t *testing.T) {
ds := new(mock.Store)
os := fleet.OperatingSystem{
ID: 6,
Name: "Android",
Version: "99 (2099-01-01)", // no artifact for version 99
Platform: "android",
}
result, err := Analyze(ctx, ds, os, vulnDir, true, nil, NewArtifactCache())
require.NoError(t, err)
require.Nil(t, result)
})
t.Run("corrupt artifact propagates the error", func(t *testing.T) {
corruptDir := t.TempDir()
// Write a file that is not valid gzip
require.NoError(t, os.WriteFile(
filepath.Join(corruptDir, "osv-android-16-2026-07-14.json.gz"),
[]byte("not gzip data"), 0o644))
ds := new(mock.Store)
os := fleet.OperatingSystem{
ID: 7,
Name: "Android",
Version: "16 (2026-01-01)",
Platform: "android",
}
result, err := Analyze(ctx, ds, os, corruptDir, true, nil, NewArtifactCache())
require.Error(t, err) // a corrupt artifact is a real failure, not a missing one
require.NotErrorIs(t, err, errArtifactNotFound)
require.Nil(t, result)
})
t.Run("empty artifact (zero vulns) is a no-op", func(t *testing.T) {
emptyDir := t.TempDir()
writeTestArtifact(t, emptyDir, &AndroidArtifact{
SchemaVersion: "1.0",
AndroidVersion: "16",
Generated: "2026-07-14T00:00:00Z",
TotalCVEs: 0,
Vulnerabilities: nil,
})
ds := new(mock.Store)
os := fleet.OperatingSystem{
ID: 8,
Name: "Android",
Version: "16 (2026-01-01)",
Platform: "android",
}
result, err := Analyze(ctx, ds, os, emptyDir, true, nil, NewArtifactCache())
require.NoError(t, err)
require.Nil(t, result)
// No datastore methods should have been called
require.False(t, ds.ListOSVulnerabilitiesByOSFuncInvoked)
})
}
func TestLoadArtifactPicksLatest(t *testing.T) {
dir := t.TempDir()
// Write two artifacts for the same version with different dates
writeTestArtifact(t, dir, &AndroidArtifact{
SchemaVersion: "1.0",
AndroidVersion: "16",
Generated: "2026-07-13T00:00:00Z",
TotalCVEs: 1,
Vulnerabilities: []AndroidVuln{
{CVE: "CVE-2026-0001", FixedSPL: "2026-01-01"},
},
})
// Overwrite with a different date filename
older := filepath.Join(dir, "osv-android-16-2026-07-13.json.gz")
newer := filepath.Join(dir, "osv-android-16-2026-07-14.json.gz")
// Rename the one writeTestArtifact created to the older date
require.NoError(t, os.Rename(
filepath.Join(dir, "osv-android-16-2026-07-14.json.gz"),
older))
// Write the newer one with 2 CVEs so we can distinguish
f, err := os.Create(newer)
require.NoError(t, err)
gz := gzip.NewWriter(f)
require.NoError(t, json.NewEncoder(gz).Encode(&AndroidArtifact{
SchemaVersion: "1.0",
AndroidVersion: "16",
Generated: "2026-07-14T00:00:00Z",
TotalCVEs: 2,
Vulnerabilities: []AndroidVuln{
{CVE: "CVE-2026-0001", FixedSPL: "2026-01-01"},
{CVE: "CVE-2026-0002", FixedSPL: "2026-02-01"},
},
}))
require.NoError(t, gz.Close())
require.NoError(t, f.Close())
artifact, err := loadArtifact("16", dir)
require.NoError(t, err)
require.Equal(t, 2, artifact.TotalCVEs, "should pick the latest (2026-07-14) artifact")
}