diff --git a/.github/workflows/PR-tests.yml b/.github/workflows/PR-tests.yml index f2a74ae..d90dc82 100644 --- a/.github/workflows/PR-tests.yml +++ b/.github/workflows/PR-tests.yml @@ -125,10 +125,18 @@ jobs: f.write("1") vulns = [] for i in range(1, 56): + severity = "low" + if i % 10 == 0: + severity = "critical" + elif i % 10 == 1: + severity = "high" + elif i % 10 == 2: + severity = "medium" vulns.append({ "name": f"pkg{i}", "version": f"1.{i}.0", "manager": "windows-registry", + "severity": severity, "vulns": [f"CVE-2026-{10000 + i}"], }) response = { @@ -166,6 +174,15 @@ jobs: if ($vulnLines -ne 50) { Write-Error "Expected 50 vulnerable package lines, got $vulnLines.`n$output" } + $firstVulnLine = [regex]::Match($output, "(?m)^\s+- pkg\d+ .*$").Value + if ($firstVulnLine -notmatch "pkg10 .* \[Critical\]: CVE-2026-10010") { + Write-Error "Expected critical package vulnerabilities to be printed first, got: $firstVulnLine`n$output" + } + $criticalIndex = $output.IndexOf("pkg10") + $lowIndex = $output.IndexOf("- pkg3 ") + if ($criticalIndex -lt 0 -or $lowIndex -lt 0 -or $criticalIndex -gt $lowIndex) { + Write-Error "Expected critical packages to appear before low packages.`n$output" + } if ($output -notmatch "\.\.\. 5 more vulnerable package\(s\) not shown\.") { Write-Error "The hidden vulnerable package count was not printed.`n$output" } diff --git a/winPEAS/winPEASexe/Tests/ArgumentParsingTests.cs b/winPEAS/winPEASexe/Tests/ArgumentParsingTests.cs index 47602a9..5672235 100644 --- a/winPEAS/winPEASexe/Tests/ArgumentParsingTests.cs +++ b/winPEAS/winPEASexe/Tests/ArgumentParsingTests.cs @@ -273,5 +273,43 @@ namespace winPEAS.Tests Assert.AreEqual(5, summary.NotShown); Assert.IsTrue(summary.Lines[0].StartsWith("- pkg1 1.1.0 [windows-registry]: CVE-2026-10001")); } + + [TestMethod] + public void PackageVulnerabilityFormatter_SortsByCriticalityBeforeCapping() + { + var responseJson = + "{\"package_vulnerabilities\":{\"checked\":4,\"affected\":4,\"vulnerable_packages\":[" + + "{\"name\":\"lowpkg\",\"version\":\"1.0\",\"manager\":\"windows-registry\",\"severity\":\"low\",\"vulns\":[\"CVE-2026-10001\"]}," + + "{\"name\":\"criticalpkg\",\"version\":\"2.0\",\"manager\":\"windows-registry\",\"severity\":\"critical\",\"vulns\":[\"CVE-2026-10002\"]}," + + "{\"name\":\"highpkg\",\"version\":\"3.0\",\"manager\":\"windows-registry\",\"severity\":\"high\",\"vulns\":[\"CVE-2026-10003\"]}," + + "{\"name\":\"mediumpkg\",\"version\":\"4.0\",\"manager\":\"windows-registry\",\"severity\":\"medium\",\"vulns\":[\"CVE-2026-10004\"]}" + + "]}}"; + + var summary = winPEAS.Info.NetworkInfo.HackTricksHostChecker.ParsePackageVulnerabilities(responseJson, 3); + + Assert.AreEqual(3, summary.Lines.Count); + Assert.AreEqual(1, summary.NotShown); + Assert.IsTrue(summary.Lines[0].StartsWith("- criticalpkg 2.0 [windows-registry] [Critical]: CVE-2026-10002")); + Assert.IsTrue(summary.Lines[1].StartsWith("- highpkg 3.0 [windows-registry] [High]: CVE-2026-10003")); + Assert.IsTrue(summary.Lines[2].StartsWith("- mediumpkg 4.0 [windows-registry] [Medium]: CVE-2026-10004")); + } + + [TestMethod] + public void PackageVulnerabilityFormatter_UsesCvssCriticalityWhenSeverityMissing() + { + var responseJson = + "{\"package_vulnerabilities\":{\"checked\":3,\"affected\":3,\"vulnerable_packages\":[" + + "{\"name\":\"mediumcvss\",\"version\":\"1.0\",\"manager\":\"windows-registry\",\"cvss_score\":5.0,\"vulns\":[\"CVE-2026-10001\"]}," + + "{\"name\":\"criticalcvss\",\"version\":\"2.0\",\"manager\":\"windows-registry\",\"cvss_score\":9.8,\"vulns\":[\"CVE-2026-10002\"]}," + + "{\"name\":\"highobject\",\"version\":\"3.0\",\"manager\":\"windows-registry\",\"vulns\":[{\"id\":\"CVE-2026-10003\",\"severity\":\"high\",\"cvss_score\":8.1}]}" + + "]}}"; + + var summary = winPEAS.Info.NetworkInfo.HackTricksHostChecker.ParsePackageVulnerabilities(responseJson, 50); + + Assert.AreEqual(3, summary.Lines.Count); + Assert.IsTrue(summary.Lines[0].StartsWith("- criticalcvss 2.0 [windows-registry] [Critical]: CVE-2026-10002")); + Assert.IsTrue(summary.Lines[1].StartsWith("- highobject 3.0 [windows-registry] [High]: CVE-2026-10003")); + Assert.IsTrue(summary.Lines[2].StartsWith("- mediumcvss 1.0 [windows-registry] [Medium]: CVE-2026-10001")); + } } } diff --git a/winPEAS/winPEASexe/winPEAS/Info/NetworkInfo/HackTricksHostChecker.cs b/winPEAS/winPEASexe/winPEAS/Info/NetworkInfo/HackTricksHostChecker.cs index 67adebc..dd48bfc 100644 --- a/winPEAS/winPEASexe/winPEAS/Info/NetworkInfo/HackTricksHostChecker.cs +++ b/winPEAS/winPEASexe/winPEAS/Info/NetworkInfo/HackTricksHostChecker.cs @@ -30,10 +30,30 @@ namespace winPEAS.Info.NetworkInfo public List Lines { get; } = new List(); } + internal sealed class PackageVulnerabilityEntry + { + public int OriginalIndex { get; set; } + public int SeverityPriority { get; set; } + public double CvssScore { get; set; } + public string Severity { get; set; } + public string Line { get; set; } + public List VulnerabilityIds { get; } = new List(); + } + + internal sealed class VulnerabilityCriticality + { + public int SeverityPriority { get; set; } + public double CvssScore { get; set; } + public string Severity { get; set; } + } + public static class HackTricksHostChecker { private const int TimeoutSeconds = 15; + private const int OsvTimeoutSeconds = 12; + private const int MaxOsvVulnerabilityLookups = 100; private const string DefaultUrl = "https://tools.hacktricks.wiki/api/host-checker"; + private const string OsvVulnerabilityUrl = "https://api.osv.dev/v1/vulns/"; private static readonly object LockObj = new object(); private static Task lookupTask; private static bool startedWithPackages; @@ -87,10 +107,21 @@ namespace winPEAS.Info.NetworkInfo return new PackageVulnerabilitySummary { Error = result.Error }; } - return ParsePackageVulnerabilities(result.RawResponse, maxLines); + return ParsePackageVulnerabilities( + result.RawResponse, + maxLines, + ResolveVulnerabilityCriticalitiesFromOsv); } public static PackageVulnerabilitySummary ParsePackageVulnerabilities(string responseJson, int maxLines) + { + return ParsePackageVulnerabilities(responseJson, maxLines, null); + } + + private static PackageVulnerabilitySummary ParsePackageVulnerabilities( + string responseJson, + int maxLines, + Func, Dictionary> vulnerabilityCriticalityResolver) { var summary = new PackageVulnerabilitySummary(); if (string.IsNullOrWhiteSpace(responseJson)) @@ -124,13 +155,10 @@ namespace winPEAS.Info.NetworkInfo return summary; } + var entries = new List(); + var index = 0; foreach (var package in packages.EnumerateArray()) { - if (summary.Lines.Count >= maxLines) - { - break; - } - var name = GetString(package, "name"); var version = GetString(package, "version"); var ecosystem = GetString(package, "ecosystem"); @@ -143,9 +171,30 @@ namespace winPEAS.Info.NetworkInfo var source = !string.IsNullOrWhiteSpace(ecosystem) ? ecosystem : manager; var sourcePart = !string.IsNullOrWhiteSpace(source) ? $" [{source}]" : string.Empty; - summary.Lines.Add($"- {name} {version}{sourcePart}: {string.Join(", ", vulns)}".Trim()); + var severity = GetPackageSeverity(package); + var severityPart = !string.IsNullOrWhiteSpace(severity) ? $" [{severity}]" : string.Empty; + var entry = new PackageVulnerabilityEntry + { + OriginalIndex = index, + Severity = severity, + SeverityPriority = GetSeverityPriority(severity), + CvssScore = GetPackageCvssScore(package), + Line = $"- {name} {version}{sourcePart}{severityPart}: {string.Join(", ", vulns)}".Trim() + }; + entry.VulnerabilityIds.AddRange(vulns); + entries.Add(entry); + index++; } + ApplyResolvedCriticalities(entries, vulnerabilityCriticalityResolver); + + summary.Lines.AddRange(entries + .OrderByDescending(entry => entry.SeverityPriority) + .ThenByDescending(entry => entry.CvssScore) + .ThenBy(entry => entry.OriginalIndex) + .Take(Math.Max(0, maxLines)) + .Select(entry => entry.Line)); + summary.NotShown = Math.Max(0, summary.Affected - summary.Lines.Count); } } @@ -157,6 +206,165 @@ namespace winPEAS.Info.NetworkInfo return summary; } + private static void ApplyResolvedCriticalities( + List entries, + Func, Dictionary> vulnerabilityCriticalityResolver) + { + if (vulnerabilityCriticalityResolver == null || entries.Count == 0) + { + return; + } + + var vulnerabilityIds = entries + .SelectMany(entry => entry.VulnerabilityIds) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (vulnerabilityIds.Count == 0) + { + return; + } + + Dictionary criticalities; + try + { + criticalities = vulnerabilityCriticalityResolver(vulnerabilityIds); + } + catch + { + return; + } + + if (criticalities == null || criticalities.Count == 0) + { + return; + } + + foreach (var entry in entries) + { + var resolved = entry.VulnerabilityIds + .Where(id => criticalities.ContainsKey(id)) + .Select(id => criticalities[id]) + .OrderByDescending(criticality => criticality.SeverityPriority) + .ThenByDescending(criticality => criticality.CvssScore) + .FirstOrDefault(); + if (resolved == null || resolved.SeverityPriority <= entry.SeverityPriority) + { + continue; + } + + entry.Severity = resolved.Severity; + entry.SeverityPriority = resolved.SeverityPriority; + entry.CvssScore = resolved.CvssScore; + entry.Line = AddSeverityToLine(entry.Line, resolved.Severity); + } + } + + private static string AddSeverityToLine(string line, string severity) + { + severity = FormatSeverity(severity); + if (string.IsNullOrWhiteSpace(severity) || string.IsNullOrWhiteSpace(line)) + { + return line; + } + + var separator = line.IndexOf(": ", StringComparison.Ordinal); + if (separator < 0) + { + return line; + } + + return line.Substring(0, separator) + $" [{severity}]" + line.Substring(separator); + } + + private static Dictionary ResolveVulnerabilityCriticalitiesFromOsv(IEnumerable vulnerabilityIds) + { + var ids = vulnerabilityIds + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(MaxOsvVulnerabilityLookups) + .ToList(); + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (ids.Count == 0) + { + return results; + } + + using (var httpClient = new HttpClient()) + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(OsvTimeoutSeconds))) + { + httpClient.Timeout = TimeSpan.FromSeconds(OsvTimeoutSeconds); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("winpeas"); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var tasks = ids.Select(id => GetOsvCriticality(httpClient, cts.Token, id)).ToArray(); + Task.WaitAll(tasks, TimeSpan.FromSeconds(OsvTimeoutSeconds)); + foreach (var task in tasks) + { + if (task.Status == TaskStatus.RanToCompletion && task.Result.Value != null) + { + results[task.Result.Key] = task.Result.Value; + } + } + } + + return results; + } + + private static async Task> GetOsvCriticality( + HttpClient httpClient, + CancellationToken cancellationToken, + string vulnerabilityId) + { + try + { + var url = OsvVulnerabilityUrl + Uri.EscapeDataString(vulnerabilityId); + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return new KeyValuePair(vulnerabilityId, null); + } + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using (var doc = JsonDocument.Parse(body)) + { + return new KeyValuePair( + vulnerabilityId, + GetOsvCriticality(doc.RootElement)); + } + } + catch + { + return new KeyValuePair(vulnerabilityId, null); + } + } + + private static VulnerabilityCriticality GetOsvCriticality(JsonElement root) + { + var best = new VulnerabilityCriticality(); + if (!root.TryGetProperty("severity", out var severities) || severities.ValueKind != JsonValueKind.Array) + { + return best; + } + + foreach (var severity in severities.EnumerateArray()) + { + var scoreText = GetString(severity, "score"); + var cvssScore = GetCvssScore(scoreText); + var severityName = GetSeverityFromCvss(cvssScore); + var priority = GetSeverityPriority(severityName); + if (priority > best.SeverityPriority || + (priority == best.SeverityPriority && cvssScore > best.CvssScore)) + { + best.Severity = severityName; + best.SeverityPriority = priority; + best.CvssScore = cvssScore; + } + } + + return best; + } + private static void EnsureStarted(bool includePackages) { Start( @@ -306,6 +514,315 @@ namespace winPEAS.Info.NetworkInfo return string.Empty; } + private static string GetPackageSeverity(JsonElement package) + { + var severity = GetString(package, "severity"); + var priority = GetSeverityPriority(severity); + + foreach (var propertyName in new[] { "max_severity", "criticality", "risk", "level" }) + { + var candidate = GetString(package, propertyName); + var candidatePriority = GetSeverityPriority(candidate); + if (candidatePriority > priority) + { + severity = candidate; + priority = candidatePriority; + } + } + + if (package.TryGetProperty("severities", out var severities) && severities.ValueKind == JsonValueKind.Array) + { + foreach (var item in severities.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + { + continue; + } + + var candidate = item.GetString(); + var candidatePriority = GetSeverityPriority(candidate); + if (candidatePriority > priority) + { + severity = candidate; + priority = candidatePriority; + } + } + } + + if (package.TryGetProperty("vulns", out var vulns) && vulns.ValueKind == JsonValueKind.Array) + { + foreach (var vuln in vulns.EnumerateArray()) + { + if (vuln.ValueKind != JsonValueKind.Object) + { + continue; + } + + foreach (var propertyName in new[] { "severity", "max_severity", "criticality", "risk", "level" }) + { + var candidate = GetString(vuln, propertyName); + var candidatePriority = GetSeverityPriority(candidate); + if (candidatePriority > priority) + { + severity = candidate; + priority = candidatePriority; + } + } + } + } + + if (priority == 0) + { + var cvssScore = GetPackageCvssScore(package); + severity = GetSeverityFromCvss(cvssScore); + } + + return FormatSeverity(severity); + } + + private static double GetPackageCvssScore(JsonElement package) + { + var score = 0.0; + foreach (var propertyName in new[] { "cvss", "cvss_score", "cvssScore", "max_cvss", "max_cvss_score", "score" }) + { + score = Math.Max(score, GetScore(package, propertyName)); + } + + if (package.TryGetProperty("vulns", out var vulns) && vulns.ValueKind == JsonValueKind.Array) + { + foreach (var vuln in vulns.EnumerateArray()) + { + if (vuln.ValueKind != JsonValueKind.Object) + { + continue; + } + + foreach (var propertyName in new[] { "cvss", "cvss_score", "cvssScore", "max_cvss", "max_cvss_score", "score" }) + { + score = Math.Max(score, GetScore(vuln, propertyName)); + } + } + } + + return score; + } + + private static int GetSeverityPriority(string severity) + { + switch ((severity ?? "").Trim().ToLowerInvariant()) + { + case "critical": + return 5; + case "high": + case "important": + return 4; + case "medium": + case "moderate": + return 3; + case "low": + return 2; + case "none": + case "informational": + case "info": + return 1; + default: + return 0; + } + } + + private static string GetSeverityFromCvss(double cvssScore) + { + if (cvssScore >= 9.0) + { + return "Critical"; + } + if (cvssScore >= 7.0) + { + return "High"; + } + if (cvssScore >= 4.0) + { + return "Medium"; + } + if (cvssScore > 0) + { + return "Low"; + } + + return string.Empty; + } + + private static string FormatSeverity(string severity) + { + severity = (severity ?? "").Trim(); + if (string.IsNullOrWhiteSpace(severity)) + { + return string.Empty; + } + + return char.ToUpperInvariant(severity[0]) + severity.Substring(1).ToLowerInvariant(); + } + + private static double GetScore(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property)) + { + return 0.0; + } + + if (property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out var value)) + { + return value; + } + + if (property.ValueKind == JsonValueKind.String) + { + return GetCvssScore(property.GetString()); + } + + return 0.0; + } + + private static double GetCvssScore(string score) + { + score = (score ?? "").Trim(); + if (double.TryParse( + score, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var numericScore)) + { + return numericScore; + } + + if (score.StartsWith("CVSS:3.", StringComparison.OrdinalIgnoreCase)) + { + return GetCvssV3Score(score); + } + + return 0.0; + } + + private static double GetCvssV3Score(string vector) + { + var metrics = vector + .Split('/') + .Skip(1) + .Select(part => part.Split(':')) + .Where(parts => parts.Length == 2) + .ToDictionary(parts => parts[0], parts => parts[1], StringComparer.OrdinalIgnoreCase); + + if (!metrics.TryGetValue("AV", out var av) || + !metrics.TryGetValue("AC", out var ac) || + !metrics.TryGetValue("PR", out var pr) || + !metrics.TryGetValue("UI", out var ui) || + !metrics.TryGetValue("S", out var scope) || + !metrics.TryGetValue("C", out var c) || + !metrics.TryGetValue("I", out var i) || + !metrics.TryGetValue("A", out var a)) + { + return 0.0; + } + + var scopeChanged = string.Equals(scope, "C", StringComparison.OrdinalIgnoreCase); + var impact = 1 - ((1 - GetCvssImpactValue(c)) * (1 - GetCvssImpactValue(i)) * (1 - GetCvssImpactValue(a))); + var impactSubScore = scopeChanged + ? 7.52 * (impact - 0.029) - 3.25 * Math.Pow(impact - 0.02, 15) + : 6.42 * impact; + if (impactSubScore <= 0) + { + return 0.0; + } + + var exploitability = 8.22 * + GetCvssAttackVectorValue(av) * + GetCvssAttackComplexityValue(ac) * + GetCvssPrivilegesRequiredValue(pr, scopeChanged) * + GetCvssUserInteractionValue(ui); + var rawScore = scopeChanged + ? Math.Min(1.08 * (impactSubScore + exploitability), 10) + : Math.Min(impactSubScore + exploitability, 10); + return RoundUpCvss(rawScore); + } + + private static double RoundUpCvss(double value) + { + return Math.Ceiling((value - 0.000001) * 10.0) / 10.0; + } + + private static double GetCvssAttackVectorValue(string value) + { + switch ((value ?? "").ToUpperInvariant()) + { + case "N": + return 0.85; + case "A": + return 0.62; + case "L": + return 0.55; + case "P": + return 0.20; + default: + return 0.0; + } + } + + private static double GetCvssAttackComplexityValue(string value) + { + switch ((value ?? "").ToUpperInvariant()) + { + case "L": + return 0.77; + case "H": + return 0.44; + default: + return 0.0; + } + } + + private static double GetCvssPrivilegesRequiredValue(string value, bool scopeChanged) + { + switch ((value ?? "").ToUpperInvariant()) + { + case "N": + return 0.85; + case "L": + return scopeChanged ? 0.68 : 0.62; + case "H": + return scopeChanged ? 0.50 : 0.27; + default: + return 0.0; + } + } + + private static double GetCvssUserInteractionValue(string value) + { + switch ((value ?? "").ToUpperInvariant()) + { + case "N": + return 0.85; + case "R": + return 0.62; + default: + return 0.0; + } + } + + private static double GetCvssImpactValue(string value) + { + switch ((value ?? "").ToUpperInvariant()) + { + case "H": + return 0.56; + case "L": + return 0.22; + case "N": + return 0.0; + default: + return 0.0; + } + } + private static List GetStringArray(JsonElement element, string propertyName) { if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) @@ -315,8 +832,27 @@ namespace winPEAS.Info.NetworkInfo return property .EnumerateArray() - .Where(item => item.ValueKind == JsonValueKind.String) - .Select(item => item.GetString()) + .Select(item => + { + if (item.ValueKind == JsonValueKind.String) + { + return item.GetString(); + } + + if (item.ValueKind == JsonValueKind.Object) + { + foreach (var propertyNameCandidate in new[] { "id", "cve", "name", "vulnerability_id" }) + { + var value = GetString(item, propertyNameCandidate); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + } + + return string.Empty; + }) .Where(value => !string.IsNullOrWhiteSpace(value)) .ToList(); }