mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-13 12:59:24 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cc120c182 | |||
| 6a68580c87 | |||
| 6f48aab4bf | |||
| 80852de813 | |||
| fa4a016388 | |||
| 996240fa0e | |||
| ed209b5f1a | |||
| a694f07f89 | |||
| fcd678eb4a | |||
| 560fa844b1 | |||
| 6198f5ac21 | |||
| e96c35a311 | |||
| dc7b960bb2 | |||
| 7f17b5c484 | |||
| 7cffcc8799 | |||
| 0cefbc17cf | |||
| 94324bf137 | |||
| 456b8f6025 | |||
| c3faece6d8 | |||
| f0fee75b08 | |||
| ac381a39ad | |||
| a1936b7cb6 | |||
| ba53b82c00 | |||
| 7f53dcf223 | |||
| cd2ea05878 | |||
| ccdd8c17ce | |||
| e6191831bd | |||
| ccea1dacfe | |||
| 33e614da5d | |||
| a1433141ce | |||
| a7e76f0584 | |||
| 3829b6ce92 | |||
| dabd0d5b7e | |||
| 68dab1e154 | |||
| a1590579f9 | |||
| a558ca5510 | |||
| e01e7abc2b | |||
| 512b145484 | |||
| 8ece30631a | |||
| c9078d00b6 | |||
| 1ff68dc0af | |||
| 7c8204357b | |||
| dfcf7ad8ba | |||
| 54d4c0bd4c | |||
| 11bc0ec2ab | |||
| 7e9b0cea54 | |||
| c115cb354e | |||
| 9cfe3fb74d | |||
| 892ff4c1e1 | |||
| 2cb181734a |
+1
-1
Submodule .claude/commands updated: 3699da8230...155a138209
+1
-1
Submodule internal updated: e0913c34d2...2e314b1de5
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-003",
|
||||
"title": "Critical Vulnerability Detected",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs in installed software that has to be updated by hand. Software that updates itself, such as browsers, is listed in the vulnerability report but does not trigger this rule.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -19,7 +19,7 @@
|
||||
"signal": "critical_vulnerabilities",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.critical_cves",
|
||||
"dataField": "scan_summary.actionable_critical_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
@@ -28,7 +28,16 @@
|
||||
"signal": "kev_matches",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.kev_matches",
|
||||
"dataField": "scan_summary.actionable_kev_matches",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "self_updating_findings",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.self_updating_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
@@ -42,7 +51,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 80,
|
||||
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
|
||||
"explanation": "The device has installed software with critical vulnerabilities that an update of the software has to close. CVEs rated CRITICAL (CVSS >= 9.0) were found in software that is updated through your deployment. Findings listed in the CISA Known Exploited Vulnerabilities catalog are known to be used in attacks and should be remediated first. Software that updates itself, such as browsers, is not counted here, because it is expected to bring itself up to date after enrollment. Its findings are still listed in the vulnerability report.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review vulnerability findings",
|
||||
@@ -74,7 +83,7 @@
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
|
||||
"summary": "Critical CVEs in software that needs a manual update: {{critical_vulnerabilities.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "vulnerabilities",
|
||||
@@ -90,6 +99,11 @@
|
||||
"label": "Known exploited",
|
||||
"value": "{{kev_matches.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "CVEs in self-updating software",
|
||||
"value": "{{self_updating_findings.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-SEC-004",
|
||||
"title": "AutoLogon enabled for an unexpected user",
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "autologon_enabled",
|
||||
"source": "event_type",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.autologon_enabled",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_autologon_user",
|
||||
"source": "event_data",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.default_user_name",
|
||||
"operator": "not_in",
|
||||
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Confirm the AutoLogon user",
|
||||
"steps": [
|
||||
"Open the autologon_analysis event in the session timeline and check the default user/domain",
|
||||
"If the user is an approved kiosk account, add it to this rule's allow-list",
|
||||
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Disable AutoLogon if not required",
|
||||
"steps": [
|
||||
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
|
||||
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
|
||||
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_autologon_user.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "AutoLogon user",
|
||||
"value": "{{unexpected_autologon_user.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"kiosk",
|
||||
"allow-list"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_autologon_users",
|
||||
"label": "Allowed AutoLogon users (kiosk accounts)",
|
||||
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
|
||||
"conditionIndex": 1,
|
||||
"field": "value",
|
||||
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
|
||||
}
|
||||
]
|
||||
}
|
||||
+20
-99
@@ -4259,10 +4259,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-003",
|
||||
"title": "Critical Vulnerability Detected",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs in installed software that has to be updated by hand. Software that updates itself, such as browsers, is listed in the vulnerability report but does not trigger this rule.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4276,7 +4276,7 @@
|
||||
"signal": "critical_vulnerabilities",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.critical_cves",
|
||||
"dataField": "scan_summary.actionable_critical_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
@@ -4285,7 +4285,16 @@
|
||||
"signal": "kev_matches",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.kev_matches",
|
||||
"dataField": "scan_summary.actionable_kev_matches",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "self_updating_findings",
|
||||
"source": "event_type",
|
||||
"eventType": "vulnerability_report",
|
||||
"dataField": "scan_summary.self_updating_cves",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
@@ -4299,7 +4308,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 80,
|
||||
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
|
||||
"explanation": "The device has installed software with critical vulnerabilities that an update of the software has to close. CVEs rated CRITICAL (CVSS >= 9.0) were found in software that is updated through your deployment. Findings listed in the CISA Known Exploited Vulnerabilities catalog are known to be used in attacks and should be remediated first. Software that updates itself, such as browsers, is not counted here, because it is expected to bring itself up to date after enrollment. Its findings are still listed in the vulnerability report.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review vulnerability findings",
|
||||
@@ -4331,7 +4340,7 @@
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
|
||||
"summary": "Critical CVEs in software that needs a manual update: {{critical_vulnerabilities.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "vulnerabilities",
|
||||
@@ -4347,6 +4356,11 @@
|
||||
"label": "Known exploited",
|
||||
"value": "{{kev_matches.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "CVEs in self-updating software",
|
||||
"value": "{{self_updating_findings.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4926,99 +4940,6 @@
|
||||
"plaintext-password"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-004",
|
||||
"title": "AutoLogon enabled for an unexpected user",
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "autologon_enabled",
|
||||
"source": "event_type",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.autologon_enabled",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_autologon_user",
|
||||
"source": "event_data",
|
||||
"eventType": "autologon_analysis",
|
||||
"dataField": "checks.default_user_name",
|
||||
"operator": "not_in",
|
||||
"value": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "AutoLogon is enabled on this device for a user that is **not on your approved kiosk allow-list**. AutoLogon is legitimate for kiosk and shared-device scenarios, so this rule only warns when the configured default user differs from the account(s) you expect.\n\nThis rule is **off by default**. A normal user-driven Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (`AutoAdminLogon=1`, a `DefaultUserName`, an `AutoLogonSID`, and no plaintext password) — registry-indistinguishable from a kiosk AutoLogon — so AutoLogon-enabled alone is not graded. The only AutoLogon state graded by default is a plaintext `DefaultPassword` on disk (**ANALYZE-SEC-003**).\n\nTo use this rule: configure the allow-list with your approved kiosk account name(s) (comma-separated, case-insensitive exact match against the Winlogon `DefaultUserName`) and enable it. AutoLogon for any other user will then warn.\n\nNote: an AutoLogon with no explicit default user (e.g. ForceAutoLogon only) is treated as unexpected and will warn.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Confirm the AutoLogon user",
|
||||
"steps": [
|
||||
"Open the autologon_analysis event in the session timeline and check the default user/domain",
|
||||
"If the user is an approved kiosk account, add it to this rule's allow-list",
|
||||
"If the user is unexpected, treat it as a possible enrollment/OOBE manipulation and investigate what wrote the Winlogon keys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Disable AutoLogon if not required",
|
||||
"steps": [
|
||||
"Set HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon\\AutoAdminLogon to 0",
|
||||
"Remove DefaultPassword (and any Sysinternals LSA secret) if present",
|
||||
"For kiosk scenarios, prefer Assigned Access / kiosk profiles over raw AutoLogon where possible"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft: Configure Windows to automatically sign in (AutoLogon)",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_autologon_user.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "AutoLogon user",
|
||||
"value": "{{unexpected_autologon_user.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"kiosk",
|
||||
"allow-list"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_autologon_users",
|
||||
"label": "Allowed AutoLogon users (kiosk accounts)",
|
||||
"description": "Comma-separated list of approved kiosk / shared-device account names that are expected to use AutoLogon (case-insensitive exact match against the Winlogon DefaultUserName, e.g. 'KioskUser' or 'Reception,LobbyPC'). AutoLogon for any other user raises a warning.",
|
||||
"conditionIndex": 1,
|
||||
"field": "value",
|
||||
"placeholder": "EXPECTED-KIOSK-USER-1,EXPECTED-KIOSK-USER-2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-005",
|
||||
"title": "Unexpected Provisioning Package",
|
||||
|
||||
-2677
File diff suppressed because it is too large
Load Diff
+6148
File diff suppressed because it is too large
Load Diff
+35
@@ -135,6 +135,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
Assert.Equal("CONTOSO", checks["default_domain_name"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPayload_reports_a_sign_in_address_as_its_domain_only()
|
||||
{
|
||||
var snap = new AutoLogonSnapshot
|
||||
{
|
||||
WinlogonKeyPresent = true,
|
||||
AutoAdminLogon = "1",
|
||||
DefaultUserName = "luke.skywalker@example.net",
|
||||
AltDefaultUserName = @"AzureAD\luke.skywalker@example.net",
|
||||
};
|
||||
var data = AutoLogonAnalyzer.BuildPayload(snap, "shutdown");
|
||||
|
||||
var checks = Checks(data);
|
||||
Assert.Equal("[redacted]@example.net", checks["default_user_name"]);
|
||||
Assert.Equal("[redacted]@example.net", checks["alt_default_user_name"]);
|
||||
Assert.DoesNotContain(checks.Values.OfType<string>(), v => v.Contains("skywalker"));
|
||||
// The reduction is presentation only — the identity is still reported as present.
|
||||
Assert.True((bool)checks["default_identity_present"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("kioskuser", "kioskuser")]
|
||||
[InlineData("defaultuser0", "defaultuser0")]
|
||||
[InlineData("foouser@example.net", "foouser@example.net")]
|
||||
[InlineData("autopilot@example.net", "autopilot@example.net")]
|
||||
[InlineData("luke.skywalker@example.net", "[redacted]@example.net")]
|
||||
[InlineData("luke@skywalker@example.net", "[redacted]@example.net")]
|
||||
[InlineData("luke.skywalker@", "[redacted]@unknown")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void ReduceUserName_keeps_local_accounts_and_placeholders_readable(string? raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AutoLogonAnalyzer.ReduceUserName(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("true", true)]
|
||||
|
||||
+43
-1
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var notJoined = new Dictionary<string, object> { { "joinType", "Not Joined" } };
|
||||
var joined = new Dictionary<string, object> { { "joinType", "Azure AD Joined" }, { "userEmail", "alice@contoso.com" } };
|
||||
var joined = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
|
||||
|
||||
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
|
||||
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, notJoined));
|
||||
@@ -56,6 +56,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
|
||||
Assert.False(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, joined));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Placeholder_replaced_by_a_user_of_the_same_domain_reemits()
|
||||
{
|
||||
// Pre-provisioning hands the device from the placeholder account to the real user
|
||||
// in the same domain — the two payloads must still differ for the gate.
|
||||
using var rig = new Rig();
|
||||
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
|
||||
var realUser = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
|
||||
|
||||
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, placeholder));
|
||||
Assert.True(rig.Sut.ShouldEmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, realUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Boot_time_and_wifi_signal_are_exempt_from_the_gate()
|
||||
{
|
||||
@@ -114,6 +127,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Payload of <c>aad_join_status</c> for a joined device.</summary>
|
||||
public sealed class AadJoinStatusPayloadTests
|
||||
{
|
||||
[Fact]
|
||||
public void Joined_payload_carries_the_domain_of_the_signed_in_address_only()
|
||||
{
|
||||
var data = DeviceInfoCollector.BuildAadJoinedData("T1", "alice@contoso.com", "AABB", isPlaceholderUser: false);
|
||||
|
||||
Assert.Equal("Azure AD Joined", data["joinType"]);
|
||||
Assert.Equal("T1", data["tenantId"]);
|
||||
Assert.Equal("AABB", data["thumbprint"]);
|
||||
Assert.Equal("contoso.com", data["userDomain"]);
|
||||
Assert.False(data.ContainsKey("isFooUser"));
|
||||
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("alice"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Joined_payload_flags_the_placeholder_and_omits_the_domain_without_an_address()
|
||||
{
|
||||
var placeholder = DeviceInfoCollector.BuildAadJoinedData("T1", "foouser@contoso.com", "AABB", isPlaceholderUser: true);
|
||||
Assert.Equal(true, placeholder["isFooUser"]);
|
||||
Assert.Equal("contoso.com", placeholder["userDomain"]);
|
||||
|
||||
var deviceOnly = DeviceInfoCollector.BuildAadJoinedData("T1", null!, "AABB", isPlaceholderUser: false);
|
||||
Assert.False(deviceOnly.ContainsKey("userDomain"));
|
||||
Assert.False(deviceOnly.ContainsKey("isFooUser"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Inventory fingerprint used by the SoftwareInventoryAnalyzer's restart dedup.</summary>
|
||||
public sealed class SoftwareInventoryFingerprintTests
|
||||
{
|
||||
|
||||
+27
@@ -148,5 +148,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
"the window never closed: the worker exited before the watcher subscribed to Exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_traced_id_that_belongs_to_another_program_is_not_tracked()
|
||||
{
|
||||
// The start trace arrives late: by then the id can belong to an unrelated process.
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
watcher.WorkerSource = () => Array.Empty<Process>();
|
||||
watcher.Start(armWmi: false);
|
||||
var child = Spawn();
|
||||
|
||||
watcher.TrackTracedProcess(child.Id, "OfficeC2RClient");
|
||||
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_traced_id_that_still_carries_the_expected_name_is_tracked()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
watcher.WorkerSource = () => Array.Empty<Process>();
|
||||
watcher.Start(armWmi: false);
|
||||
var child = Spawn();
|
||||
|
||||
watcher.TrackTracedProcess(child.Id, "cmd");
|
||||
|
||||
Assert.True(watcher.IsActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+319
-75
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -10,14 +11,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// ConsoleBypassWatcher classification — driven via the internal <c>HandleStart</c>/<c>Classify</c>
|
||||
/// with an injected process probe (no real WMI / processes). Asserts the parent-free discriminator:
|
||||
/// an interactive-session cmd with a bare command line is flagged (high confidence); a scripted
|
||||
/// <c>cmd /c</c> and a session-0 (service) cmd are ignored; an interactive-session cmd whose command
|
||||
/// line cannot be read (instant-close race) is still surfaced at low confidence.
|
||||
/// with injected probes (no real WMI / processes). A console that is still running when probed is
|
||||
/// reported: bare command line (high confidence), <c>/k</c> or unreadable (low). A scripted
|
||||
/// <c>cmd /c</c> and a session-0 cmd are ignored. A start whose process is gone, or whose id now
|
||||
/// belongs to another process, is counted as script activity and never reported as a console.
|
||||
/// </summary>
|
||||
public sealed class ConsoleBypassWatcherTests
|
||||
{
|
||||
private const string Cmd = "cmd.exe";
|
||||
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
|
||||
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
|
||||
private const string Created = "20261111143453.119277+060";
|
||||
private const int Pid = 9636;
|
||||
private const int Parent = 9016;
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 34, 53, DateTimeKind.Utc);
|
||||
|
||||
private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info);
|
||||
|
||||
@@ -25,59 +32,67 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public List<ConsoleSpawnInfo> Detected { get; } = new List<ConsoleSpawnInfo>();
|
||||
public List<int> ParentLookups { get; } = new List<int>();
|
||||
public int WindowsOpened { get; private set; }
|
||||
public ConsoleBypassWatcher Sut { get; }
|
||||
|
||||
public Rig(Func<int, ProcessProbe?> probe)
|
||||
public Rig(Func<int, string, ProcessProbe?> probe, bool alive = true, Func<int, string?>? parentName = null)
|
||||
{
|
||||
Sut = new ConsoleBypassWatcher(NewLogger(_tmp.Path), probe);
|
||||
Sut = new ConsoleBypassWatcher(
|
||||
NewLogger(_tmp.Path),
|
||||
probe,
|
||||
isAlive: _ => alive,
|
||||
parentName: pid => { ParentLookups.Add(pid); return parentName?.Invoke(pid); },
|
||||
utcNow: () => At);
|
||||
Sut.BypassConsoleDetected += (_, info) => Detected.Add(info);
|
||||
Sut.ScriptActivityStarted += (_, __) => WindowsOpened++;
|
||||
}
|
||||
|
||||
public void Trace(int pid = Pid, int parentPid = Parent, int sessionId = 1, string? ownerSid = OobeAccount) =>
|
||||
Sut.HandleStart(pid, parentPid, sessionId, ownerSid, "process_start_trace", Cmd);
|
||||
|
||||
public void Dispose() { Sut.Dispose(); _tmp.Dispose(); }
|
||||
}
|
||||
|
||||
private static Func<int, ProcessProbe?> Probe(string? commandLine, string? owner = @"PC\defaultuser0")
|
||||
=> _ => new ProcessProbe(commandLine, owner);
|
||||
/// <summary>A running process that is the traced one, with the given command line.</summary>
|
||||
private static Func<int, string, ProcessProbe?> Running(
|
||||
string? commandLine, string name = Cmd, int sessionId = 1, int parentPid = Parent, string creationDate = Created)
|
||||
=> (_, __) => new ProcessProbe(name, sessionId, parentPid, commandLine, creationDate);
|
||||
|
||||
private static readonly Func<int, ProcessProbe?> GoneProbe = _ => null;
|
||||
/// <summary>The process has exited before it could be read.</summary>
|
||||
private static readonly Func<int, string, ProcessProbe?> Gone = (_, __) => null;
|
||||
|
||||
// ---------------------------------------------------------------- HandleStart end-to-end
|
||||
// ---------------------------------------------------------------- consoles that are reported
|
||||
|
||||
[Fact]
|
||||
public void Interactive_session_bare_cmd_raises_high_confidence_with_signature()
|
||||
{
|
||||
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
|
||||
using var rig = new Rig(Running(BareCmd), parentName: _ => "explorer.exe");
|
||||
|
||||
rig.Sut.HandleStart(pid: 9636, parentPid: 9016, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("cmd.exe", info.ProcessName);
|
||||
Assert.Equal(9636, info.ProcessId);
|
||||
Assert.Equal(9016, info.ParentProcessId);
|
||||
Assert.Equal(Cmd, info.ProcessName);
|
||||
Assert.Equal(Pid, info.ProcessId);
|
||||
Assert.Equal(Parent, info.ParentProcessId);
|
||||
Assert.Equal("explorer.exe", info.ParentProcessName);
|
||||
Assert.Equal(1, info.SessionId);
|
||||
Assert.Equal("high", info.Confidence);
|
||||
Assert.Equal("interactive_console", info.Classification);
|
||||
Assert.Equal(@"PC\defaultuser0", info.Owner);
|
||||
Assert.Equal(OobeAccount, info.Owner);
|
||||
Assert.Equal(BareCmd, info.CommandLine);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_zero_cmd_is_ignored_even_when_bare()
|
||||
public void A_console_whose_launcher_is_gone_has_no_parent_name()
|
||||
{
|
||||
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
|
||||
using var rig = new Rig(Probe(BareCmd));
|
||||
rig.Sut.HandleStart(1234, 1, sessionId: 0, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
Assert.Empty(rig.Detected);
|
||||
}
|
||||
// The launcher behind Shift+F10 exits within milliseconds.
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
|
||||
[InlineData(@"cmd.exe /C ping")]
|
||||
public void Scripted_cmd_is_ignored(string commandLine)
|
||||
{
|
||||
using var rig = new Rig(Probe(commandLine));
|
||||
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
Assert.Empty(rig.Detected);
|
||||
rig.Trace();
|
||||
|
||||
Assert.Null(Assert.Single(rig.Detected).ParentProcessName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,9 +100,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
// L12: /k runs its command and then leaves a fully interactive shell — a deliberate
|
||||
// false-negative seam if ignored (e.g. a technician-planted `cmd /k whoami`).
|
||||
using var rig = new Rig(Probe(@"cmd.exe /k whoami"));
|
||||
using var rig = new Rig(Running(@"cmd.exe /k whoami"));
|
||||
|
||||
rig.Sut.HandleStart(2222, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
@@ -101,9 +116,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
// cmd.exe honors only the FIRST /c-or-/k switch; a '/c' inside the /k payload is inert, so
|
||||
// the console is a persistent human-usable shell and must still be surfaced (low confidence).
|
||||
using var rig = new Rig(Probe(commandLine));
|
||||
using var rig = new Rig(Running(commandLine));
|
||||
|
||||
rig.Sut.HandleStart(2223, 1, sessionId: 1, ownerSidHint: null, detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
@@ -111,88 +126,317 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instant_close_unreadable_commandline_raises_low_confidence()
|
||||
public void A_running_process_without_a_readable_command_line_raises_low_confidence()
|
||||
{
|
||||
// Win32_Process query loses the race (process already exited). SessionID from the trace is
|
||||
// still known, so the console is surfaced — low confidence, owner from the trace SID hint.
|
||||
using var rig = new Rig(GoneProbe);
|
||||
using var rig = new Rig(Running(commandLine: null), alive: true);
|
||||
|
||||
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
Assert.Equal("interactive_session_unclassified", info.Classification);
|
||||
Assert.Equal("S-1-5-18", info.Owner);
|
||||
Assert.Null(info.CommandLine);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Owner_prefers_probe_over_trace_hint()
|
||||
public void A_running_process_that_could_not_be_read_at_all_raises_low_confidence()
|
||||
{
|
||||
using var rig = new Rig(Probe(BareCmd, owner: @"PC\defaultuser0"));
|
||||
rig.Sut.HandleStart(9636, 9016, sessionId: 1, ownerSidHint: "S-1-5-18", detectedVia: "process_start_trace");
|
||||
Assert.Equal(@"PC\defaultuser0", Assert.Single(rig.Detected).Owner);
|
||||
// The query failed. That says nothing about the process: it is reported, never counted away.
|
||||
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: true);
|
||||
|
||||
rig.Trace();
|
||||
|
||||
var info = Assert.Single(rig.Detected);
|
||||
Assert.Equal("low", info.Confidence);
|
||||
Assert.Equal("interactive_session_unclassified", info.Classification);
|
||||
Assert.Equal(OobeAccount, info.Owner);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- starts that are ignored
|
||||
|
||||
[Fact]
|
||||
public void Same_pid_seen_twice_raises_only_once()
|
||||
public void Session_zero_cmd_is_ignored_even_when_bare()
|
||||
{
|
||||
// The startup probe and the live trace can both observe the same still-open console.
|
||||
using var rig = new Rig(Probe(BareCmd));
|
||||
rig.Sut.HandleStart(9636, 9016, 1, null, "startup_probe");
|
||||
rig.Sut.HandleStart(9636, 9016, 1, null, "process_start_trace");
|
||||
Assert.Single(rig.Detected);
|
||||
// IME / Intune install cmd run as SYSTEM in session 0 — never an interactive console.
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
rig.Trace(sessionId: 0);
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe /c ""install.bat""")]
|
||||
[InlineData(@"cmd.exe /C ping")]
|
||||
public void A_running_scripted_cmd_is_neither_reported_nor_counted(string commandLine)
|
||||
{
|
||||
using var rig = new Rig(Running(commandLine));
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_pid_is_ignored()
|
||||
{
|
||||
using var rig = new Rig(Probe(BareCmd));
|
||||
rig.Sut.HandleStart(0, 9016, 1, null, "process_start_trace");
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
rig.Trace(pid: 0);
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- starts that are counted
|
||||
|
||||
[Fact]
|
||||
public void A_process_that_has_exited_is_counted_and_not_reported()
|
||||
{
|
||||
// WMI delivers the start about a second late; a scripted cmd /c is gone by then. That is
|
||||
// script activity, not a console someone types into.
|
||||
using var rig = new Rig(Gone, parentName: _ => "powershell.exe");
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
var activity = rig.Sut.TakeScriptActivity()!;
|
||||
Assert.Equal(1, activity.Count);
|
||||
Assert.Equal(Cmd, activity.ProcessName);
|
||||
Assert.Equal(At, activity.FirstSeenUtc);
|
||||
Assert.Equal(new[] { 1 }, activity.WindowsSessions);
|
||||
Assert.Equal(new[] { OobeAccount }, activity.Owners);
|
||||
var parent = Assert.Single(activity.Parents);
|
||||
Assert.Equal(Parent, parent.ProcessId);
|
||||
Assert.Equal("powershell.exe", parent.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SearchFilterHost.exe", 1, Parent)] // another program was given the id
|
||||
[InlineData(Cmd, 0, Parent)] // a service-session cmd was given the id
|
||||
[InlineData(Cmd, 1, 4242)] // a cmd of another parent was given the id
|
||||
public void An_id_that_now_belongs_to_another_process_is_counted_and_not_reported(string name, int sessionId, int parentPid)
|
||||
{
|
||||
// Its command line has no /c, which used to read as a bare interactive console.
|
||||
using var rig = new Rig(Running(@"C:\Windows\System32\other.exe -Embedding", name, sessionId, parentPid));
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_without_a_command_line_whose_process_has_ended_is_counted()
|
||||
{
|
||||
using var rig = new Rig(Running(commandLine: null), alive: false);
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_process_that_could_not_be_read_and_is_gone_at_the_second_look_is_counted()
|
||||
{
|
||||
using var rig = new Rig((_, __) => ProcessProbe.Unreadable, alive: false);
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_burst_opens_one_window_and_asks_for_each_parent_once()
|
||||
{
|
||||
using var rig = new Rig(Gone, parentName: _ => "python.exe");
|
||||
|
||||
for (var i = 0; i < 25; i++) rig.Trace(pid: 5000 + i, parentPid: 2688);
|
||||
for (var i = 0; i < 21; i++) rig.Trace(pid: 6000 + i, parentPid: 9296);
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Equal(1, rig.WindowsOpened);
|
||||
Assert.Equal(new[] { 2688, 9296 }, rig.ParentLookups);
|
||||
var activity = rig.Sut.TakeScriptActivity()!;
|
||||
Assert.Equal(46, activity.Count);
|
||||
Assert.Equal(new[] { 25, 21 }, activity.Parents.Select(p => p.Children));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_next_start_after_the_content_was_taken_opens_a_new_window()
|
||||
{
|
||||
using var rig = new Rig(Gone);
|
||||
|
||||
rig.Trace(pid: 1);
|
||||
rig.Trace(pid: 2);
|
||||
rig.Sut.TakeScriptActivity();
|
||||
rig.Trace(pid: 3);
|
||||
|
||||
Assert.Equal(2, rig.WindowsOpened);
|
||||
Assert.Equal(1, rig.Sut.TakeScriptActivity()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_is_reported_or_counted_after_dispose()
|
||||
{
|
||||
var rig = new Rig(Gone);
|
||||
rig.Sut.Dispose();
|
||||
|
||||
rig.Trace();
|
||||
|
||||
Assert.Empty(rig.Detected);
|
||||
Assert.Null(rig.Sut.TakeScriptActivity());
|
||||
rig.Dispose();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- de-duplication
|
||||
|
||||
[Fact]
|
||||
public void The_same_process_seen_by_startup_probe_and_trace_raises_only_once()
|
||||
{
|
||||
// The startup probe and the live trace can both observe the same still-open console.
|
||||
using var rig = new Rig(Running(BareCmd));
|
||||
|
||||
rig.Sut.HandleProbed(Pid, Parent, 1, OobeAccount, "startup_probe", Cmd, new ProcessProbe(Cmd, 1, Parent, BareCmd, Created));
|
||||
rig.Trace();
|
||||
|
||||
Assert.Equal("startup_probe", Assert.Single(rig.Detected).DetectedVia);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_later_console_that_was_given_an_id_seen_before_is_reported_again()
|
||||
{
|
||||
var created = Created;
|
||||
using var rig = new Rig((_, __) => new ProcessProbe(Cmd, 1, Parent, BareCmd, created));
|
||||
|
||||
rig.Trace();
|
||||
created = "20261111150324.504661+060";
|
||||
rig.Trace();
|
||||
|
||||
Assert.Equal(2, rig.Detected.Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the probe's decision
|
||||
|
||||
private static readonly ProcessProbe Row = new ProcessProbe(Cmd, 1, Parent, BareCmd, Created);
|
||||
|
||||
[Theory]
|
||||
[InlineData("NotRunning")]
|
||||
[InlineData("OtherImage")]
|
||||
public void The_probe_says_gone_only_when_the_process_list_is_certain(string listed)
|
||||
{
|
||||
var asked = false;
|
||||
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
|
||||
|
||||
var probe = ConsoleBypassWatcher.Probe(Pid, state, _ => { asked = true; return Row; });
|
||||
|
||||
Assert.Null(probe);
|
||||
Assert.False(asked, "a process that is certainly gone costs no query");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Expected")]
|
||||
[InlineData("Unknown")]
|
||||
public void The_probe_returns_the_row_of_a_process_that_may_still_run(string listed)
|
||||
{
|
||||
var state = (ConsoleBypassWatcher.ListedAs)Enum.Parse(typeof(ConsoleBypassWatcher.ListedAs), listed);
|
||||
|
||||
Assert.Same(Row, ConsoleBypassWatcher.Probe(Pid, state, _ => Row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failed_query_is_unreadable_and_never_gone()
|
||||
{
|
||||
var probe = ConsoleBypassWatcher.Probe(
|
||||
Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => throw new InvalidOperationException("WinMgmt did not answer"));
|
||||
|
||||
Assert.Same(ProcessProbe.Unreadable, probe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_query_without_a_row_is_unreadable_and_never_gone()
|
||||
{
|
||||
var probe = ConsoleBypassWatcher.Probe(Pid, ConsoleBypassWatcher.ListedAs.Expected, _ => null);
|
||||
|
||||
Assert.Same(ProcessProbe.Unreadable, probe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_process_list_tells_running_other_image_and_not_running_apart()
|
||||
{
|
||||
using var me = System.Diagnostics.Process.GetCurrentProcess();
|
||||
const int neverAProcessId = int.MaxValue;
|
||||
|
||||
Assert.Equal(ConsoleBypassWatcher.ListedAs.Expected, ConsoleBypassWatcher.ProcessListState(me.Id, me.ProcessName + ".exe"));
|
||||
Assert.Equal(ConsoleBypassWatcher.ListedAs.OtherImage, ConsoleBypassWatcher.ProcessListState(me.Id, "not-" + me.ProcessName + ".exe"));
|
||||
Assert.Equal(ConsoleBypassWatcher.ListedAs.NotRunning, ConsoleBypassWatcher.ProcessListState(neverAProcessId, Cmd));
|
||||
Assert.True(ConsoleBypassWatcher.IsRunning(me.Id));
|
||||
Assert.False(ConsoleBypassWatcher.IsRunning(neverAProcessId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- IsTracedProcess (pure)
|
||||
|
||||
[Theory]
|
||||
[InlineData("cmd.exe", 1, Parent, true)]
|
||||
[InlineData("CMD.EXE", 2, Parent, true)] // image name compares without case
|
||||
[InlineData("cmd.exe", 1, 0, true)] // the row does not know its parent
|
||||
// A fact that could not be read contradicts nothing — the process stays the traced one.
|
||||
[InlineData(null, null, null, true)]
|
||||
[InlineData(null, 1, Parent, true)]
|
||||
[InlineData("cmd.exe", null, Parent, true)]
|
||||
[InlineData("cmd.exe", 1, null, true)]
|
||||
// A fact that was read and differs makes it another process.
|
||||
[InlineData("conhost.exe", 1, Parent, false)]
|
||||
[InlineData("cmd.exe", 0, Parent, false)]
|
||||
[InlineData("cmd.exe", 1, 77, false)]
|
||||
public void IsTracedProcess_compares_name_session_and_parent(string? name, int? sessionId, int? rowParent, bool expected)
|
||||
{
|
||||
var probe = new ProcessProbe(name, sessionId, rowParent, BareCmd, Created);
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, Parent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTracedProcess_accepts_any_parent_when_the_trace_has_none()
|
||||
{
|
||||
var probe = new ProcessProbe(Cmd, 1, 77, BareCmd, Created);
|
||||
Assert.True(ConsoleBypassWatcher.IsTracedProcess(probe, Cmd, parentPid: 0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Classify (pure)
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, BareCmd, "Ignore")] // service session
|
||||
[InlineData(1, null, "UnclassifiedInteractive")] // instant-close
|
||||
[InlineData(1, null, "UnclassifiedInteractive")] // running, command line not readable
|
||||
[InlineData(1, BareCmd, "InteractiveConsole")] // bare shell
|
||||
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe""", "InteractiveConsole")]
|
||||
[InlineData(1, "", "InteractiveConsole")]
|
||||
[InlineData(1, @"cmd.exe /d", "InteractiveConsole")] // /d skips AutoRun — still interactive
|
||||
[InlineData(1, @"C:\cmd\cmd.exe", "InteractiveConsole")] // folder named 'cmd', not a switch
|
||||
[InlineData(1, @"""C:/k/cmd.exe""", "InteractiveConsole")] // forward-slash path stripped before scan
|
||||
[InlineData(1, @"cmd.exe /c foo", "Ignore")] // scripted, run-and-exit
|
||||
[InlineData(1, @"cmd.exe /C whoami", "Ignore")]
|
||||
[InlineData(1, @"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", "Ignore")]
|
||||
[InlineData(2, @"C:\x\cmd.exe /K bar", "InteractiveWithCommand")] // L12: /k stays interactive
|
||||
[InlineData(1, @"cmd.exe /k whoami", "InteractiveWithCommand")] // technician-planted shell
|
||||
[InlineData(1, @"cmd.exe /k", "InteractiveWithCommand")]
|
||||
[InlineData(1, @"cmd.exe /D/Q/K stay", "InteractiveWithCommand")] // combined run-and-stay
|
||||
[InlineData(1, @"cmd.exe /d/q/c exit 9", "Ignore")] // combined run-and-exit wins
|
||||
[InlineData(1, @"cmd /k rem /c", "InteractiveWithCommand")] // cmd.exe honors the FIRST switch: /k stays interactive
|
||||
[InlineData(1, @"cmd.exe /k ""title /c""", "InteractiveWithCommand")] // /c inside the /k payload is not a switch
|
||||
[InlineData(1, @"cmd /k /c", "InteractiveWithCommand")] // /c after /k is just the /k payload
|
||||
[InlineData(1, @"cmd.exe /c cmd /k", "Ignore")] // first switch /c wins the other way round
|
||||
[InlineData(1, @"cmd.exe /c rem /k", "Ignore")]
|
||||
public void Classify_maps_session_and_commandline(int sessionId, string? commandLine, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.Classify(sessionId, commandLine).ToString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HasScriptArgument (pure)
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe", false)]
|
||||
[InlineData(@"""C:\WINDOWS\system32\cmd.exe""", false)]
|
||||
[InlineData(@"cmd.exe /d", false)] // /d skips AutoRun — still interactive
|
||||
[InlineData(@"C:\cmd\cmd.exe", false)] // folder named 'cmd', not a switch
|
||||
[InlineData(@"""C:/k/cmd.exe""", false)] // forward-slash path stripped before scan
|
||||
[InlineData("", false)]
|
||||
[InlineData(@"cmd.exe /c whoami", true)]
|
||||
[InlineData(@"cmd.exe /C whoami", true)]
|
||||
[InlineData(@"cmd.exe /k", false)] // L12: /k leaves an interactive shell
|
||||
[InlineData(@"cmd.exe /d/q/c exit 9", true)] // combined switches — the field case
|
||||
[InlineData(@"cmd.exe /D/Q/K stay", false)] // L12: run-and-stay is not scripted
|
||||
[InlineData(@"cmd.exe /k rem /c", false)] // first switch /k wins — payload /c is inert
|
||||
[InlineData(@"cmd.exe /c rem /k", true)] // first switch /c wins
|
||||
[InlineData(@"""C:\WINDOWS\system32\cmd.exe"" /c ""x.bat""", true)]
|
||||
public void HasScriptArgument_detects_run_command_switch(string commandLine, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ConsoleBypassWatcher.HasScriptArgument(commandLine));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\WINDOWS\system32\cmd.exe", "")]
|
||||
[InlineData(@"cmd.exe /d/q/c exit 9", "/d/q/c exit 9")]
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The tally behind <c>console_script_activity</c>: what it counts, what it lists and caps, and
|
||||
/// that taking the content empties it.
|
||||
/// </summary>
|
||||
public sealed class ConsoleScriptActivityTallyTests
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
|
||||
private const string Cmd = "cmd.exe";
|
||||
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
|
||||
|
||||
private static string? NoName(int pid) => null;
|
||||
|
||||
[Fact]
|
||||
public void An_empty_tally_has_nothing_to_take()
|
||||
{
|
||||
Assert.Null(new ConsoleScriptActivityTally().Take());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Starts_are_counted_with_time_span_sessions_owners_and_parents()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
|
||||
sut.Add(Cmd, 101, 50, sessionId: 2, OobeAccount, At, _ => "powershell.exe");
|
||||
sut.Add(Cmd, 102, 50, sessionId: 1, OobeAccount, At.AddSeconds(1), _ => "powershell.exe");
|
||||
sut.Add(Cmd, 103, 60, sessionId: 1, "S-1-5-18", At.AddSeconds(9), _ => "setup.exe");
|
||||
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(Cmd, a.ProcessName);
|
||||
Assert.Equal(3, a.Count);
|
||||
Assert.Equal(At, a.FirstSeenUtc);
|
||||
Assert.Equal(At.AddSeconds(9), a.LastSeenUtc);
|
||||
Assert.Equal(new[] { 1, 2 }, a.WindowsSessions);
|
||||
Assert.Equal(new[] { OobeAccount, "S-1-5-18" }, a.Owners);
|
||||
Assert.Equal(2, a.OwnersTotal);
|
||||
Assert.Equal(2, a.ParentsTotal);
|
||||
Assert.Equal(new[] { 50, 60 }, a.Parents.Select(p => p.ProcessId));
|
||||
Assert.Equal(new[] { 2, 1 }, a.Parents.Select(p => p.Children));
|
||||
Assert.Equal(new[] { "powershell.exe", "setup.exe" }, a.Parents.Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Taking_empties_the_tally_and_the_next_start_opens_it_again()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
|
||||
Assert.True(sut.Add(Cmd, 101, 50, 1, OobeAccount, At, NoName));
|
||||
Assert.False(sut.Add(Cmd, 102, 50, 1, OobeAccount, At, NoName));
|
||||
Assert.Equal(2, sut.Take()!.Count);
|
||||
|
||||
Assert.Null(sut.Take());
|
||||
Assert.True(sut.Add(Cmd, 103, 50, 1, OobeAccount, At, NoName));
|
||||
Assert.Equal(1, sut.Take()!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_parent_is_looked_up_once_even_when_the_lookup_finds_nothing()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
var asked = new List<int>();
|
||||
string? Lookup(int pid) { asked.Add(pid); return null; }
|
||||
|
||||
for (var i = 0; i < 5; i++) sut.Add(Cmd, 100 + i, 50, 1, OobeAccount, At, Lookup);
|
||||
|
||||
Assert.Equal(new[] { 50 }, asked);
|
||||
var parent = Assert.Single(sut.Take()!.Parents);
|
||||
Assert.Null(parent.Name);
|
||||
Assert.Equal(5, parent.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_parent_that_was_counted_itself_is_named_without_a_lookup()
|
||||
{
|
||||
// A script shell that is already gone, and the children it started.
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
var asked = new List<int>();
|
||||
string? Lookup(int pid) { asked.Add(pid); return "AgentExecutor.exe"; }
|
||||
|
||||
sut.Add(Cmd, 100, 50, 2, OobeAccount, At, Lookup);
|
||||
for (var i = 1; i <= 6; i++) sut.Add(Cmd, 100 + i, 100, 2, OobeAccount, At, Lookup);
|
||||
|
||||
Assert.Equal(new[] { 50 }, asked);
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(7, a.Count);
|
||||
Assert.Equal(new[] { 100, 50 }, a.Parents.Select(p => p.ProcessId));
|
||||
Assert.Equal(new[] { Cmd, "AgentExecutor.exe" }, a.Parents.Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lookups_are_limited_per_window_and_allowed_again_after_taking()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
var lookups = 0;
|
||||
string? Lookup(int pid) { lookups++; return "p" + pid + ".exe"; }
|
||||
|
||||
for (var i = 0; i < ConsoleScriptActivityTally.MaxParentLookups + 3; i++)
|
||||
sut.Add(Cmd, 1000 + i, 10 + i, 1, OobeAccount, At, Lookup);
|
||||
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups, lookups);
|
||||
sut.Take();
|
||||
|
||||
sut.Add(Cmd, 2000, 99, 1, OobeAccount, At, Lookup);
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxParentLookups + 1, lookups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lists_are_capped_and_the_totals_tell_how_much_there_was()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
const int parents = ConsoleScriptActivityTally.MaxParentsListed + 3;
|
||||
const int owners = ConsoleScriptActivityTally.MaxOwnersListed + 2;
|
||||
|
||||
for (var p = 0; p < parents; p++)
|
||||
{
|
||||
// Parent p gets p+1 children, so the busiest parents are the last ones added.
|
||||
for (var c = 0; c <= p; c++)
|
||||
sut.Add(Cmd, 10_000 + p * 100 + c, 500 + p, 1, "S-1-5-21-9-9-9-" + (p % owners), At, NoName);
|
||||
}
|
||||
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(parents, a.ParentsTotal);
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxParentsListed, a.Parents.Count);
|
||||
Assert.Equal(500 + parents - 1, a.Parents[0].ProcessId);
|
||||
Assert.Equal(parents, a.Parents[0].Children);
|
||||
Assert.Equal(owners, a.OwnersTotal);
|
||||
Assert.Equal(ConsoleScriptActivityTally.MaxOwnersListed, a.Owners.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_start_without_a_parent_id_or_owner_still_counts()
|
||||
{
|
||||
var sut = new ConsoleScriptActivityTally();
|
||||
|
||||
sut.Add(Cmd, 101, 0, 1, null, At, _ => throw new InvalidOperationException("no parent to look up"));
|
||||
|
||||
var a = sut.Take()!;
|
||||
Assert.Equal(1, a.Count);
|
||||
Assert.Empty(a.Parents);
|
||||
Assert.Equal(0, a.ParentsTotal);
|
||||
Assert.Empty(a.Owners);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -80,6 +80,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_session_gone_report_is_exempt_from_the_cooldown_like_the_telemetry_one()
|
||||
{
|
||||
// The integrity report goes out post-config, seconds before the registration is refused.
|
||||
var clock = Clock();
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client, clock);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.IntegrityCheckFailed, "m");
|
||||
clock.Advance(TimeSpan.FromSeconds(5));
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
|
||||
Assert.Equal(2, client.Calls);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, client.Reports[1].ErrorType);
|
||||
Assert.Equal(410, client.Reports[1].HttpStatusCode);
|
||||
|
||||
// Key dedup still applies: a second one is held back.
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
|
||||
{
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// ConsoleBypassHost — what goes onto the single rail: a console the watcher reports becomes a
|
||||
/// Warning <c>oobe_console_spawned</c>; starts the watcher only counts become one Info
|
||||
/// <c>console_script_activity</c> per window, capped per run, and whatever is still counted goes
|
||||
/// out on every way the host stops. The watcher is driven through its internal entry points with
|
||||
/// injected probes; no WMI subscription and no window timer runs here.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // one test runs a real SignalIngress worker
|
||||
public sealed class ConsoleBypassHostTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
|
||||
private const string Cmd = "cmd.exe";
|
||||
private const string BareCmd = @"C:\WINDOWS\system32\cmd.exe";
|
||||
private const string OobeAccount = "S-1-5-21-1-2-3-1000";
|
||||
private const int ScriptHost = 2688;
|
||||
private const int GoneLauncher = 9016;
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly AgentLogger _logger;
|
||||
private string? _runningCommandLine; // null = every probed process has exited
|
||||
private DateTime _now = At;
|
||||
|
||||
public ConsoleBypassHostTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ConsoleBypassWatcher NewWatcher() => new ConsoleBypassWatcher(
|
||||
_logger,
|
||||
processProbe: (pid, name) => _runningCommandLine == null
|
||||
? null
|
||||
: new ProcessProbe(name, 1, 0, _runningCommandLine, "20261111144135.000000+060"),
|
||||
isAlive: _ => true,
|
||||
parentName: pid => pid == ScriptHost ? "powershell.exe" : null,
|
||||
utcNow: () => _now);
|
||||
|
||||
private ConsoleBypassHost Build(ConsoleBypassWatcher watcher, ISignalIngressSink? ingress = null) =>
|
||||
new ConsoleBypassHost("sess-console", "tenant-console", ingress ?? _sink, new VirtualClock(At), _logger, watcher);
|
||||
|
||||
private static void Trace(ConsoleBypassWatcher watcher, int pid, int parentPid = ScriptHost) =>
|
||||
watcher.HandleStart(pid, parentPid, 1, OobeAccount, "process_start_trace", Cmd);
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Events(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
// ---------------------------------------------------------------- consoles
|
||||
|
||||
[Fact]
|
||||
public void A_console_is_a_warning_with_immediate_upload_and_the_name_of_its_parent()
|
||||
{
|
||||
_runningCommandLine = BareCmd;
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 9636);
|
||||
|
||||
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
var data = Data(s);
|
||||
Assert.Equal("high", data["confidence"]);
|
||||
Assert.Equal("powershell.exe", data["parentProcessName"]);
|
||||
Assert.Equal(ScriptHost, data["parentProcessId"]);
|
||||
Assert.Equal(OobeAccount, data["owner"]);
|
||||
Assert.Equal(BareCmd, data["commandLine"]);
|
||||
Assert.Empty(Events(Constants.EventTypes.ConsoleScriptActivity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_low_confidence_console_is_uploaded_at_once_too_and_names_a_gone_parent_unknown()
|
||||
{
|
||||
// The confidence follows the command line, which whoever opens the console chooses:
|
||||
// it labels the warning, it never delays it.
|
||||
_runningCommandLine = "cmd.exe /k whoami";
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 9636, parentPid: GoneLauncher);
|
||||
|
||||
var s = Assert.Single(Events(Constants.EventTypes.OobeConsoleSpawned));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("low", Data(s)["confidence"]);
|
||||
Assert.Equal("unknown", Data(s)["parentProcessName"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- script activity
|
||||
|
||||
[Fact]
|
||||
public void Counted_starts_become_one_info_line_when_the_window_ends()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 5001);
|
||||
_now = At.AddSeconds(1);
|
||||
Trace(watcher, pid: 5002);
|
||||
_now = At.AddSeconds(2);
|
||||
Trace(watcher, pid: 5003);
|
||||
Assert.Empty(_sink.Posted);
|
||||
|
||||
host.WindowElapsedForTest();
|
||||
|
||||
Assert.Empty(Events(Constants.EventTypes.OobeConsoleSpawned));
|
||||
var s = Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal(At.AddSeconds(2), s.OccurredAtUtc);
|
||||
Assert.Equal(
|
||||
"Script activity: 3 short-lived cmd.exe processes in session 1, started by powershell.exe — counted, not an interactive console",
|
||||
s.Payload![SignalPayloadKeys.Message]);
|
||||
|
||||
var data = Data(s);
|
||||
Assert.Equal(3, data["count"]);
|
||||
Assert.Equal(Cmd, data["processName"]);
|
||||
Assert.Equal(At.ToString("o"), data["firstSeenUtc"]);
|
||||
Assert.Equal(At.AddSeconds(2).ToString("o"), data["lastSeenUtc"]);
|
||||
Assert.Equal(new[] { 1 }, (List<int>)data["windowsSessions"]);
|
||||
Assert.Equal(new[] { OobeAccount }, (List<string>)data["owners"]);
|
||||
Assert.Equal(1, data["ownersTotal"]);
|
||||
Assert.Equal(1, data["parentsTotal"]);
|
||||
var parent = Assert.Single((List<Dictionary<string, object>>)data["parents"]);
|
||||
Assert.Equal(ScriptHost, parent["pid"]);
|
||||
Assert.Equal("powershell.exe", parent["name"]);
|
||||
Assert.Equal(3, parent["children"]);
|
||||
Assert.False(data.ContainsKey("capped"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_counted_starts_reports_nothing()
|
||||
{
|
||||
using var host = Build(NewWatcher());
|
||||
|
||||
host.WindowElapsedForTest();
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_window_reports_only_its_own_starts()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
using var host = Build(watcher);
|
||||
|
||||
Trace(watcher, pid: 5001);
|
||||
host.WindowElapsedForTest();
|
||||
Trace(watcher, pid: 5002);
|
||||
Trace(watcher, pid: 5003);
|
||||
host.WindowElapsedForTest();
|
||||
|
||||
Assert.Equal(new object[] { 1, 2 }, Events(Constants.EventTypes.ConsoleScriptActivity).Select(s => Data(s)["count"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_cap_no_window_reports_and_the_line_at_stop_says_so()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
|
||||
for (var i = 0; i < ConsoleBypassHost.MaxScriptActivitySummaries; i++)
|
||||
{
|
||||
Trace(watcher, pid: 5000 + i);
|
||||
host.WindowElapsedForTest();
|
||||
}
|
||||
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
|
||||
|
||||
Trace(watcher, pid: 7001);
|
||||
Trace(watcher, pid: 7002);
|
||||
host.WindowElapsedForTest();
|
||||
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries, Events(Constants.EventTypes.ConsoleScriptActivity).Count);
|
||||
|
||||
host.Stop();
|
||||
|
||||
var all = Events(Constants.EventTypes.ConsoleScriptActivity);
|
||||
Assert.Equal(ConsoleBypassHost.MaxScriptActivitySummaries + 1, all.Count);
|
||||
Assert.All(all.Take(ConsoleBypassHost.MaxScriptActivitySummaries), s => Assert.False(Data(s).ContainsKey("capped")));
|
||||
var last = Data(all[all.Count - 1]);
|
||||
Assert.Equal(2, last["count"]);
|
||||
Assert.Equal(true, last["capped"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- stopping
|
||||
|
||||
[Fact]
|
||||
public void Stopping_reports_what_was_counted_exactly_once()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
Trace(watcher, pid: 5001);
|
||||
Trace(watcher, pid: 5002);
|
||||
|
||||
host.Stop();
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
Assert.Equal(2, Data(Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity)))["count"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Desktop_arrival_reports_what_was_counted()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
Trace(watcher, pid: 5001);
|
||||
|
||||
host.StopForDesktopArrival();
|
||||
|
||||
Assert.Single(Events(Constants.EventTypes.ConsoleScriptActivity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_stop_signal_on_the_decision_rail_reports_what_was_counted()
|
||||
{
|
||||
var clock = new VirtualClock(At);
|
||||
using var ingress = new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")),
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: new FakeDecisionStepProcessor(),
|
||||
clock: clock);
|
||||
var postedEventTypes = new List<string>();
|
||||
ingress.SignalPosted += (kind, payload) =>
|
||||
{
|
||||
if (kind == DecisionSignalKind.InformationalEvent && payload != null
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var et))
|
||||
{
|
||||
lock (postedEventTypes) postedEventTypes.Add(et);
|
||||
}
|
||||
};
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher, ingress);
|
||||
ingress.Start();
|
||||
host.StartCore(armWatcher: false);
|
||||
Trace(watcher, pid: 5001);
|
||||
|
||||
ingress.Post(
|
||||
DecisionSignalKind.DesktopArrived, At, "DesktopArrivalDetector",
|
||||
new Evidence(EvidenceKind.Raw, "desktop", "evidence-desktop"));
|
||||
|
||||
Assert.True(SpinWait.SpinUntil(() => { lock (postedEventTypes) return postedEventTypes.Contains(Constants.EventTypes.ConsoleScriptActivity); }, 5000),
|
||||
"the host stopped on the rail signal without reporting what was counted");
|
||||
|
||||
Trace(watcher, pid: 5002); // the watcher is disposed: nothing is counted any more
|
||||
Assert.Null(watcher.TakeScriptActivity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_ingress_does_not_break_the_stop()
|
||||
{
|
||||
var watcher = NewWatcher();
|
||||
var host = Build(watcher);
|
||||
Trace(watcher, pid: 5001);
|
||||
_sink.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
|
||||
|
||||
Assert.Null(Record.Exception(() => host.Dispose()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timeline text
|
||||
|
||||
private static ConsoleScriptActivity Activity(int count, int[] sessions, int parentsTotal, params (int pid, string? name, int children)[] parents) =>
|
||||
new ConsoleScriptActivity(
|
||||
Cmd, count, At, At, sessions,
|
||||
new[] { OobeAccount }, 1,
|
||||
parents.Select(p => new ConsoleScriptParent(p.pid, p.name, p.children)).ToList(), parentsTotal);
|
||||
|
||||
[Fact]
|
||||
public void One_process_from_a_parent_that_is_gone()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 1 short-lived cmd.exe process in session 1, started by an unknown program — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(1, new[] { 1 }, 1, (6688, null, 1))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Several_named_parents_in_two_sessions()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 46 short-lived cmd.exe processes in sessions 1, 2, started by 2 parent processes (powershell.exe, cmd.exe) — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(46, new[] { 1, 2 }, 2, (2688, "powershell.exe", 25), (9296, "cmd.exe", 21))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void More_parents_than_names_end_in_an_ellipsis()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 9 short-lived cmd.exe processes in session 2, started by 4 parent processes (python.exe, …) — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(9, new[] { 2 }, 4, (10, "python.exe", 5), (11, null, 4))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parents_without_any_name_are_only_counted()
|
||||
{
|
||||
Assert.Equal(
|
||||
"Script activity: 3 short-lived cmd.exe processes in session 1, started by 3 parent processes — counted, not an interactive console",
|
||||
ConsoleBypassHost.DescribeScriptActivity(Activity(3, new[] { 1 }, 3, (10, null, 1), (11, null, 1), (12, null, 1))));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -43,6 +43,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
[InlineData("network_connectivity_check")]
|
||||
// Bookkeeping of health-script runs that were deliberately not reported.
|
||||
[InlineData("script_recurrence_summary")]
|
||||
// Counted short-lived console processes — a spawning script is not enrollment progress.
|
||||
[InlineData("console_script_activity")]
|
||||
// Agent health / control / transport — not device/enrollment progress (P2).
|
||||
[InlineData("collector_degraded")]
|
||||
[InlineData("telemetry_upload_poisoned")]
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// A registration the backend answered with HTTP 410 at process start: the session was deleted.
|
||||
/// Exactly one <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> report, then the
|
||||
/// start-time end of monitoring with a forced cleanup — the same end state as the emergency
|
||||
/// break and the kill signal. The WhiteGlove Part 2 start that registers a new session instead
|
||||
/// is covered by <c>SessionRegistrationHelperTests</c>; here only its trace on <c>agent_started</c>.
|
||||
/// </summary>
|
||||
public sealed class SessionGoneAtRegistrationTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly SessionIdPersistence _persistence;
|
||||
private readonly string _stateDir;
|
||||
private readonly List<string> _sequence = new List<string>();
|
||||
|
||||
public SessionGoneAtRegistrationTests()
|
||||
{
|
||||
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
|
||||
_persistence = new SessionIdPersistence(_tmp.Path);
|
||||
_persistence.GetOrCreate(_logger);
|
||||
_stateDir = Path.Combine(_tmp.Path, "State");
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private sealed class RecordingEmergencyReporter : EmergencyReporter
|
||||
{
|
||||
private readonly List<string> _sequence;
|
||||
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode, int Attempts)> Reports { get; } =
|
||||
new List<(AgentErrorType, string, int?, int)>();
|
||||
|
||||
public RecordingEmergencyReporter(List<string> sequence)
|
||||
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
|
||||
{
|
||||
_sequence = sequence;
|
||||
}
|
||||
|
||||
public override Task TrySendAsync(
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null,
|
||||
long? sequenceNumber = null,
|
||||
int attempts = 1,
|
||||
TimeSpan? perAttemptTimeout = null,
|
||||
TimeSpan? retryDelay = null,
|
||||
double? sessionAgeHours = null,
|
||||
RegistrationFailureSummary? priorRegistrationFailure = null)
|
||||
{
|
||||
Reports.Add((errorType, message, httpStatusCode, attempts));
|
||||
_sequence.Add("report");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private CleanupService CleanupFactory()
|
||||
{
|
||||
_sequence.Add("cleanup");
|
||||
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
|
||||
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
|
||||
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
|
||||
}
|
||||
|
||||
private bool MarkerExists => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // forced like the kill signal: SelfDestructOnComplete does not gate it
|
||||
public void Reports_once_then_ends_monitoring_with_a_forced_cleanup(bool selfDestructOnComplete)
|
||||
{
|
||||
_persistence.SaveWhiteGloveComplete(_logger);
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", SelfDestructOnComplete = selfDestructOnComplete };
|
||||
var reporter = new RecordingEmergencyReporter(_sequence);
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, reporter, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
// The report leaves before the cleanup deletes the agent.
|
||||
Assert.Equal(new[] { "report", "cleanup" }, _sequence);
|
||||
|
||||
var report = Assert.Single(reporter.Reports);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, report.ErrorType);
|
||||
Assert.Equal(410, report.HttpStatusCode);
|
||||
Assert.Contains("410", report.Message);
|
||||
Assert.True(report.Attempts > 1, "the process ends right after: the one report gets a retry");
|
||||
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
Assert.False(_persistence.IsWhiteGloveResume());
|
||||
Assert.Null(_persistence.LoadSessionCreatedAt());
|
||||
// The next start ends at the bootstrap guard, exactly as after the emergency break.
|
||||
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
|
||||
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ends_monitoring_without_a_reporter()
|
||||
{
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1" };
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, emergencyReporter: null!, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(new[] { "cleanup" }, _sequence);
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionRotationReasons.SessionGone)]
|
||||
[InlineData(SessionRotationReasons.OwnerMismatch)]
|
||||
public void Agent_started_names_why_the_session_was_rotated(string reason)
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: "11111111-1111-1111-1111-111111111111", reason);
|
||||
|
||||
Assert.Equal(true, data["sessionRotated"]);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", data["previousSessionId"]);
|
||||
Assert.Equal(reason, data["sessionRotationReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Agent_started_carries_no_rotation_reason_on_the_normal_path()
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: null, sessionRotationReason: null);
|
||||
|
||||
Assert.Equal(false, data["sessionRotated"]);
|
||||
Assert.False(data.ContainsKey("sessionRotationReason"));
|
||||
}
|
||||
|
||||
private Dictionary<string, object> EmitAgentStarted(string? rotatedFromSessionId, string? sessionRotationReason)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
new InformationalEventPost(sink, SystemClock.Instance, _logger),
|
||||
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
|
||||
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
|
||||
"2.0.test",
|
||||
remoteConfigService: null,
|
||||
_logger,
|
||||
rotatedFromSessionId: rotatedFromSessionId,
|
||||
sessionRotationReason: sessionRotationReason);
|
||||
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -166,6 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.OwnerMismatch, result.RotationReason);
|
||||
}
|
||||
|
||||
private static BackendRefusalException TenantConflict()
|
||||
@@ -313,6 +314,136 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
// ── 410 at registration: the backend deleted the session ────────────
|
||||
|
||||
private static BackendSessionGoneException SessionGone()
|
||||
=> new BackendSessionGoneException("Backend returned 410 Gone: the session was deleted or is being deleted.", 410);
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_ends_the_run_at_once_without_retry_rotation_or_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
// The rotation hook is wired on every start; without the Part 2 flag it must stay unused.
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; return Guid.NewGuid().ToString(); });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(410, result.HttpStatusCode);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(0, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
Assert.Null(result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_rotates_once_and_registers_the_new_session()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var originalSessionId = config.SessionId;
|
||||
var rotatedTo = Guid.NewGuid().ToString();
|
||||
var seenSessionIds = new System.Collections.Generic.List<string>();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(
|
||||
attempt => attempt == 1
|
||||
? throw SessionGone()
|
||||
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
|
||||
onRegister: r => seenSessionIds.Add(r.SessionId));
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.SessionGone, result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_gone_after_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_with_a_failing_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => throw new IOException("disk full"),
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_without_rotation_hook_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_after_six_exceptions_without_auth_error()
|
||||
{
|
||||
|
||||
+31
@@ -138,6 +138,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
|
||||
}
|
||||
|
||||
// ── 410: session gone, only when the backend said so ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_410_on_registration_is_session_gone()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"{\"success\":false,\"message\":\"Session is being deleted by an administrator (state=Tombstoned); registration is rejected.\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendSessionGoneException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
|
||||
Assert.Equal(410, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_410_without_the_backends_echo_stays_a_plain_http_error()
|
||||
{
|
||||
// Answered on the network path: the caller would end monitoring on a session-gone
|
||||
// signal, so it must stay an ordinary, retried failure.
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"<html><head><title>Gone</title></head><body>Gone</body></html>",
|
||||
"text/html",
|
||||
fromBackend: false);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
|
||||
+2
-33
@@ -3,8 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
@@ -130,7 +129,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { pid = process.Id; } catch { }
|
||||
try { session = process.SessionId; } catch { }
|
||||
try { start = process.StartTime.ToUniversalTime(); } catch { }
|
||||
try { path = QueryImagePath(pid); } catch { }
|
||||
try { path = ProcessImagePath.Query(pid); } catch { }
|
||||
if (path == null)
|
||||
{
|
||||
try { path = process.MainModule?.FileName; } catch { }
|
||||
@@ -138,35 +137,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
return new ImeProcessCandidate(pid, session, path, start);
|
||||
}
|
||||
|
||||
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
|
||||
|
||||
private static string QueryImagePath(int pid)
|
||||
{
|
||||
if (pid <= 0) return null;
|
||||
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
if (handle == IntPtr.Zero) return null;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder(1024);
|
||||
int size = sb.Capacity;
|
||||
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -80,5 +80,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|
||||
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The domain part of a sign-in address, or "unknown" when there is none. Telemetry
|
||||
/// carries this instead of the address: the part before the last '@' stays on the device.
|
||||
/// </summary>
|
||||
internal static string ExtractUserDomain(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return "unknown";
|
||||
var at = email.LastIndexOf('@');
|
||||
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Full image path of another process through <c>QueryFullProcessImageName</c>. It needs only
|
||||
/// <c>PROCESS_QUERY_LIMITED_INFORMATION</c> and resolves across bitness, where
|
||||
/// <c>Process.MainModule</c> throws.
|
||||
/// </summary>
|
||||
internal static class ProcessImagePath
|
||||
{
|
||||
private const int PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder exeName, ref int size);
|
||||
|
||||
/// <summary>The path, or <c>null</c> when the process cannot be opened or queried.</summary>
|
||||
public static string Query(int pid)
|
||||
{
|
||||
if (pid <= 0) return null;
|
||||
var handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
if (handle == IntPtr.Zero) return null;
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder(1024);
|
||||
int size = sb.Capacity;
|
||||
return QueryFullProcessImageName(handle, 0, sb, ref size) && size > 0 ? sb.ToString(0, size) : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Management;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
@@ -121,5 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner SID of a <c>Win32_Process</c> row via <c>GetOwnerSid</c>, or <c>null</c> on failure.
|
||||
/// The row must have been selected with <c>Handle</c>: without its key property WMI cannot
|
||||
/// invoke a method on the object and throws.
|
||||
/// </summary>
|
||||
public static string OwnerSid(ManagementObject process)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var result = process.InvokeMethod("GetOwnerSid", null, null))
|
||||
{
|
||||
if (result != null && Convert.ToUInt32(result["ReturnValue"]) == 0)
|
||||
return result["Sid"]?.ToString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process gone or owner not readable.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -31,7 +32,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// This analyzer reports facts ONLY, always at <see cref="EventSeverity.Info"/>. It never reads
|
||||
/// or emits the <c>DefaultPassword</c> value — only its presence. Whether an AutoLogon is a
|
||||
/// problem is judged by backend analyze-rules (ANALYZE-SEC-003 = plaintext DefaultPassword on
|
||||
/// disk → escalated; ANALYZE-SEC-004 = optional kiosk allow-list template).
|
||||
/// disk → escalated).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Trigger</b>: NOT at agent start — the Winlogon keys are written later by a provisioning
|
||||
@@ -218,9 +219,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "force_auto_logon", snap.ForceAutoLogon ?? string.Empty },
|
||||
{ "force_auto_logon_enabled", forceAutoLogonEnabled },
|
||||
{ "autologon_enabled", autologonEnabled },
|
||||
{ "default_user_name", snap.DefaultUserName ?? string.Empty },
|
||||
{ "default_user_name", ReduceUserName(snap.DefaultUserName) },
|
||||
{ "default_domain_name", snap.DefaultDomainName ?? string.Empty },
|
||||
{ "alt_default_user_name", snap.AltDefaultUserName ?? string.Empty },
|
||||
{ "alt_default_user_name", ReduceUserName(snap.AltDefaultUserName) },
|
||||
{ "alt_default_domain_name", snap.AltDefaultDomainName ?? string.Empty },
|
||||
{ "auto_logon_sid", snap.AutoLogonSid ?? string.Empty },
|
||||
{ "auto_logon_count", snap.AutoLogonCount.HasValue ? (object)snap.AutoLogonCount.Value : string.Empty },
|
||||
@@ -244,6 +245,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
// Small fail-soft helpers.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A sign-in address is reported as its domain only. Local account names (kiosk,
|
||||
/// defaultuser0) and the provisioning placeholders stay readable.
|
||||
/// </summary>
|
||||
internal static string ReduceUserName(string? userName)
|
||||
{
|
||||
if (userName is not string name || name.Length == 0) return string.Empty;
|
||||
if (name.IndexOf('@') < 0 || AadJoinInfo.IsPlaceholderUserEmail(name)) return name;
|
||||
return "[redacted]@" + AadJoinInfo.ExtractUserDomain(name);
|
||||
}
|
||||
|
||||
/// <summary>AutoLogon flags are REG_SZ "0"/"1"; treat "1"/"true" (any case) as enabled.</summary>
|
||||
private static bool IsTruthy(string? value)
|
||||
{
|
||||
|
||||
+47
-33
@@ -640,53 +640,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
Dictionary<string, object> data;
|
||||
|
||||
// Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
|
||||
// are transient provisioning accounts and must NOT be treated as a real AAD join;
|
||||
// HasAadJoinedUser is only set for real user emails.
|
||||
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
|
||||
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)
|
||||
&& !string.IsNullOrEmpty(thumbprint))
|
||||
{
|
||||
// Still need TenantId — open the subkey directly when we have a thumbprint.
|
||||
if (!string.IsNullOrEmpty(thumbprint))
|
||||
// Still need TenantId — open the subkey directly.
|
||||
string tenantId;
|
||||
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
|
||||
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
|
||||
{
|
||||
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
|
||||
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
|
||||
{
|
||||
data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
|
||||
}
|
||||
|
||||
data["userEmail"] = userEmail;
|
||||
data["joinType"] = "Azure AD Joined";
|
||||
data["thumbprint"] = thumbprint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
if (isPlaceholderUser)
|
||||
{
|
||||
IsFooUserDetected = true;
|
||||
data["isFooUser"] = true;
|
||||
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
|
||||
}
|
||||
else
|
||||
{
|
||||
HasAadJoinedUser = true;
|
||||
}
|
||||
}
|
||||
tenantId = subKey?.GetValue("TenantId")?.ToString();
|
||||
}
|
||||
else
|
||||
|
||||
data = BuildAadJoinedData(tenantId, userEmail, thumbprint, isPlaceholderUser);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
data["joinType"] = "Not Joined";
|
||||
if (isPlaceholderUser)
|
||||
{
|
||||
IsFooUserDetected = true;
|
||||
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
|
||||
}
|
||||
else
|
||||
{
|
||||
HasAadJoinedUser = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
data["joinType"] = "Not Joined";
|
||||
data = new Dictionary<string, object> { ["joinType"] = "Not Joined" };
|
||||
}
|
||||
|
||||
object joinTypeValue;
|
||||
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {joinType}", data);
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AadJoinStatus, $"AAD join: {data["joinType"]}", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -694,6 +684,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>aad_join_status</c> payload of a joined device. The signed-in address is
|
||||
/// reported as its domain only.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, object> BuildAadJoinedData(
|
||||
string tenantId, string userEmail, string thumbprint, bool isPlaceholderUser)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["tenantId"] = tenantId,
|
||||
["joinType"] = "Azure AD Joined",
|
||||
["thumbprint"] = thumbprint,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
data["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail);
|
||||
if (isPlaceholderUser)
|
||||
data["isFooUser"] = true;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads ESP skip configuration from the enrollment FirstSync registry key via the
|
||||
/// shared <see cref="EspSkipConfigurationProbe"/> (also used by
|
||||
|
||||
+27
-6
@@ -166,12 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
var pidObj = e.NewEvent?["ProcessID"];
|
||||
if (pidObj == null) return;
|
||||
var pid = Convert.ToInt32(pidObj);
|
||||
|
||||
Process? proc = null;
|
||||
try { proc = Process.GetProcessById(pid); }
|
||||
catch { /* already gone — ignore */ }
|
||||
if (proc != null) TrackWorker(proc);
|
||||
TrackTracedProcess(Convert.ToInt32(pidObj), WorkerProcessNameNoExt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -179,6 +174,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the process a start trace announced (internal for tests). WMI delivers the trace
|
||||
/// with a delay, so the id may already belong to another process: only a process that still
|
||||
/// carries the worker's name is tracked. A foreign process would hold the install window
|
||||
/// open until it exits.
|
||||
/// </summary>
|
||||
internal void TrackTracedProcess(int pid, string processNameNoExt)
|
||||
{
|
||||
Process? proc = null;
|
||||
try { proc = Process.GetProcessById(pid); }
|
||||
catch { /* already gone — ignore */ }
|
||||
if (proc == null) return;
|
||||
|
||||
bool isWorker;
|
||||
try { isWorker = string.Equals(proc.ProcessName, processNameNoExt, StringComparison.OrdinalIgnoreCase); }
|
||||
catch { isWorker = false; /* exited between the lookup and the name read */ }
|
||||
|
||||
if (isWorker)
|
||||
{
|
||||
TrackWorker(proc);
|
||||
return;
|
||||
}
|
||||
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void TrackWorker(Process proc)
|
||||
{
|
||||
bool tracked = false;
|
||||
|
||||
+278
-87
@@ -1,10 +1,14 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Management;
|
||||
using System.Security.Principal;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
{
|
||||
@@ -26,10 +30,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Instant-close fallback.</b> Reading the command line still races if the process exits before
|
||||
/// the probe. In that case (<c>SessionID ≠ 0</c> but command line unreadable) the console is still
|
||||
/// surfaced, tagged <c>confidence:"low"</c> — better than missing it. A truly race-free capture of
|
||||
/// the command line would require the ETW Kernel-Process provider (deferred).
|
||||
/// <b>A process id is not an identity.</b> WMI delivers the start trace in batches about once a
|
||||
/// second, so the probe runs up to a second after the start. By then a scripted <c>cmd /c</c> has
|
||||
/// usually exited, and Windows may have given its id to another process. A start whose process is
|
||||
/// gone, or whose id now belongs to a different process (other image name, session 0, other
|
||||
/// parent), is not a console someone types into: it is counted in
|
||||
/// <see cref="ConsoleScriptActivityTally"/> and reported as one summary, never as a console.
|
||||
/// The comparison uses no clock on purpose — trace time and process start time need not follow
|
||||
/// the same clock corrections during OOBE, and a wrong tolerance would count real consoles away.
|
||||
/// Counting needs certainty: the process list no longer has the id, or a fact that was read
|
||||
/// contradicts the trace. A query that fails, or a fact that cannot be read, proves nothing —
|
||||
/// such a process is reported at low confidence, exactly as a running console nobody could
|
||||
/// classify.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lifecycle.</b> The host stops this watcher once the real-user desktop has arrived (wired in
|
||||
@@ -69,9 +81,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<int, ProcessProbe?> _processProbe;
|
||||
private readonly Func<int, string, ProcessProbe?> _processProbe;
|
||||
private readonly Func<int, bool> _isAlive;
|
||||
private readonly Func<int, string?> _parentName;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private readonly ConsoleScriptActivityTally _tally = new ConsoleScriptActivityTally();
|
||||
private readonly object _lock = new object();
|
||||
private readonly HashSet<int> _seen = new HashSet<int>();
|
||||
private readonly HashSet<string> _seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
private ManagementEventWatcher? _startWatcher;
|
||||
private bool _started;
|
||||
@@ -79,11 +95,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
|
||||
/// <summary>
|
||||
/// Raised once per process when a watched console is classified as an interactive console
|
||||
/// (high confidence) or an unclassifiable interactive-session console (low confidence). The
|
||||
/// (high confidence) or as one that stays open or cannot be classified (low confidence). The
|
||||
/// host translates this into a Warning <c>oobe_console_spawned</c> event.
|
||||
/// </summary>
|
||||
public event EventHandler<ConsoleSpawnInfo>? BypassConsoleDetected;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a short-lived start is counted into an empty tally. The host opens its
|
||||
/// reporting window on it and collects the content with <see cref="TakeScriptActivity"/>.
|
||||
/// </summary>
|
||||
internal event EventHandler? ScriptActivityStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the WMI <c>Win32_ProcessStartTrace</c> watcher fails to arm — the LIVE detector
|
||||
/// is dead and only the one-shot startup probe ran. The host surfaces this as a
|
||||
@@ -92,12 +114,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
/// </summary>
|
||||
public event EventHandler<Exception>? WatcherArmFailed;
|
||||
|
||||
/// <param name="processProbe">Resolves the command line + owner for a live PID; returns null
|
||||
/// when the process has already exited (the instant-close race). Defaults to a WMI query.</param>
|
||||
public ConsoleBypassWatcher(AgentLogger logger, Func<int, ProcessProbe?>? processProbe = null)
|
||||
/// <param name="processProbe">Reads a live process by id; the second argument is the image
|
||||
/// name the trace announced. Returns null only when the process has certainly exited or
|
||||
/// the id certainly runs another image, and <see cref="ProcessProbe.Unreadable"/> when it
|
||||
/// could not be read. Defaults to a process-list check followed by one WMI row.</param>
|
||||
/// <param name="isAlive">False only when a process id certainly no longer runs. Defaults to
|
||||
/// the process list.</param>
|
||||
/// <param name="parentName">Image file name of a process, or null when it cannot be read.</param>
|
||||
/// <param name="utcNow">Clock for the timestamps of counted starts.</param>
|
||||
public ConsoleBypassWatcher(
|
||||
AgentLogger logger,
|
||||
Func<int, string, ProcessProbe?>? processProbe = null,
|
||||
Func<int, bool>? isAlive = null,
|
||||
Func<int, string?>? parentName = null,
|
||||
Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_processProbe = processProbe ?? DefaultProbe;
|
||||
_isAlive = isAlive ?? IsRunning;
|
||||
_parentName = parentName ?? ImageFileName;
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -114,7 +150,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
// delayed every host after this one — so they run on a background thread. Exceptions
|
||||
// are contained in StartCore; a disposal racing the background start is handled by
|
||||
// the _disposed re-checks inside.
|
||||
System.Threading.Tasks.Task.Run(() => StartCore());
|
||||
Task.Run(() => StartCore());
|
||||
}
|
||||
|
||||
private void StartCore()
|
||||
@@ -147,7 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
watcher.Start();
|
||||
|
||||
// Re-check after arming: Dispose() may have run between the lock release above and
|
||||
// Start() — it then stopped a not-yet-armed watcher and this thread armed it right
|
||||
// Start() — it then tore down a not-yet-armed watcher and this thread armed it right
|
||||
// back, leaving a WMI subscription no code path could ever reach again. If disposal
|
||||
// won that race, tear the watcher down here.
|
||||
bool disposedDuringArm;
|
||||
@@ -158,9 +194,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
if (disposedDuringArm)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,27 +216,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Startup probe — catch a console already open before the watcher armed. Win32_Process
|
||||
// exposes SessionId directly, so the same classification path is reused. Runs AFTER the
|
||||
// arm so there is no gap; overlap with the live trace is deduped by _seen.
|
||||
// 2) Startup probe — catch a console already open before the watcher armed. One query
|
||||
// carries everything the classification needs; Handle is selected because the owner
|
||||
// is a WMI method call on the row. Runs AFTER the arm so there is no gap; overlap
|
||||
// with the live trace is deduped by _seen.
|
||||
try
|
||||
{
|
||||
var nameFilter = string.Join(" OR ",
|
||||
Array.ConvertAll(WatchedProcessNames, n => $"Name = '{n}'"));
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT Name, ProcessId, ParentProcessId, SessionId FROM Win32_Process WHERE {nameFilter}");
|
||||
"SELECT Handle, Name, ProcessId, ParentProcessId, SessionId, CommandLine, CreationDate " +
|
||||
$"FROM Win32_Process WHERE {nameFilter}");
|
||||
using var results = searcher.Get();
|
||||
foreach (var mo in results)
|
||||
foreach (ManagementObject mo in results)
|
||||
{
|
||||
using (mo)
|
||||
{
|
||||
HandleStart(
|
||||
ToInt(mo["ProcessId"]),
|
||||
ToInt(mo["ParentProcessId"]),
|
||||
ToInt(mo["SessionId"]),
|
||||
ownerSidHint: null,
|
||||
detectedVia: "startup_probe",
|
||||
processName: mo["Name"]?.ToString() ?? WatchedProcessNames[0]);
|
||||
var sessionId = ToInt(mo["SessionId"]);
|
||||
var commandLine = mo["CommandLine"]?.ToString();
|
||||
// The owner costs a method call: only for a row that will be reported.
|
||||
if (Classify(sessionId, commandLine) == ConsoleVerdict.Ignore) continue;
|
||||
|
||||
var name = mo["Name"]?.ToString() ?? WatchedProcessNames[0];
|
||||
var parentPid = ToInt(mo["ParentProcessId"]);
|
||||
HandleProbed(
|
||||
ToInt(mo["ProcessId"]), parentPid, sessionId, ProcessOwnerLookup.OwnerSid(mo), "startup_probe", name,
|
||||
new ProcessProbe(name, sessionId, parentPid, commandLine, mo["CreationDate"]?.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,13 +269,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification core (internal for tests). Gates race-free on <paramref name="sessionId"/>,
|
||||
/// then probes the live process for its command line; raises <see cref="BypassConsoleDetected"/>
|
||||
/// once per PID for an interactive console (bare command line → high confidence) or an
|
||||
/// unreadable interactive-session console (instant-close → low confidence). Scripted
|
||||
/// invocations (<c>cmd /c</c>) and session-0 (service) cmd are ignored.
|
||||
/// One traced start (internal for tests). Gates race-free on <paramref name="sessionId"/>,
|
||||
/// then probes the process: a start that is gone or whose id belongs to another process is
|
||||
/// counted, everything else goes through <see cref="HandleProbed"/>.
|
||||
/// </summary>
|
||||
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSidHint, string detectedVia, string? processName = null)
|
||||
internal void HandleStart(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string? processName = null)
|
||||
{
|
||||
if (pid <= 0) return;
|
||||
lock (_lock) { if (_disposed) return; }
|
||||
@@ -245,16 +282,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
// bulk of legitimate IME/Intune install cmd (SYSTEM, session 0) without even probing.
|
||||
if (sessionId == 0) return;
|
||||
|
||||
var probe = _processProbe(pid); // null when the process already exited (instant-close)
|
||||
var verdict = Classify(sessionId, probe?.CommandLine);
|
||||
var name = processName ?? WatchedProcessNames[0];
|
||||
var probe = _processProbe(pid, name);
|
||||
if (probe == null || !IsTracedProcess(probe, name, parentPid))
|
||||
{
|
||||
CountShortLived(name, pid, parentPid, sessionId, ownerSid);
|
||||
return;
|
||||
}
|
||||
|
||||
HandleProbed(pid, parentPid, sessionId, ownerSid, detectedVia, name, probe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classification of a process that was read while it ran (internal for tests). Raises
|
||||
/// <see cref="BypassConsoleDetected"/> once per process for an interactive console (bare
|
||||
/// command line → high confidence), one that stays open after its command (<c>/k</c> → low)
|
||||
/// or a running one whose command line WMI does not return (low). A scripted <c>cmd /c</c>
|
||||
/// is ignored.
|
||||
/// </summary>
|
||||
internal void HandleProbed(int pid, int parentPid, int sessionId, string? ownerSid, string detectedVia, string processName, ProcessProbe probe)
|
||||
{
|
||||
lock (_lock) { if (_disposed) return; }
|
||||
|
||||
// A row without a command line is only a console nobody could classify while the
|
||||
// process still runs; one that ended in the meantime is a short-lived start.
|
||||
if (probe.CommandLine == null && !_isAlive(pid))
|
||||
{
|
||||
CountShortLived(processName, pid, parentPid, sessionId, ownerSid);
|
||||
return;
|
||||
}
|
||||
|
||||
var verdict = Classify(sessionId, probe.CommandLine);
|
||||
if (verdict == ConsoleVerdict.Ignore) return; // scripted invocation (cmd /c ...)
|
||||
|
||||
// Dedup only on a flagged hit, so the startup probe + live trace observing the same console
|
||||
// collapse to one detection, and an ignored start never pre-empts a later real one.
|
||||
// collapse to one detection, and an ignored start never pre-empts a later real one. The
|
||||
// creation time is part of the key: a later console may be given an id seen before.
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_seen.Add(pid)) return;
|
||||
if (!_seen.Add(pid + "|" + probe.CreationDate)) return;
|
||||
}
|
||||
|
||||
bool high = verdict == ConsoleVerdict.InteractiveConsole;
|
||||
@@ -266,15 +333,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
default: classification = "interactive_session_unclassified"; break;
|
||||
}
|
||||
var info = new ConsoleSpawnInfo(
|
||||
processName: processName ?? WatchedProcessNames[0],
|
||||
processName: processName,
|
||||
processId: pid,
|
||||
parentProcessId: parentPid,
|
||||
sessionId: sessionId,
|
||||
owner: probe?.Owner ?? ownerSidHint,
|
||||
commandLine: probe?.CommandLine,
|
||||
owner: ownerSid,
|
||||
commandLine: probe.CommandLine,
|
||||
confidence: high ? "high" : "low",
|
||||
classification: classification,
|
||||
detectedVia: detectedVia);
|
||||
detectedVia: detectedVia,
|
||||
parentProcessName: SafeParentName(parentPid));
|
||||
|
||||
_logger.Warning($"[ConsoleBypassWatcher] interactive console detected: {info.ProcessName} (PID={pid}) " +
|
||||
$"session={sessionId}, confidence={info.Confidence}, via={detectedVia} — possible Shift+F10");
|
||||
@@ -283,14 +351,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
catch (Exception ex) { _logger.Warning($"[ConsoleBypassWatcher] handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The counted short-lived starts since the last call, or null when there were none. The
|
||||
/// host calls it when its window ends and when it stops.
|
||||
/// </summary>
|
||||
internal ConsoleScriptActivity? TakeScriptActivity() => _tally.Take();
|
||||
|
||||
private void CountShortLived(string processName, int pid, int parentPid, int sessionId, string? ownerSid)
|
||||
{
|
||||
var opened = _tally.Add(processName, pid, parentPid, sessionId, ownerSid, _utcNow(), SafeParentName);
|
||||
_logger.Trace($"[ConsoleBypassWatcher] short-lived {processName} counted (PID={pid}, parent={parentPid}, session={sessionId})");
|
||||
if (!opened) return;
|
||||
|
||||
try { ScriptActivityStarted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Debug($"[ConsoleBypassWatcher] script-activity handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private string? SafeParentName(int parentPid)
|
||||
{
|
||||
if (parentPid <= 0) return null;
|
||||
try { return _parentName(parentPid); }
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
internal enum ConsoleVerdict { Ignore, InteractiveConsole, UnclassifiedInteractive, InteractiveWithCommand }
|
||||
|
||||
/// <summary>
|
||||
/// Pure classification (internal for tests): session-0 → Ignore; a scripted <c>cmd /c</c> →
|
||||
/// Ignore; <c>cmd /k</c> (runs its command, then STAYS interactive — L12) →
|
||||
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); an
|
||||
/// unreadable command line in an interactive session → UnclassifiedInteractive (low, the
|
||||
/// instant-close fallback).
|
||||
/// InteractiveWithCommand (low); a bare command line → InteractiveConsole (high); a running
|
||||
/// process without a readable command line in an interactive session →
|
||||
/// UnclassifiedInteractive (low).
|
||||
/// </summary>
|
||||
internal static ConsoleVerdict Classify(int sessionId, string? commandLine)
|
||||
{
|
||||
@@ -304,15 +395,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the FIRST <c>/c</c>-or-<c>/k</c> switch on the command line is <c>/c</c>
|
||||
/// (a scripted run-and-exit invocation, mirroring cmd.exe precedence), false for a bare
|
||||
/// interactive shell or a <c>/k</c> run-and-stay shell — even one whose payload contains a
|
||||
/// later <c>/c</c>. The executable token is stripped first so a path can never spoof a
|
||||
/// switch, and switches may be combined (e.g. <c>cmd /d/q/c exit 9</c>).</summary>
|
||||
internal static bool HasScriptArgument(string commandLine)
|
||||
/// <summary>
|
||||
/// Whether a probed process is still the one the trace announced (internal for tests). Only
|
||||
/// a fact that contradicts the trace makes it another process — another image name, the
|
||||
/// service session, another parent. A fact that could not be read contradicts nothing: the
|
||||
/// process is then reported, never counted away.
|
||||
/// </summary>
|
||||
internal static bool IsTracedProcess(ProcessProbe probe, string processName, int parentPid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commandLine)) return false;
|
||||
return FirstRunSwitch(StripExecutable(commandLine)) == RunSwitch.RunAndExit;
|
||||
if (probe.Name != null && !string.Equals(probe.Name, processName, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (probe.SessionId == 0) return false;
|
||||
return parentPid <= 0 || probe.ParentProcessId == null || probe.ParentProcessId <= 0 || probe.ParentProcessId == parentPid;
|
||||
}
|
||||
|
||||
/// <summary>Returns the command line with the leading executable token (quoted or
|
||||
@@ -330,44 +423,100 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
return space >= 0 ? s.Substring(space + 1) : string.Empty;
|
||||
}
|
||||
|
||||
private static ProcessProbe? DefaultProbe(int pid)
|
||||
// The process list first: a WMI query takes a few hundred milliseconds, and in a burst
|
||||
// almost every traced process has already exited. Queried one after the other on the
|
||||
// delivery thread they would pile up, and a real console closed during the backlog would
|
||||
// be counted as a script.
|
||||
private static ProcessProbe? DefaultProbe(int pid, string processName) =>
|
||||
Probe(pid, ProcessListState(pid, processName), ReadRow);
|
||||
|
||||
/// <summary>
|
||||
/// The probe's decision (internal for tests). Null only when the process list says so for
|
||||
/// certain: the id no longer runs, or it runs another image. Everything else comes back as
|
||||
/// a probe — with the row's facts, or <see cref="ProcessProbe.Unreadable"/> when the query
|
||||
/// failed or returned nothing. A failed query says nothing about the process, so it must
|
||||
/// never read as "exited".
|
||||
/// </summary>
|
||||
internal static ProcessProbe? Probe(int pid, ListedAs listed, Func<int, ProcessProbe?> readRow)
|
||||
{
|
||||
if (listed == ListedAs.NotRunning || listed == ListedAs.OtherImage) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {pid}");
|
||||
using var results = searcher.Get();
|
||||
foreach (ManagementObject mo in results)
|
||||
{
|
||||
using (mo)
|
||||
{
|
||||
var commandLine = mo["CommandLine"]?.ToString();
|
||||
return new ProcessProbe(commandLine, TryGetOwner(mo));
|
||||
}
|
||||
}
|
||||
return readRow(pid) ?? ProcessProbe.Unreadable;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Process gone or query failed — treated as the instant-close fallback (low confidence).
|
||||
return ProcessProbe.Unreadable; // WinMgmt can fail or hang during OOBE
|
||||
}
|
||||
}
|
||||
|
||||
private static ProcessProbe? ReadRow(int pid)
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT Name, SessionId, ParentProcessId, CommandLine, CreationDate FROM Win32_Process WHERE ProcessId = {pid}");
|
||||
using var results = searcher.Get();
|
||||
foreach (ManagementObject mo in results)
|
||||
{
|
||||
using (mo)
|
||||
{
|
||||
return new ProcessProbe(
|
||||
mo["Name"]?.ToString(),
|
||||
ToNullableInt(mo["SessionId"]),
|
||||
ToNullableInt(mo["ParentProcessId"]),
|
||||
mo["CommandLine"]?.ToString(),
|
||||
mo["CreationDate"]?.ToString());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetOwner(ManagementObject process)
|
||||
/// <summary>What the process list says about an id and the image name expected under it.</summary>
|
||||
internal enum ListedAs { NotRunning, OtherImage, Expected, Unknown }
|
||||
|
||||
/// <summary>Internal for tests.</summary>
|
||||
internal static ListedAs ProcessListState(int pid, string processName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var outParams = process.InvokeMethod("GetOwner", null, null);
|
||||
if (outParams != null && Convert.ToInt32(outParams["ReturnValue"]) == 0)
|
||||
{
|
||||
var domain = outParams["Domain"]?.ToString();
|
||||
var user = outParams["User"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(user))
|
||||
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
|
||||
}
|
||||
using var process = Process.GetProcessById(pid);
|
||||
return string.Equals(process.ProcessName, Path.GetFileNameWithoutExtension(processName), StringComparison.OrdinalIgnoreCase)
|
||||
? ListedAs.Expected
|
||||
: ListedAs.OtherImage;
|
||||
}
|
||||
catch { /* best-effort enrichment */ }
|
||||
return null;
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return ListedAs.NotRunning; // what GetProcessById throws for an id that does not run
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return ListedAs.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>False only when the id certainly does not run; when that cannot be told, true.
|
||||
/// Internal for tests.</summary>
|
||||
internal static bool IsRunning(int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(pid);
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ImageFileName(int pid)
|
||||
{
|
||||
var path = ProcessImagePath.Query(pid);
|
||||
return string.IsNullOrEmpty(path) ? null : Path.GetFileName(path);
|
||||
}
|
||||
|
||||
private static string? TryReadSid(object? sidValue)
|
||||
@@ -388,42 +537,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static int? ToNullableInt(object? value)
|
||||
{
|
||||
if (value == null) return null;
|
||||
try { return Convert.ToInt32(value); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private void TearDownStartTrace(ManagementEventWatcher watcher)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ManagementEventWatcher? startWatcher;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
startWatcher = _startWatcher;
|
||||
_startWatcher = null;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
if (startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
_startWatcher = null;
|
||||
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
|
||||
// is the agent's stop path, so it never waits for it.
|
||||
var armed = startWatcher;
|
||||
Task.Run(() => TearDownStartTrace(armed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Command line + owner for a probed PID; either may be null (best-effort).</summary>
|
||||
/// <summary>
|
||||
/// What was read about a running process: the facts that tell whether it is still the process
|
||||
/// a start trace announced, and its command line. Every field is null when it could not be
|
||||
/// read; null never means "no" or "zero".
|
||||
/// </summary>
|
||||
public sealed class ProcessProbe
|
||||
{
|
||||
public ProcessProbe(string? commandLine, string? owner)
|
||||
/// <summary>
|
||||
/// A process that runs but about which nothing could be read (the query failed or returned
|
||||
/// no row). It goes on as the traced process without a command line, so it is reported.
|
||||
/// </summary>
|
||||
internal static readonly ProcessProbe Unreadable = new ProcessProbe(null, null, null, null, null);
|
||||
|
||||
public ProcessProbe(string? name, int? sessionId, int? parentProcessId, string? commandLine, string? creationDate)
|
||||
{
|
||||
Name = name;
|
||||
SessionId = sessionId;
|
||||
ParentProcessId = parentProcessId;
|
||||
CommandLine = commandLine;
|
||||
Owner = owner;
|
||||
CreationDate = creationDate;
|
||||
}
|
||||
|
||||
public string? Name { get; }
|
||||
public int? SessionId { get; }
|
||||
public int? ParentProcessId { get; }
|
||||
public string? CommandLine { get; }
|
||||
public string? Owner { get; }
|
||||
/// <summary>The raw WMI creation time; only compared for equality.</summary>
|
||||
public string? CreationDate { get; }
|
||||
}
|
||||
|
||||
/// <summary>Immutable detail record for a detected interactive console spawn.</summary>
|
||||
public sealed class ConsoleSpawnInfo
|
||||
{
|
||||
public ConsoleSpawnInfo(string processName, int processId, int parentProcessId, int sessionId,
|
||||
string? owner, string? commandLine, string confidence, string classification, string detectedVia)
|
||||
string? owner, string? commandLine, string confidence, string classification, string detectedVia,
|
||||
string? parentProcessName)
|
||||
{
|
||||
ProcessName = processName;
|
||||
ProcessId = processId;
|
||||
@@ -434,17 +618,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
Confidence = confidence;
|
||||
Classification = classification;
|
||||
DetectedVia = detectedVia;
|
||||
ParentProcessName = parentProcessName;
|
||||
}
|
||||
|
||||
public string ProcessName { get; }
|
||||
public int ProcessId { get; }
|
||||
public int ParentProcessId { get; }
|
||||
public int SessionId { get; }
|
||||
/// <summary>Owner SID, or null when it could not be read.</summary>
|
||||
public string? Owner { get; }
|
||||
public string? CommandLine { get; }
|
||||
/// <summary>"high" (bare interactive shell) or "low" (interactive session, command line unreadable).</summary>
|
||||
/// <summary>"high" (bare interactive shell) or "low" (stays open after a command, or not classifiable).</summary>
|
||||
public string Confidence { get; }
|
||||
public string Classification { get; }
|
||||
public string DetectedVia { get; }
|
||||
/// <summary>
|
||||
/// Image name of the parent while it still ran, else null. The launcher behind Shift+F10
|
||||
/// exits within milliseconds, so null is the expected value for that case.
|
||||
/// </summary>
|
||||
public string? ParentProcessName { get; }
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts console processes that were gone before they could be inspected — script children,
|
||||
/// reported as one summary per window instead of one event each. Thread-safe. The content
|
||||
/// leaves through <see cref="Take"/>, which hands out copies and leaves an empty tally behind,
|
||||
/// so a start is never counted in two summaries.
|
||||
/// </summary>
|
||||
internal sealed class ConsoleScriptActivityTally
|
||||
{
|
||||
internal const int MaxOwnersListed = 5;
|
||||
internal const int MaxParentsListed = 10;
|
||||
internal const int MaxParentLookups = 10;
|
||||
|
||||
// A spawner can keep running for hours after the host has stopped opening windows. The
|
||||
// count grows with it; what is remembered per start does not.
|
||||
private const int MaxTrackedParents = 256;
|
||||
private const int MaxTrackedProcessIds = 2048;
|
||||
private const int MaxTrackedOwners = 64;
|
||||
|
||||
private sealed class Parent
|
||||
{
|
||||
public string? Name;
|
||||
public int Children;
|
||||
}
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly SortedSet<int> _sessions = new SortedSet<int>();
|
||||
private readonly List<string> _owners = new List<string>();
|
||||
private readonly Dictionary<int, Parent> _parents = new Dictionary<int, Parent>();
|
||||
private readonly HashSet<int> _processIds = new HashSet<int>();
|
||||
private string _processName = string.Empty;
|
||||
private int _count;
|
||||
private int _parentLookups;
|
||||
private DateTime _firstSeenUtc;
|
||||
private DateTime _lastSeenUtc;
|
||||
|
||||
/// <summary>
|
||||
/// Counts one start. <paramref name="parentName"/> is asked at most once per parent and at
|
||||
/// most <see cref="MaxParentLookups"/> times per window, outside the lock; a parent that is
|
||||
/// itself one of the counted processes is named without asking.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when this start opened an empty tally.</returns>
|
||||
public bool Add(
|
||||
string processName, int processId, int parentProcessId, int sessionId, string? ownerSid,
|
||||
DateTime seenUtc, Func<int, string?> parentName)
|
||||
{
|
||||
bool opened;
|
||||
Parent? toResolve = null;
|
||||
lock (_lock)
|
||||
{
|
||||
opened = _count == 0;
|
||||
if (opened)
|
||||
{
|
||||
_processName = processName;
|
||||
_firstSeenUtc = seenUtc;
|
||||
}
|
||||
_count++;
|
||||
_lastSeenUtc = seenUtc;
|
||||
_sessions.Add(sessionId);
|
||||
|
||||
if (!string.IsNullOrEmpty(ownerSid) && _owners.Count < MaxTrackedOwners && !_owners.Contains(ownerSid!))
|
||||
_owners.Add(ownerSid!);
|
||||
|
||||
if (parentProcessId > 0)
|
||||
{
|
||||
if (_parents.TryGetValue(parentProcessId, out var parent))
|
||||
{
|
||||
parent.Children++;
|
||||
}
|
||||
else if (_parents.Count < MaxTrackedParents)
|
||||
{
|
||||
parent = new Parent { Children = 1 };
|
||||
_parents[parentProcessId] = parent;
|
||||
if (_processIds.Contains(parentProcessId))
|
||||
{
|
||||
parent.Name = processName;
|
||||
}
|
||||
else if (_parentLookups < MaxParentLookups)
|
||||
{
|
||||
_parentLookups++;
|
||||
toResolve = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_processIds.Count < MaxTrackedProcessIds) _processIds.Add(processId);
|
||||
}
|
||||
|
||||
if (toResolve != null)
|
||||
{
|
||||
var name = parentName(parentProcessId);
|
||||
lock (_lock) { toResolve.Name = name; }
|
||||
}
|
||||
|
||||
return opened;
|
||||
}
|
||||
|
||||
/// <summary>The counted content, or <c>null</c> when nothing was counted. Empties the tally.</summary>
|
||||
public ConsoleScriptActivity? Take()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_count == 0) return null;
|
||||
|
||||
var parents = _parents
|
||||
.Select(kv => new ConsoleScriptParent(kv.Key, kv.Value.Name, kv.Value.Children))
|
||||
.OrderByDescending(p => p.Children)
|
||||
.ThenBy(p => p.ProcessId)
|
||||
.ToList();
|
||||
|
||||
var activity = new ConsoleScriptActivity(
|
||||
_processName, _count, _firstSeenUtc, _lastSeenUtc,
|
||||
_sessions.ToList(),
|
||||
_owners.Take(MaxOwnersListed).ToList(), _owners.Count,
|
||||
parents.Take(MaxParentsListed).ToList(), parents.Count);
|
||||
|
||||
_count = 0;
|
||||
_parentLookups = 0;
|
||||
_processName = string.Empty;
|
||||
_sessions.Clear();
|
||||
_owners.Clear();
|
||||
_parents.Clear();
|
||||
_processIds.Clear();
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One window of counted console processes. Lists are capped; the totals are not.</summary>
|
||||
internal sealed class ConsoleScriptActivity
|
||||
{
|
||||
public ConsoleScriptActivity(
|
||||
string processName, int count, DateTime firstSeenUtc, DateTime lastSeenUtc,
|
||||
IReadOnlyList<int> windowsSessions,
|
||||
IReadOnlyList<string> owners, int ownersTotal,
|
||||
IReadOnlyList<ConsoleScriptParent> parents, int parentsTotal)
|
||||
{
|
||||
ProcessName = processName;
|
||||
Count = count;
|
||||
FirstSeenUtc = firstSeenUtc;
|
||||
LastSeenUtc = lastSeenUtc;
|
||||
WindowsSessions = windowsSessions;
|
||||
Owners = owners;
|
||||
OwnersTotal = ownersTotal;
|
||||
Parents = parents;
|
||||
ParentsTotal = parentsTotal;
|
||||
}
|
||||
|
||||
public string ProcessName { get; }
|
||||
public int Count { get; }
|
||||
public DateTime FirstSeenUtc { get; }
|
||||
public DateTime LastSeenUtc { get; }
|
||||
public IReadOnlyList<int> WindowsSessions { get; }
|
||||
public IReadOnlyList<string> Owners { get; }
|
||||
public int OwnersTotal { get; }
|
||||
/// <summary>Parents by number of children, most first.</summary>
|
||||
public IReadOnlyList<ConsoleScriptParent> Parents { get; }
|
||||
public int ParentsTotal { get; }
|
||||
}
|
||||
|
||||
/// <summary>A parent process of counted console processes. Only its image name is kept, never its command line.</summary>
|
||||
internal sealed class ConsoleScriptParent
|
||||
{
|
||||
public ConsoleScriptParent(int processId, string? name, int children)
|
||||
{
|
||||
ProcessId = processId;
|
||||
Name = name;
|
||||
Children = children;
|
||||
}
|
||||
|
||||
public int ProcessId { get; }
|
||||
/// <summary><c>null</c> when the parent had exited or was not looked up.</summary>
|
||||
public string? Name { get; }
|
||||
public int Children { get; }
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
await ThrowOnAuthFailureAsync(response, correlationId).ConfigureAwait(false);
|
||||
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
|
||||
ThrowOnSessionGone(response, correlationId);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -412,6 +413,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendSessionGoneException"/> for a 410 the backend answered: the
|
||||
/// session was deleted or is being deleted. Only a response that echoes this request's
|
||||
/// <c>X-Correlation-ID</c> counts — the caller ends monitoring on it, so a 410 from the
|
||||
/// network path stays an ordinary failure (<c>EnsureSuccessStatusCode</c>) and is retried.
|
||||
/// </summary>
|
||||
private static void ThrowOnSessionGone(HttpResponseMessage response, string correlationId)
|
||||
{
|
||||
if (response.StatusCode != HttpStatusCode.Gone)
|
||||
return;
|
||||
if (!ResponseOriginClassifier.EchoesCorrelationId(response, correlationId))
|
||||
return;
|
||||
|
||||
throw new BackendSessionGoneException(
|
||||
"Backend returned 410 Gone: the session was deleted or is being deleted.",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
|
||||
/// or a non-string value yields null, never an exception.
|
||||
@@ -537,4 +556,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown for a 410 Gone the backend answered: the session named in the request was deleted or
|
||||
/// is being deleted. No retry can clear it and it says nothing about the device's authorization.
|
||||
/// </summary>
|
||||
public class BackendSessionGoneException : Exception
|
||||
{
|
||||
/// <summary>HTTP status code returned by the backend (410).</summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
public BackendSessionGoneException(string message, int statusCode) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
|
||||
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
|
||||
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
|
||||
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
|
||||
/// (the terminal session-gone reports <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
/// and <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> are exempt)
|
||||
///
|
||||
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
|
||||
/// </summary>
|
||||
@@ -123,11 +124,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return;
|
||||
}
|
||||
|
||||
// The session-unknown report is terminal and sent at most once per process (the
|
||||
// A session-gone report is terminal and sent at most once per process (the
|
||||
// termination follows immediately): a routine report minutes earlier must not cost
|
||||
// the last word about the session. Only the cooldown is waived; key dedup and the
|
||||
// per-session maximum still apply.
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown
|
||||
|| errorType == AgentErrorType.RegistrationRejectedSessionGone;
|
||||
if (!exemptFromCooldown && _lastReportTime != null &&
|
||||
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -21,6 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// only sees consoles spawned after the agent started; the pre-agent OOBE window is covered
|
||||
/// (coarsely) by the ConsolePrefetchScanner. The composition root calls <see cref="Stop"/> once the
|
||||
/// real-user desktop arrives (Shift+F10 no longer possible) to avoid post-enrollment false positives.
|
||||
/// <para>
|
||||
/// Short-lived console processes (script children) are not events of their own: the watcher
|
||||
/// counts them and this host reports one Info <c>console_script_activity</c> per window. The
|
||||
/// first counted start opens a window of <see cref="ScriptActivityWindow"/>; at most
|
||||
/// <see cref="MaxScriptActivitySummaries"/> windows are reported per agent run, whatever comes
|
||||
/// after goes into the one line written when the host stops.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class ConsoleBypassHost : ICollectorHost
|
||||
{
|
||||
@@ -28,6 +36,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
"best-effort: only consoles spawned after the agent started are observable; a Shift+F10 " +
|
||||
"pressed earlier in OOBE (before agent install) is covered coarsely by console_prefetch_detected";
|
||||
|
||||
internal const int MaxScriptActivitySummaries = 20;
|
||||
private static readonly TimeSpan ScriptActivityWindow = TimeSpan.FromSeconds(60);
|
||||
|
||||
public string Name => "ConsoleBypassWatcher";
|
||||
|
||||
private readonly string _sessionId;
|
||||
@@ -36,7 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly ConsoleBypassWatcher _watcher;
|
||||
private readonly SignalIngress? _observableIngress;
|
||||
private readonly object _summaryLock = new object();
|
||||
private Action<AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _signalPostedHandler;
|
||||
private Timer? _windowTimer;
|
||||
private int _summariesEmitted;
|
||||
private int _disposed;
|
||||
|
||||
public ConsoleBypassHost(
|
||||
@@ -55,11 +69,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
_watcher = watcher ?? new ConsoleBypassWatcher(logger);
|
||||
_watcher.BypassConsoleDetected += OnBypassConsoleDetected;
|
||||
_watcher.ScriptActivityStarted += OnScriptActivityStarted;
|
||||
_watcher.WatcherArmFailed += OnWatcherArmFailed;
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public void Start() => StartCore(armWatcher: true);
|
||||
|
||||
/// <summary>Internal for tests: <paramref name="armWatcher"/> = false leaves the WMI
|
||||
/// subscription and the startup probe out; everything else is the real start.</summary>
|
||||
internal void StartCore(bool armWatcher)
|
||||
{
|
||||
// L14 fallback: the primary stop path is the DesktopArrivalDetector's real-user-owner
|
||||
// callback (StopForDesktopArrival). If the DAD misses the owner (no-candidate timeout,
|
||||
@@ -73,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_observableIngress.SignalPosted += _signalPostedHandler;
|
||||
}
|
||||
|
||||
_watcher.Start();
|
||||
if (armWatcher) _watcher.Start();
|
||||
}
|
||||
|
||||
private void OnSignalPosted(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
@@ -117,6 +136,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Interactive console spawned during enrollment: {info.ProcessName} " +
|
||||
$"(session {info.SessionId}, {info.Confidence} confidence) — possible Shift+F10",
|
||||
// At once, whatever the confidence: the confidence follows the command line,
|
||||
// and that is chosen by whoever opens the console.
|
||||
ImmediateUpload = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -124,6 +145,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{ "processName", info.ProcessName },
|
||||
{ "processId", info.ProcessId },
|
||||
{ "parentProcessId", info.ParentProcessId },
|
||||
{ "parentProcessName", info.ParentProcessName ?? "unknown" },
|
||||
{ "sessionId", info.SessionId },
|
||||
{ "owner", info.Owner ?? "unknown" },
|
||||
{ "commandLine", info.CommandLine ?? "unavailable" },
|
||||
@@ -141,6 +163,132 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScriptActivityStarted(object? sender, EventArgs e)
|
||||
{
|
||||
lock (_summaryLock)
|
||||
{
|
||||
// Checked under the lock Dispose takes for the timer, so no timer is created behind it.
|
||||
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
|
||||
// Past the cap no window opens any more; the watcher keeps counting for the stop line.
|
||||
if (_summariesEmitted >= MaxScriptActivitySummaries) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (_windowTimer == null)
|
||||
_windowTimer = new Timer(_ => WindowElapsed(), null, ScriptActivityWindow, Timeout.InfiniteTimeSpan);
|
||||
else
|
||||
_windowTimer.Change(ScriptActivityWindow, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// stopping
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Internal for tests: what the window timer runs when it elapses.</summary>
|
||||
internal void WindowElapsedForTest() => WindowElapsed();
|
||||
|
||||
private void WindowElapsed()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _disposed, 0, 0) == 1) return;
|
||||
ReportScriptActivity(stopping: false);
|
||||
}
|
||||
|
||||
private void ReportScriptActivity(bool stopping)
|
||||
{
|
||||
ConsoleScriptActivity? activity;
|
||||
bool capped;
|
||||
lock (_summaryLock)
|
||||
{
|
||||
capped = _summariesEmitted >= MaxScriptActivitySummaries;
|
||||
if (capped && !stopping) return;
|
||||
|
||||
activity = _watcher.TakeScriptActivity();
|
||||
if (activity == null) return;
|
||||
_summariesEmitted++;
|
||||
}
|
||||
|
||||
// Outside the lock: a post blocks while the ingress is full.
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "count", activity.Count },
|
||||
{ "processName", activity.ProcessName },
|
||||
{ "firstSeenUtc", activity.FirstSeenUtc.ToString("o") },
|
||||
{ "lastSeenUtc", activity.LastSeenUtc.ToString("o") },
|
||||
{ "windowsSessions", activity.WindowsSessions.ToList() },
|
||||
{ "owners", activity.Owners.ToList() },
|
||||
{ "ownersTotal", activity.OwnersTotal },
|
||||
{
|
||||
"parents", activity.Parents.Select(p => new Dictionary<string, object>
|
||||
{
|
||||
{ "pid", p.ProcessId },
|
||||
{ "name", p.Name ?? "unknown" },
|
||||
{ "children", p.Children },
|
||||
}).ToList()
|
||||
},
|
||||
{ "parentsTotal", activity.ParentsTotal },
|
||||
};
|
||||
if (capped) data["capped"] = true;
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.ConsoleScriptActivity,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = Name,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Timestamp = activity.LastSeenUtc,
|
||||
Message = DescribeScriptActivity(activity),
|
||||
ImmediateUpload = false,
|
||||
Data = data,
|
||||
});
|
||||
|
||||
_logger.Debug($"[ConsoleBypassHost] script activity reported: {activity.Count} short-lived {activity.ProcessName} " +
|
||||
$"from {activity.ParentsTotal} parent(s){(capped ? " (summary cap reached)" : string.Empty)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[ConsoleBypassHost] failed to emit console_script_activity: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The timeline text of one summary (internal for tests).</summary>
|
||||
internal static string DescribeScriptActivity(ConsoleScriptActivity activity)
|
||||
{
|
||||
var processes = activity.Count == 1
|
||||
? $"1 short-lived {activity.ProcessName} process"
|
||||
: $"{activity.Count} short-lived {activity.ProcessName} processes";
|
||||
|
||||
var sessions = activity.WindowsSessions.Count == 1
|
||||
? $"session {activity.WindowsSessions[0]}"
|
||||
: $"sessions {string.Join(", ", activity.WindowsSessions)}";
|
||||
|
||||
string startedBy;
|
||||
if (activity.ParentsTotal == 1 && activity.Parents.Count == 1)
|
||||
{
|
||||
startedBy = activity.Parents[0].Name ?? "an unknown program";
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = activity.Parents
|
||||
.Select(p => p.Name)
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var shown = names.Take(3).ToList();
|
||||
var more = names.Count > shown.Count || activity.Parents.Any(p => p.Name == null) || activity.ParentsTotal > activity.Parents.Count;
|
||||
startedBy = $"{activity.ParentsTotal} parent processes";
|
||||
if (shown.Count > 0)
|
||||
startedBy += $" ({string.Join(", ", shown)}{(more ? ", …" : string.Empty)})";
|
||||
}
|
||||
|
||||
return $"Script activity: {processes} in {sessions}, started by {startedBy} — counted, not an interactive console";
|
||||
}
|
||||
|
||||
// The WMI live watcher could not arm — surface it as collector_degraded so the backend can
|
||||
// tell "no console detected" from "live detector never started" (MON-D1 pattern).
|
||||
private void OnWatcherArmFailed(object? sender, Exception ex)
|
||||
@@ -151,12 +299,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
|
||||
// What was counted goes out first. It has to happen here, in the host's stop: the
|
||||
// handlers below go away, and once the orchestrator has stopped the ingress a post throws.
|
||||
ReportScriptActivity(stopping: true);
|
||||
|
||||
if (_observableIngress != null && _signalPostedHandler != null)
|
||||
{
|
||||
try { _observableIngress.SignalPosted -= _signalPostedHandler; } catch { }
|
||||
}
|
||||
try { _watcher.BypassConsoleDetected -= OnBypassConsoleDetected; } catch { }
|
||||
try { _watcher.ScriptActivityStarted -= OnScriptActivityStarted; } catch { }
|
||||
try { _watcher.WatcherArmFailed -= OnWatcherArmFailed; } catch { }
|
||||
|
||||
lock (_summaryLock)
|
||||
{
|
||||
try { _windowTimer?.Dispose(); } catch { }
|
||||
_windowTimer = null;
|
||||
}
|
||||
|
||||
try { _watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// The OOBE update registry snapshot (D-310) is observation at agent start/stop and at
|
||||
// the update page — the page's own records (oobe_update_page) stay activity.
|
||||
SharedConstants.EventTypes.OobeUpdateState,
|
||||
// The count of short-lived console processes: a script that keeps spawning them for
|
||||
// hours would otherwise hold the periodic collectors awake and reset the stall clock.
|
||||
// The work those scripts do arrives as its own app, script or phase event.
|
||||
SharedConstants.EventTypes.ConsoleScriptActivity,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// authorized agent.</item>
|
||||
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
|
||||
/// without retrying — a refusal with a code does not clear by waiting.</item>
|
||||
/// <item>410 from the backend (<see cref="BackendSessionGoneException"/>): the persisted
|
||||
/// session was deleted. With <c>rotateOnSessionGone</c> (a WhiteGlove Part 2 start, whose
|
||||
/// enrollment is only now continuing) the session is rotated like above and registered
|
||||
/// once more. Every other start returns <see cref="SessionRegistrationOutcome.SessionGone"/>
|
||||
/// without retrying — the caller ends monitoring. So does a second 410 or a failed rotation:
|
||||
/// the 48-hour break skips a WhiteGlove resume, so no path here may leave it retrying.</item>
|
||||
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
|
||||
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
|
||||
/// </list>
|
||||
@@ -75,6 +81,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// Optional pre-registration link wait used by tests. Production callers pass
|
||||
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
|
||||
/// </param>
|
||||
/// <param name="rotateOnSessionGone">
|
||||
/// True at a WhiteGlove Part 2 start: a 410 for the persisted session then becomes a new
|
||||
/// session through <paramref name="rotateSession"/> instead of ending monitoring.
|
||||
/// </param>
|
||||
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
|
||||
BackendApiClient apiClient,
|
||||
AgentConfiguration agentConfig,
|
||||
@@ -86,7 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
Func<Exception, Task> onTerminalTransportFailure = null,
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
|
||||
Func<AgentLogger, Task> networkLinkWait = null,
|
||||
Func<string> rotateSession = null)
|
||||
Func<string> rotateSession = null,
|
||||
bool rotateOnSessionGone = false)
|
||||
{
|
||||
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
@@ -101,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
string lastError = null;
|
||||
Exception lastException = null;
|
||||
string rotatedFromSessionId = null;
|
||||
string rotationReason = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
@@ -112,7 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
if (response != null && response.Success)
|
||||
{
|
||||
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId, rotationReason);
|
||||
}
|
||||
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
@@ -149,9 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
|
||||
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
|
||||
$"Rotated to {newSessionId}; re-registering.");
|
||||
rotationReason = SessionRotationReasons.OwnerMismatch;
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
catch (BackendSessionGoneException ex)
|
||||
{
|
||||
if (rotateOnSessionGone && rotateSession != null && rotatedFromSessionId == null)
|
||||
{
|
||||
var goneSessionId = registration.SessionId;
|
||||
string newSessionId = null;
|
||||
try
|
||||
{
|
||||
newSessionId = rotateSession();
|
||||
}
|
||||
catch (Exception rotateEx)
|
||||
{
|
||||
logger.Error($"Backend answered 410 for session {goneSessionId} but session rotation failed: {rotateEx.Message}", rotateEx);
|
||||
}
|
||||
|
||||
if (newSessionId != null)
|
||||
{
|
||||
rotatedFromSessionId = goneSessionId;
|
||||
rotationReason = SessionRotationReasons.SessionGone;
|
||||
logger.Warning(
|
||||
$"Backend answered 410 for session {goneSessionId} at the start of a WhiteGlove Part 2: " +
|
||||
$"the session was deleted. Rotated to {newSessionId}; re-registering.");
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error($"Session registration refused with 410 Gone — the backend deleted session {registration.SessionId}.");
|
||||
return SessionRegistrationResult.SessionGone(ex.StatusCode, ex.Message);
|
||||
}
|
||||
catch (BackendRefusalException ex)
|
||||
{
|
||||
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
|
||||
@@ -294,6 +337,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 410 from the backend: the persisted session was deleted. No retry was attempted; the
|
||||
/// caller ends monitoring and cleans up.
|
||||
/// </summary>
|
||||
SessionGone = 3,
|
||||
}
|
||||
|
||||
/// <summary>Why a run registered under a new SessionId (<see cref="SessionRegistrationResult.RotationReason"/>).</summary>
|
||||
public static class SessionRotationReasons
|
||||
{
|
||||
/// <summary>The backend refused the persisted id with <c>session_owner_mismatch</c>.</summary>
|
||||
public const string OwnerMismatch = "owner_mismatch";
|
||||
|
||||
/// <summary>The backend answered 410 for the persisted id at a WhiteGlove Part 2 start.</summary>
|
||||
public const string SessionGone = "session_gone";
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
|
||||
@@ -308,12 +367,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
|
||||
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
|
||||
/// The SessionId this run started with when the backend refused it (<c>session_owner_mismatch</c>,
|
||||
/// or 410 at a WhiteGlove Part 2 start) and the session was rotated before the successful
|
||||
/// registration. Null on the normal path.
|
||||
/// </summary>
|
||||
public string RotatedFromSessionId { get; }
|
||||
|
||||
/// <summary>One of <see cref="SessionRotationReasons"/> when <see cref="RotatedFromSessionId"/> is set, else null.</summary>
|
||||
public string RotationReason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the run ended on a 401/403 that something on the network path answered (TLS
|
||||
/// inspection, a proxy page — <see cref="BackendAuthException.IsForeignResponse"/>), not
|
||||
@@ -327,7 +389,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
int httpStatusCode,
|
||||
string errorMessage,
|
||||
string rotatedFromSessionId = null,
|
||||
bool isIntercepted = false)
|
||||
bool isIntercepted = false,
|
||||
string rotationReason = null)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Response = response;
|
||||
@@ -335,10 +398,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
ErrorMessage = errorMessage;
|
||||
RotatedFromSessionId = rotatedFromSessionId;
|
||||
IsIntercepted = isIntercepted;
|
||||
RotationReason = rotatedFromSessionId != null ? rotationReason : null;
|
||||
}
|
||||
|
||||
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
|
||||
public static SessionRegistrationResult Succeeded(
|
||||
RegisterSessionResponse response, string rotatedFromSessionId = null, string rotationReason = null)
|
||||
=> new SessionRegistrationResult(
|
||||
SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId, rotationReason: rotationReason);
|
||||
|
||||
public static SessionRegistrationResult SessionGone(int statusCode, string message)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.SessionGone, null, statusCode, message);
|
||||
|
||||
public static SessionRegistrationResult AuthFailed(int statusCode, string message, bool intercepted = false)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message, isIntercepted: intercepted);
|
||||
|
||||
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
// Don't log the full email in decision signals — user PII. Keep domain only
|
||||
// for audit trail; full email lives only in local agent logs if at all.
|
||||
["userDomain"] = ExtractDomain(userEmail),
|
||||
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
|
||||
["hasThumbprint"] = string.IsNullOrEmpty(thumbprint) ? "false" : "true",
|
||||
// The AadJoinWatcher fires AadUserJoined ONLY when isPlaceholderUser==false
|
||||
// (placeholder accounts go via the separate PlaceholderUserDetected event).
|
||||
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var placeholderType = ClassifyPlaceholder(userEmail);
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["userDomain"] = ExtractDomain(userEmail),
|
||||
["userDomain"] = AadJoinInfo.ExtractUserDomain(userEmail),
|
||||
["placeholderType"] = placeholderType,
|
||||
["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo",
|
||||
};
|
||||
@@ -154,13 +154,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
occurredAtUtc: _clock.UtcNow);
|
||||
}
|
||||
|
||||
private static string ExtractDomain(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return "unknown";
|
||||
var at = email.IndexOf('@');
|
||||
return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown";
|
||||
}
|
||||
|
||||
private static string ClassifyPlaceholder(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email)) return "unknown";
|
||||
|
||||
@@ -257,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// <c>true</c> when the file-based <c>enrollment-complete.marker</c> signals that a
|
||||
/// previous session already completed enrollment — written by
|
||||
/// <see cref="EndMonitoringAtStart"/> (emergency break, intercepted-registration give-up,
|
||||
/// config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
|
||||
/// session gone at registration, config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
|
||||
/// cleanly even if the cleanup retry failed to remove the Scheduled Task. Triggers another
|
||||
/// self-destruct attempt when <paramref name="selfDestructOnComplete"/> is set.
|
||||
/// <para>
|
||||
@@ -369,10 +369,11 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// next start exits cleanly even if the cleanup retry fails (Scheduled Task still alive,
|
||||
/// files locked, …), runs the cleanup retry when <paramref name="runSelfDestruct"/> is set
|
||||
/// (the emergency break and the give-up pass <c>SelfDestructOnComplete</c>, the kill signal
|
||||
/// forces it), and deletes the persisted session files. Shared by the session-age emergency
|
||||
/// break, the intercepted-registration give-up
|
||||
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>) and the config-channel kill signal
|
||||
/// so all three leave the device in the same state. Never throws.
|
||||
/// and the session-gone end force it), and deletes the persisted session files. Shared by
|
||||
/// the session-age emergency break, the intercepted-registration give-up
|
||||
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>), the registration answered with 410
|
||||
/// (<c>BackendSessionRegistration.ConcludeSessionGone</c>) and the config-channel kill signal
|
||||
/// so all four leave the device in the same state. Never throws.
|
||||
/// </summary>
|
||||
internal static void EndMonitoringAtStart(
|
||||
string stateDirectory,
|
||||
|
||||
@@ -275,7 +275,9 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
|
||||
// Exit codes: 6 = AuthFailed, 7 = anything else. A registration intercepted on more
|
||||
// starts than the buffer allows cleans up like the emergency break (state folder +
|
||||
// cleanup factory) and still exits 6. See Runtime/BackendSessionRegistration.cs.
|
||||
// cleanup factory) and still exits 6. A registration the backend answers with 410
|
||||
// (session deleted) cleans up the same way and exits 0; a WhiteGlove Part 2 start
|
||||
// registers a new session instead. See Runtime/BackendSessionRegistration.cs.
|
||||
var registration = Runtime.BackendSessionRegistration.Register(
|
||||
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
|
||||
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
|
||||
@@ -283,7 +285,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
sessionPersistence: bootstrap.SessionPersistence,
|
||||
configFetchOutcome: runtimeConfig.RemoteConfigService.LastFetchOutcome.ToString(),
|
||||
stateDirectory: stateSubdir,
|
||||
cleanupServiceFactory: bootstrap.CleanupServiceFactory);
|
||||
cleanupServiceFactory: bootstrap.CleanupServiceFactory,
|
||||
isWhiteGloveResume: bootstrap.IsWhiteGloveResume);
|
||||
if (registration.ShouldExit)
|
||||
{
|
||||
return registration.ExitCode;
|
||||
|
||||
@@ -347,7 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
|
||||
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
|
||||
rotatedFromSessionId: registrationResult?.RotatedFromSessionId,
|
||||
priorRegistrationFailures: registration.PriorFailures);
|
||||
priorRegistrationFailures: registration.PriorFailures,
|
||||
sessionRotationReason: registrationResult?.RotationReason);
|
||||
// Wire-visible signal that the agent is running on defaults/cache
|
||||
// rather than the live tenant config — closes the cold-start
|
||||
// blind spot observed in session 8f2bef72 (2026-05-19).
|
||||
|
||||
@@ -26,6 +26,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// the next boot. The failed run is recorded for the emergency break report. A registration
|
||||
/// that something on the network path answered gets <c>InterceptedRegistrationRetryBoots</c>
|
||||
/// further boots, then the agent cleans up like the emergency break (<see cref="ConcludeFailedRun"/>).
|
||||
/// A registration the backend answers with 410 — the session was deleted — ends monitoring at
|
||||
/// once (<see cref="ConcludeSessionGone"/>); only a WhiteGlove Part 2 start registers a new
|
||||
/// session instead, because its enrollment is only now continuing.
|
||||
/// A successful registration after failed starts reports them on <c>agent_started</c>
|
||||
/// (<see cref="ClearAfterSuccess"/>).
|
||||
/// </summary>
|
||||
@@ -37,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan PendingDistressBound = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up.</param>
|
||||
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>).</param>
|
||||
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up and the session-gone end.</param>
|
||||
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>) and the session-gone end (always run).</param>
|
||||
/// <param name="isWhiteGloveResume">This start is a WhiteGlove Part 2: a 410 for the persisted session becomes a new session.</param>
|
||||
public static SessionRegistrationOutcomeResult Register(
|
||||
AgentConfiguration agentConfig,
|
||||
BackendAuthBundle auth,
|
||||
@@ -50,7 +54,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
SessionIdPersistence sessionPersistence = null,
|
||||
string configFetchOutcome = null,
|
||||
string stateDirectory = null,
|
||||
Func<CleanupService> cleanupServiceFactory = null)
|
||||
Func<CleanupService> cleanupServiceFactory = null,
|
||||
bool isWhiteGloveResume = false)
|
||||
{
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
if (auth == null) throw new ArgumentNullException(nameof(auth));
|
||||
@@ -86,7 +91,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// Reuse the single hardened hardware read from the auth bundle so the session row
|
||||
// matches the security headers the backend validated against (no second WMI query).
|
||||
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
|
||||
rotateSession: rotateSession)
|
||||
rotateSession: rotateSession,
|
||||
rotateOnSessionGone: isWhiteGloveResume)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
|
||||
@@ -97,6 +103,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
|
||||
}
|
||||
|
||||
if (registrationResult.Outcome == SessionRegistrationOutcome.SessionGone)
|
||||
{
|
||||
// The report goes out through the auth bundle's client: dispose only afterwards.
|
||||
var exitCode = ConcludeSessionGone(
|
||||
agentConfig, auth.EmergencyReporter, sessionPersistence, stateDirectory,
|
||||
cleanupServiceFactory, consoleMode, logger);
|
||||
try { mtlsHttpClient?.Dispose(); } catch { }
|
||||
try { auth.BackendApiClient?.Dispose(); } catch { }
|
||||
return SessionRegistrationOutcomeResult.Exit(exitCode);
|
||||
}
|
||||
|
||||
var record = RecordFailedRun(
|
||||
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
|
||||
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
|
||||
@@ -264,6 +281,41 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end of a start whose registration the backend answered with 410: the session was
|
||||
/// deleted. One report over the emergency channel (a deleted session has no timeline to
|
||||
/// carry an event), then monitoring ends like on the telemetry-channel 410 — marker first,
|
||||
/// self-destruct regardless of <c>SelfDestructOnComplete</c>, session files deleted
|
||||
/// (<see cref="Program.EndMonitoringAtStart"/>). Returns exit code 0: an intended end.
|
||||
/// </summary>
|
||||
internal static int ConcludeSessionGone(
|
||||
AgentConfiguration agentConfig,
|
||||
EmergencyReporter emergencyReporter,
|
||||
SessionIdPersistence sessionPersistence,
|
||||
string stateDirectory,
|
||||
Func<CleanupService> cleanupServiceFactory,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
{
|
||||
logger.Error(
|
||||
$"=== SESSION GONE (HTTP 410): the backend deleted session {agentConfig.SessionId} — " +
|
||||
"reporting once over the emergency channel, then cleaning up with self-destruct. ===");
|
||||
if (consoleMode)
|
||||
Console.Error.WriteLine("Session was deleted in the backend. Cleaning up and exiting.");
|
||||
|
||||
SessionGoneReport.SendBlocking(
|
||||
emergencyReporter,
|
||||
AgentErrorType.RegistrationRejectedSessionGone,
|
||||
"Session registration rejected with HTTP 410 — the backend deleted this session. Agent cleaning up with self-destruct.",
|
||||
logger);
|
||||
|
||||
Program.EndMonitoringAtStart(
|
||||
stateDirectory, sessionPersistence, runSelfDestruct: true, cleanupServiceFactory, logger,
|
||||
markerText: $"Session gone at {DateTime.UtcNow:O} (registration refused with HTTP 410)",
|
||||
cleanupReason: "session_gone");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
|
||||
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
|
||||
|
||||
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
RemoteConfigService remoteConfigService,
|
||||
AgentLogger logger,
|
||||
string rotatedFromSessionId = null,
|
||||
PriorRegistrationFailures priorRegistrationFailures = null)
|
||||
PriorRegistrationFailures priorRegistrationFailures = null,
|
||||
string sessionRotationReason = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -72,9 +73,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
|
||||
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
|
||||
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
|
||||
// SESSION-OWNER-BINDING: true when the backend refused the persisted session
|
||||
// id (session_owner_mismatch) and this run rotated to a new session before
|
||||
// registering — the re-enroll-without-wipe shape, made visible on the timeline.
|
||||
// True when the backend refused the persisted session id and this run rotated
|
||||
// to a new session before registering: session_owner_mismatch (re-enroll
|
||||
// without wipe) or 410 at a WhiteGlove Part 2 start (session deleted).
|
||||
// sessionRotationReason below names which.
|
||||
{ "sessionRotated", rotatedFromSessionId != null },
|
||||
{ "previousSessionId", rotatedFromSessionId ?? string.Empty },
|
||||
// remoteConfigFetched answers "is the agent running on a live-fetched
|
||||
@@ -96,6 +98,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
if (recovered)
|
||||
data["remoteConfigRecovered"] = true;
|
||||
|
||||
if (rotatedFromSessionId != null && !string.IsNullOrEmpty(sessionRotationReason))
|
||||
data["sessionRotationReason"] = sessionRotationReason;
|
||||
|
||||
// Which bootstrap installed this agent (installer and loader versions, MSI or
|
||||
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
|
||||
// fleet's share of old or directly assigned installers countable.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
@@ -193,14 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
// Bounded blocking send for the one session-gone report: the terminate that follows ends
|
||||
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
|
||||
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
|
||||
private const int SessionGoneReportAttempts = 2;
|
||||
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
|
||||
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
|
||||
|
||||
/// <summary>
|
||||
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
|
||||
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
@@ -227,25 +218,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
|
||||
|
||||
if (emergencyReporter != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
emergencyReporter
|
||||
.TrySendAsync(
|
||||
AgentErrorType.TelemetryRejectedSessionUnknown,
|
||||
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
|
||||
httpStatusCode: (int)HttpStatusCode.Gone,
|
||||
attempts: SessionGoneReportAttempts,
|
||||
perAttemptTimeout: SessionGoneReportTimeout,
|
||||
retryDelay: SessionGoneReportRetryDelay)
|
||||
.Wait(SessionGoneReportBudget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
|
||||
}
|
||||
}
|
||||
SessionGoneReport.SendBlocking(
|
||||
emergencyReporter,
|
||||
AgentErrorType.TelemetryRejectedSessionUnknown,
|
||||
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
|
||||
logger);
|
||||
|
||||
var terminateAction = new ServerAction
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// The one emergency report an agent sends when the backend answered HTTP 410 for its own
|
||||
/// session — on a telemetry upload (<see cref="ServerControlPlane.HandleSessionGone"/>) or at
|
||||
/// registration (<see cref="BackendSessionRegistration"/>). The send blocks within a bound:
|
||||
/// the caller ends the process right after and the cleanup deletes the agent, so a
|
||||
/// fire-and-forget send would be abandoned mid-flight. Retries stay inside one anti-flood
|
||||
/// reservation (ONE report). Never throws.
|
||||
/// </summary>
|
||||
internal static class SessionGoneReport
|
||||
{
|
||||
private const int Attempts = 2;
|
||||
private static readonly TimeSpan PerAttemptTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3);
|
||||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(40);
|
||||
|
||||
public static void SendBlocking(
|
||||
EmergencyReporter emergencyReporter,
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (emergencyReporter == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
emergencyReporter
|
||||
.TrySendAsync(
|
||||
errorType,
|
||||
message,
|
||||
httpStatusCode: (int)HttpStatusCode.Gone,
|
||||
attempts: Attempts,
|
||||
perAttemptTimeout: PerAttemptTimeout,
|
||||
retryDelay: RetryDelay)
|
||||
.Wait(Budget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -810,6 +810,36 @@ public class AdminWireParityTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReseedFromGitHubResponse_carries_the_inventory_counts_and_the_problems_of_a_refused_list()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = false,
|
||||
message = "Reseed from GitHub complete, except for the CPE mappings",
|
||||
gather = new { deleted = 0, written = 0, orphanStatesGcd = 0, sunsetSkipped = 0 },
|
||||
analyze = new { deleted = 0, written = 0, orphanStatesGcd = 0, sunsetSkipped = 0 },
|
||||
ime = new { deleted = 0, written = 0 },
|
||||
cpeCommunityMappings = new { deleted = 0, written = 0 },
|
||||
cpeSeedMappings = new { deleted = 0, written = 0 },
|
||||
cpeInventory = new { rows = 9864, set = 0, cleared = 0, failed = 0, productsNoted = 0 },
|
||||
cpeProblems = new[] { "seed list: the list holds no mapping" }
|
||||
},
|
||||
new ReseedFromGitHubResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Reseed from GitHub complete, except for the CPE mappings",
|
||||
Gather = new ReseedRuleCountsNode { Deleted = 0, Written = 0, OrphanStatesGcd = 0, SunsetSkipped = 0 },
|
||||
Analyze = new ReseedRuleCountsNode { Deleted = 0, Written = 0, OrphanStatesGcd = 0, SunsetSkipped = 0 },
|
||||
Ime = new ReseedTableCountsNode { Deleted = 0, Written = 0 },
|
||||
CpeCommunityMappings = new ReseedTableCountsNode { Deleted = 0, Written = 0 },
|
||||
CpeSeedMappings = new ReseedTableCountsNode { Deleted = 0, Written = 0 },
|
||||
CpeInventory = new ReseedInventoryNode { Rows = 9864, Set = 0, Cleared = 0, Failed = 0, ProductsNoted = 0 },
|
||||
CpeProblems = new List<string> { "seed list: the list holds no mapping" }
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SubmitOffboardingFeedback -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string RuleId = "ANALYZE-CUSTOM-777";
|
||||
private const string TemplateRuleId = "ANALYZE-SEC-004";
|
||||
private const string TemplateRuleId = "ANALYZE-SEC-006";
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
@@ -177,7 +177,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
|
||||
var template = new AnalyzeRule
|
||||
{
|
||||
RuleId = TemplateRuleId,
|
||||
Title = "AutoLogon enabled for an unexpected user",
|
||||
Title = "Unexpected Provisioning Package (Custom Allow-List)",
|
||||
Description = "Template",
|
||||
Severity = "warning",
|
||||
Category = "security",
|
||||
@@ -186,7 +186,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
|
||||
Author = "Autopilot Monitor",
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new() { Signal = "autologon", Source = "event_type", EventType = "autologon_analysis", Operator = "equals", Value = "PLACEHOLDER", Required = true }
|
||||
new() { Signal = "unexpected_package", Source = "event_type", EventType = "provisioning_package_scan", Operator = "equals", Value = "PLACEHOLDER", Required = true }
|
||||
},
|
||||
TemplateVariables = new List<TemplateVariable>
|
||||
{
|
||||
@@ -194,7 +194,7 @@ public class AnalyzeRulesFunctionAuthorEndpointTests
|
||||
},
|
||||
};
|
||||
var (function, stored) = BuildFunction(globalRule: template);
|
||||
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "KIOSK-1" } });
|
||||
var req = BuildRequest(AlicePrincipal(), new { variables = new Dictionary<string, string> { ["allowed"] = "Contoso.Recovery" } });
|
||||
|
||||
var response = await function.CreateFromTemplate(req, TemplateRuleId);
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST vulnerability/cpe-mapping/auto-resolve tries the given programs one after the other and
|
||||
/// says per program what came of it. The operator waits for the answer, so once NVD leaves a
|
||||
/// request unanswered NVD is not asked about the remaining programs, which still get every
|
||||
/// answer that needs no request; programs that got a mapping have it on their inventory rows
|
||||
/// when the call returns.
|
||||
/// </summary>
|
||||
[Collection(NvdTestState.Collection)]
|
||||
public sealed class AutoResolveCpeMappingFunctionTests : IDisposable
|
||||
{
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string GadgetCpe = "cpe:2.3:a:fabrikam:gadget";
|
||||
private const string ToolCpe = "cpe:2.3:a:northwind:tool";
|
||||
private const string ReaderCpe = "cpe:2.3:a:adatum:reader";
|
||||
private const string ViewerCpe = "cpe:2.3:a:adatum:viewer";
|
||||
private const string EditorCpe = "cpe:2.3:a:adatum:editor";
|
||||
|
||||
private static readonly AutoResolveCpeMappingItem Widget = new() { SoftwareName = "Contoso Widget 4.2 (x64)", NormalizedVendor = "contoso", Publisher = "Contoso Ltd" };
|
||||
private static readonly AutoResolveCpeMappingItem Gadget = new() { SoftwareName = "Fabrikam Gadget 2.0", NormalizedVendor = "fabrikam", Publisher = "Fabrikam Inc" };
|
||||
private static readonly AutoResolveCpeMappingItem Tool = new() { SoftwareName = "Northwind Tool 3.1", NormalizedVendor = "northwind", Publisher = "Northwind Traders" };
|
||||
private static readonly AutoResolveCpeMappingItem Reader = new() { SoftwareName = "Adatum Reader 2.0", NormalizedVendor = "adatum", Publisher = "Adatum Corporation" };
|
||||
private static readonly AutoResolveCpeMappingItem Viewer = new() { SoftwareName = "Adatum Viewer 1.4", NormalizedVendor = "adatum", Publisher = "Adatum Corporation" };
|
||||
private static readonly AutoResolveCpeMappingItem Editor = new() { SoftwareName = "Adatum Editor 6.0", NormalizedVendor = "adatum", Publisher = "Adatum Corporation" };
|
||||
private static readonly AutoResolveCpeMappingItem Launcher = new() { SoftwareName = "Tailspin Launcher", NormalizedVendor = "tailspin", Publisher = "Tailspin Toys" };
|
||||
private static readonly AutoResolveCpeMappingItem Terminal = new() { SoftwareName = "Tailspin Terminal", NormalizedVendor = "tailspin", Publisher = "Tailspin Toys" };
|
||||
|
||||
public AutoResolveCpeMappingFunctionTests() => NvdTestState.Reset();
|
||||
|
||||
public void Dispose() => NvdTestState.Reset();
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
private Rig(MappingRig maps, CpeDictionaryService dictionary)
|
||||
{
|
||||
Maps = maps;
|
||||
Function = new AutoResolveCpeMappingFunction(
|
||||
maps.Resolver(dictionary), maps.Store, maps.Reconciler, NullLogger<AutoResolveCpeMappingFunction>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gadget is mapped by the seed; widget, gadget, tool, reader, viewer and editor are
|
||||
/// products of the stored dictionary, and NVD lists one CVE for whatever it is asked about.
|
||||
/// </summary>
|
||||
public static async Task<Rig> CreateAsync(bool dictionaryStored = true)
|
||||
{
|
||||
var maps = await MappingRig.CreateAsync(
|
||||
VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", GadgetCpe, "fabrikam gadget"));
|
||||
maps.Scan.NvdAnswer = OneCve;
|
||||
var dictionary = dictionaryStored
|
||||
? CpeDictionaryFixture.Stored(
|
||||
("contoso:widget", "Contoso Widget"), ("fabrikam:gadget", "Fabrikam Gadget"),
|
||||
("northwind:tool", "Northwind Tool"), ("adatum:reader", "Adatum Reader"),
|
||||
("adatum:viewer", "Adatum Viewer"), ("adatum:editor", "Adatum Editor"))
|
||||
: CpeDictionaryFixture.Stored();
|
||||
return new Rig(maps, dictionary);
|
||||
}
|
||||
|
||||
public MappingRig Maps { get; }
|
||||
public AutoResolveCpeMappingFunction Function { get; }
|
||||
public IEnumerable<string> AskedFor => Maps.Scan.Nvd.Requests.Select(r => r.Cpe);
|
||||
|
||||
public async Task<JsonElement> ResolveAsync(params AutoResolveCpeMappingItem[] items)
|
||||
{
|
||||
var response = await Function.Run(OperatorCall.Request(new AutoResolveCpeMappingRequest { Items = items.ToList() }));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return OperatorCall.BodyOf(response);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage OneCve(NvdRequest request) =>
|
||||
NvdFixture.Page(NvdFixture.Cve("CVE-2026-10001", 8.8, NvdFixture.Below(request.Cpe, "9.0")));
|
||||
|
||||
private static List<(string SoftwareName, string Reason)> FailedOf(JsonElement body) => body.GetProperty("failed").EnumerateArray()
|
||||
.Select(f => (f.GetProperty("softwareName").GetString()!, f.GetProperty("reason").GetString()!))
|
||||
.ToList();
|
||||
|
||||
private static List<(string SoftwareName, string CpeUri)> ResolvedOf(JsonElement body) => body.GetProperty("resolved").EnumerateArray()
|
||||
.Select(r => (r.GetProperty("softwareName").GetString()!, r.GetProperty("cpeUri").GetString()!))
|
||||
.ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task Run_MappedPrograms_AreReportedWithTheirCpe_AndHaveItOnTheirInventoryRows()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var installed = rig.Maps.Tables.AddInventory("contoso", "Contoso Widget 4.2 (x64)", "4.2");
|
||||
var otherVersion = rig.Maps.Tables.AddInventory("contoso", "Contoso Widget 5.0", "5.0");
|
||||
|
||||
var body = await rig.ResolveAsync(Widget, Tool);
|
||||
|
||||
Assert.Equal(new[] { (Widget.SoftwareName, WidgetCpe), (Tool.SoftwareName, ToolCpe) }, ResolvedOf(body));
|
||||
Assert.All(body.GetProperty("resolved").EnumerateArray(), r => Assert.Equal(1, r.GetProperty("confidence").GetDouble()));
|
||||
Assert.Empty(FailedOf(body));
|
||||
Assert.Equal(2, body.GetProperty("totalProcessed").GetInt32());
|
||||
Assert.Equal(2, body.GetProperty("totalResolved").GetInt32());
|
||||
Assert.Equal(0, body.GetProperty("totalFailed").GetInt32());
|
||||
|
||||
Assert.Equal(new[] { WidgetCpe, ToolCpe }, rig.AskedFor);
|
||||
Assert.Equal(WidgetCpe, installed["CpeUri"]);
|
||||
Assert.Equal(WidgetCpe, otherVersion["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_FirstRequestNvdLeavesUnanswered_EndsTheAsking_AndTheRestIsReportedUnavailable()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Maps.Scan.NvdAnswer = request =>
|
||||
request.Cpe == WidgetCpe ? NvdFixture.Status(HttpStatusCode.InternalServerError) : OneCve(request);
|
||||
var installed = rig.Maps.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0");
|
||||
|
||||
var body = await rig.ResolveAsync(Reader, Widget, Tool);
|
||||
|
||||
Assert.Equal(new[] { (Reader.SoftwareName, ReaderCpe) }, ResolvedOf(body));
|
||||
Assert.Equal(
|
||||
new[] { (Widget.SoftwareName, "nvd_unavailable"), (Tool.SoftwareName, "nvd_unavailable") }, FailedOf(body));
|
||||
Assert.Equal(3, body.GetProperty("totalProcessed").GetInt32());
|
||||
Assert.Equal(1, body.GetProperty("totalResolved").GetInt32());
|
||||
Assert.Equal(2, body.GetProperty("totalFailed").GetInt32());
|
||||
|
||||
// The tool was never asked about: its request would have waited for the same timeout.
|
||||
Assert.Equal(new[] { ReaderCpe, WidgetCpe }, rig.AskedFor);
|
||||
Assert.Equal(new[] { "adatum:adatum reader" }, rig.Maps.Tables.RowKeys(CpeMappingStore.AutoPartition));
|
||||
// What was mapped before NVD stopped answering is kept and reaches the inventory.
|
||||
Assert.Equal(ReaderCpe, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_RequestNvdTurnsDown_FailsThatProgramOnly()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Maps.Scan.NvdAnswer = request =>
|
||||
request.Cpe == WidgetCpe ? NvdFixture.Status(HttpStatusCode.NotFound) : OneCve(request);
|
||||
|
||||
var body = await rig.ResolveAsync(Widget, Tool);
|
||||
|
||||
Assert.Equal(new[] { (Widget.SoftwareName, "nvd_unavailable") }, FailedOf(body));
|
||||
Assert.Equal(new[] { (Tool.SoftwareName, ToolCpe) }, ResolvedOf(body));
|
||||
Assert.Equal(new[] { WidgetCpe, ToolCpe }, rig.AskedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_SaysWhyAProgramGotNoMapping()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Maps.Scan.NvdAnswer = _ => NvdFixture.Page();
|
||||
var ignore = CpeIgnoreEntry.Build(Tool.NormalizedVendor, Tool.SoftwareName, "manual")!.Value;
|
||||
rig.Maps.Tables.Put(CpeMappingStore.IgnoredPartition, ignore.RowKey, ignore.Properties);
|
||||
var installed = rig.Maps.Tables.AddInventory("contoso", "Contoso Widget 4.2 (x64)", "4.2");
|
||||
|
||||
var body = await rig.ResolveAsync(
|
||||
new AutoResolveCpeMappingItem { SoftwareName = " " }, Gadget, Tool, Launcher, Widget);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
(" ", "empty_name"),
|
||||
(Gadget.SoftwareName, "already_mapped"),
|
||||
(Tool.SoftwareName, "ignored"),
|
||||
(Launcher.SoftwareName, "no_exact_product"),
|
||||
(Widget.SoftwareName, "no_cves"),
|
||||
},
|
||||
FailedOf(body));
|
||||
Assert.Empty(ResolvedOf(body));
|
||||
Assert.Equal(new[] { WidgetCpe }, rig.AskedFor);
|
||||
// Nothing was mapped, so no inventory row is touched.
|
||||
Assert.Empty(rig.Maps.Tables.InventoryWrites);
|
||||
Assert.Equal(string.Empty, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NoDictionaryStored_SaysSoForEveryProgram()
|
||||
{
|
||||
var rig = await Rig.CreateAsync(dictionaryStored: false);
|
||||
|
||||
var body = await rig.ResolveAsync(Widget, Tool);
|
||||
|
||||
Assert.Equal(new[] { (Widget.SoftwareName, "no_dictionary"), (Tool.SoftwareName, "no_dictionary") }, FailedOf(body));
|
||||
Assert.Empty(rig.AskedFor);
|
||||
}
|
||||
|
||||
public static TheoryData<string> BodiesWithoutUsableItems() => new()
|
||||
{
|
||||
"{}",
|
||||
"""{ "items": [] }""",
|
||||
"""{ "items": null }""",
|
||||
JsonSerializer.Serialize(new { items = Enumerable.Range(0, 26).Select(n => new { softwareName = $"Contoso Tool {n}" }) }),
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(BodiesWithoutUsableItems))]
|
||||
public async Task Run_NoItemsOrMoreThanARequestMayCarry_IsABadRequest(string json)
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.RequestWithJson(json));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.BadRequest, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(rig.AskedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_AsManyItemsAsARequestMayCarry_AreAllTried()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var items = Enumerable.Range(0, 25)
|
||||
.Select(n => new AutoResolveCpeMappingItem { SoftwareName = $"Tailspin Launcher {(char)('a' + n)}", NormalizedVendor = "tailspin" })
|
||||
.ToArray();
|
||||
|
||||
var body = await rig.ResolveAsync(items);
|
||||
|
||||
Assert.Equal(25, body.GetProperty("totalProcessed").GetInt32());
|
||||
Assert.All(FailedOf(body), failed => Assert.Equal("no_exact_product", failed.Reason));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CpeAutoMapOutcome.AlreadyMapped, "already_mapped")]
|
||||
[InlineData(CpeAutoMapOutcome.Ignored, "ignored")]
|
||||
[InlineData(CpeAutoMapOutcome.NoExactProduct, "no_exact_product")]
|
||||
[InlineData(CpeAutoMapOutcome.NoCves, "no_cves")]
|
||||
[InlineData(CpeAutoMapOutcome.UnversionedCves, "unversioned_cves")]
|
||||
[InlineData(CpeAutoMapOutcome.StorePackage, "store_package")]
|
||||
[InlineData(CpeAutoMapOutcome.NvdUnavailable, "nvd_unavailable")]
|
||||
[InlineData(CpeAutoMapOutcome.NoDictionary, "no_dictionary")]
|
||||
public void ReasonOf_NamesTheOutcome(CpeAutoMapOutcome outcome, string expected) =>
|
||||
Assert.Equal(expected, AutoResolveCpeMappingFunction.ReasonOf(outcome));
|
||||
|
||||
[Fact]
|
||||
public void ReasonOf_HasAReasonOfItsOwnForEveryOutcomeThatIsNotAMapping()
|
||||
{
|
||||
var reasons = Enum.GetValues<CpeAutoMapOutcome>()
|
||||
.Where(outcome => outcome != CpeAutoMapOutcome.Mapped)
|
||||
.Select(AutoResolveCpeMappingFunction.ReasonOf)
|
||||
.ToList();
|
||||
|
||||
// An outcome added later must not fall back to the catch-all.
|
||||
Assert.DoesNotContain("not_mapped", reasons);
|
||||
Assert.Equal(reasons.Count, reasons.Distinct().Count());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Answers that need no request, Store packages, unreadable mappings
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Run_AfterNvdWentQuiet_WhateverNeedsNoRequestStillGetsItsRealAnswer()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Maps.Scan.NvdAnswer = _ => NvdFixture.Status(HttpStatusCode.InternalServerError);
|
||||
var cachedAt = DateTime.UtcNow.AddDays(-2);
|
||||
rig.Maps.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
ReaderCpe, new[] { VulnerabilityCacheRows.Cve("CVE-2026-10002", 7.5, ReaderCpe, "9.0") }, cachedAt, nvdTotal: 1));
|
||||
rig.Maps.Scan.Cache(VulnerabilityCacheRows.Filled(ViewerCpe, Array.Empty<CachedCve>(), cachedAt));
|
||||
rig.Maps.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
EditorCpe, new[] { VulnerabilityCacheRows.CveOfEveryVersion("CVE-2026-10004", 9.8, EditorCpe) }, cachedAt, nvdTotal: 1));
|
||||
var ignore = CpeIgnoreEntry.Build(Launcher.NormalizedVendor, Launcher.SoftwareName, "manual")!.Value;
|
||||
rig.Maps.Tables.Put(CpeMappingStore.IgnoredPartition, ignore.RowKey, ignore.Properties);
|
||||
var installed = rig.Maps.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0");
|
||||
|
||||
var body = await rig.ResolveAsync(Widget, Gadget, Launcher, Terminal, Reader, Viewer, Editor, Tool);
|
||||
|
||||
// The widget's request is the one NVD left unanswered. Nothing is sent after it.
|
||||
Assert.Equal(new[] { WidgetCpe }, rig.AskedFor);
|
||||
Assert.Equal(new[] { (Reader.SoftwareName, ReaderCpe) }, ResolvedOf(body));
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
(Widget.SoftwareName, "nvd_unavailable"),
|
||||
(Gadget.SoftwareName, "already_mapped"),
|
||||
(Launcher.SoftwareName, "ignored"),
|
||||
(Terminal.SoftwareName, "no_exact_product"),
|
||||
(Viewer.SoftwareName, "no_cves"),
|
||||
(Editor.SoftwareName, "unversioned_cves"),
|
||||
// The only other program whose answer NVD would have had to give.
|
||||
(Tool.SoftwareName, "nvd_unavailable"),
|
||||
},
|
||||
FailedOf(body));
|
||||
Assert.Equal(new[] { "adatum:adatum reader" }, rig.Maps.Tables.RowKeys(CpeMappingStore.AutoPartition));
|
||||
Assert.Equal(ReaderCpe, installed["CpeUri"]);
|
||||
// The tool was not noted as "no CVEs" or as waiting: the next attempt asks NVD about it.
|
||||
Assert.DoesNotContain(CveCacheFiller.CacheKey(ToolCpe), rig.Maps.Scan.CacheRows.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_ProductWithACveThatNamesNoVersion_IsNotMapped_AndSaysWhy()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Maps.Scan.NvdAnswer = request =>
|
||||
NvdFixture.Page(NvdFixture.Cve("CVE-2026-10004", 9.8, NvdFixture.EveryVersion(request.Cpe)));
|
||||
var installed = rig.Maps.Tables.AddInventory("contoso", "Contoso Widget 4.2 (x64)", "4.2");
|
||||
|
||||
var body = await rig.ResolveAsync(Widget);
|
||||
|
||||
Assert.Equal(new[] { (Widget.SoftwareName, "unversioned_cves") }, FailedOf(body));
|
||||
Assert.Empty(ResolvedOf(body));
|
||||
Assert.Empty(rig.Maps.Tables.Rows(CpeMappingStore.AutoPartition));
|
||||
Assert.Equal(string.Empty, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_ProgramThatOccursOnlyAsAStorePackage_IsNotMapped_AndCostsNoRequest()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
// A package carries the version of its packaging, which need not be the product's.
|
||||
var package = rig.Maps.Tables.AddInventory("contoso", "Contoso Widget", "1.0.3", registrySource: "AppX");
|
||||
// The tool is a package on some devices and installed the classic way on others.
|
||||
rig.Maps.Tables.AddInventory("northwind", "Northwind Tool", "1.0.3", registrySource: "AppX");
|
||||
var installed = rig.Maps.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1");
|
||||
|
||||
var body = await rig.ResolveAsync(Widget, Tool);
|
||||
|
||||
Assert.Equal(new[] { (Widget.SoftwareName, "store_package") }, FailedOf(body));
|
||||
Assert.Equal(new[] { (Tool.SoftwareName, ToolCpe) }, ResolvedOf(body));
|
||||
Assert.Equal(new[] { ToolCpe }, rig.AskedFor);
|
||||
Assert.Equal(new[] { "northwind:northwind tool" }, rig.Maps.Tables.RowKeys(CpeMappingStore.AutoPartition));
|
||||
Assert.Equal(string.Empty, package["CpeUri"]);
|
||||
Assert.Equal(ToolCpe, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_StoreOnlyProgram_ThatAMappingCoversOrAnOperatorIgnores_IsToldThat()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Maps.Tables.AddInventory("fabrikam", "Fabrikam Gadget", "1.0.3", cpeUri: GadgetCpe, registrySource: "AppX");
|
||||
rig.Maps.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0.3", registrySource: "AppX");
|
||||
var ignore = CpeIgnoreEntry.Build(Launcher.NormalizedVendor, Launcher.SoftwareName, "manual")!.Value;
|
||||
rig.Maps.Tables.Put(CpeMappingStore.IgnoredPartition, ignore.RowKey, ignore.Properties);
|
||||
|
||||
var body = await rig.ResolveAsync(Gadget, Launcher);
|
||||
|
||||
// "Store package" would send the operator off to map by hand what is mapped or ignored already.
|
||||
Assert.Equal(
|
||||
new[] { (Gadget.SoftwareName, "already_mapped"), (Launcher.SoftwareName, "ignored") }, FailedOf(body));
|
||||
Assert.Empty(rig.AskedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_MappingsCannotBeRead_IsServiceUnavailableWithARetryHint_AndNothingIsAskedOrWritten()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Maps.Tables.ReadFailure = new InvalidOperationException("table storage is not reachable");
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.Request(
|
||||
new AutoResolveCpeMappingRequest { Items = new List<AutoResolveCpeMappingItem> { Widget, Tool } }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ServiceUnavailable, EndpointHarness.ErrorCode(response));
|
||||
Assert.Equal(new[] { "60" }, response.Headers.GetValues("Retry-After"));
|
||||
Assert.Empty(rig.AskedFor);
|
||||
Assert.Empty(rig.Maps.Tables.Writes);
|
||||
Assert.Empty(rig.Maps.Tables.InventoryWrites);
|
||||
}
|
||||
}
|
||||
@@ -111,17 +111,12 @@ public class BuiltInRulesTests
|
||||
Assert.Equal("high", sec003.Severity);
|
||||
Assert.Contains(sec003.Conditions, c => c.DataField == "checks.default_password_present");
|
||||
|
||||
// SEC-004: kiosk allow-list TEMPLATE — disabled until configured.
|
||||
var sec004 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-004");
|
||||
Assert.NotNull(sec004);
|
||||
Assert.False(sec004!.Enabled, "ANALYZE-SEC-004 is a template and must ship disabled");
|
||||
Assert.NotNull(sec004.TemplateVariables);
|
||||
var tv = Assert.Single(sec004.TemplateVariables);
|
||||
Assert.Equal("allowed_autologon_users", tv.Name);
|
||||
Assert.Equal(1, tv.ConditionIndex);
|
||||
Assert.Equal("value", tv.Field);
|
||||
Assert.Equal("not_in", sec004.Conditions[1].Operator);
|
||||
Assert.Equal("checks.default_user_name", sec004.Conditions[1].DataField);
|
||||
// SEC-004 (kiosk allow-list template) was removed. The ID stays retired, and no surviving
|
||||
// rule may match on the AutoLogon user name: the event reports a sign-in address as its
|
||||
// domain only, so an exact-match list no longer fits that field.
|
||||
Assert.DoesNotContain(rules, r => r.RuleId == "ANALYZE-SEC-004");
|
||||
Assert.DoesNotContain(rules, r =>
|
||||
r.Conditions != null && r.Conditions.Any(c => c.DataField == "checks.default_user_name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent gate never refuses on a cached configuration another instance's write has outdated:
|
||||
/// before a refusal the configuration decides, it reads the stored row and decides on that. An admin
|
||||
/// allows a manufacturer or switches a validation on, and the next device is admitted at once, on
|
||||
/// every instance. A request the cached copy admits must not cost a read — that is the hot path.
|
||||
/// The request comes from <see cref="CertTenantBindingTests"/>: a valid certificate of the sample
|
||||
/// tenant, manufacturer "Contoso", model "TestBook".
|
||||
/// </summary>
|
||||
public sealed class ConfigConfirmBeforeRefusalTests
|
||||
{
|
||||
private static readonly string TenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
|
||||
private sealed record Gate(SecurityValidator Validator, Mock<TenantConfigurationService> Config, Dictionary<object, object> Items);
|
||||
|
||||
/// <summary>A permissive tenant: no device validator needed, every hardware allowed.</summary>
|
||||
private static TenantConfiguration Admitting(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
AllowInsecureAgentRequests = true,
|
||||
ManufacturerWhitelist = "*",
|
||||
ModelWhitelist = "*",
|
||||
};
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="cached"/> is what this instance holds; <paramref name="stored"/> is what a read
|
||||
/// before a refusal returns (null: nothing to read, as inside the confirm interval). Like the real
|
||||
/// service, a confirming read makes the stored row the cached one.
|
||||
/// </summary>
|
||||
private static Gate Build(TenantConfiguration cached, TenantConfiguration? stored)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var current = cached;
|
||||
|
||||
var config = new Mock<TenantConfigurationService>(configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
config.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => (current, true));
|
||||
config.Setup(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>())).ReturnsAsync(() =>
|
||||
{
|
||||
if (stored != null) current = stored;
|
||||
return stored;
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
var validator = new SecurityValidator(
|
||||
configService: config.Object,
|
||||
adminConfigService: adminConfig.Object,
|
||||
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!,
|
||||
corporateIdentifierValidator: null!,
|
||||
logger: Mock.Of<ILogger>(),
|
||||
bootstrapSessionService: null,
|
||||
deviceAssociationValidator: null);
|
||||
return new Gate(validator, config, new Dictionary<object, object>());
|
||||
}
|
||||
|
||||
private static Task<SecurityValidationResult> Validate(Gate gate)
|
||||
=> gate.Validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items), TenantId);
|
||||
|
||||
private static string? Stamp(Gate gate)
|
||||
=> gate.Items.TryGetValue(RequestRowMarkers.ConfigConfirmKey, out var value) ? value as string : null;
|
||||
|
||||
// ---------------------------------------------------------------- a refusal is confirmed first
|
||||
|
||||
public static TheoryData<string, TenantConfiguration, string> OutdatedCopies() => new()
|
||||
{
|
||||
{ "manufacturer just allowed", Admitting(c => c.ManufacturerWhitelist = "Dell*"), "Hardware not authorized" },
|
||||
{ "model just allowed", Admitting(c => c.ModelWhitelist = "Latitude*"), "Hardware not authorized" },
|
||||
{ "validation requirement just met", Admitting(c => c.AllowInsecureAgentRequests = false), "Device validation is required" },
|
||||
{ "tenant just re-enabled", Admitting(c => { c.Disabled = true; c.DisabledReason = "paused"; }), "Tenant is suspended" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task The_stored_row_admits_what_the_outdated_copy_would_refuse(string _, TenantConfiguration outdated, string refusal)
|
||||
{
|
||||
var gate = Build(cached: outdated, stored: Admitting());
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.True(result.IsValid, $"refused with '{result.ErrorMessage}' although the stored row admits (outdated copy would say '{refusal}')");
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Averted, Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task The_refusal_stands_when_the_stored_row_says_the_same(string _, TenantConfiguration refusing, string refusal)
|
||||
{
|
||||
var gate = Build(cached: refusing, stored: ConfigRepoMockExtensions.Clone(refusing));
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
Assert.Equal(refusal, result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task Without_a_read_the_cached_copy_decides(string _, TenantConfiguration refusing, string refusal)
|
||||
{
|
||||
// Inside the confirm interval, or storage failed: nothing newer to decide on.
|
||||
var gate = Build(cached: refusing, stored: null);
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(refusal, result.ErrorMessage);
|
||||
Assert.Null(Stamp(gate));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the hot path stays untouched
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_the_cached_copy_admits_reads_nothing()
|
||||
{
|
||||
var gate = Build(cached: Admitting(), stored: Admitting());
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_the_configuration_does_not_decide_reads_nothing()
|
||||
{
|
||||
// A certificate of another tenant: the configuration admits, the certificate does not.
|
||||
var gate = Build(cached: Admitting(), stored: Admitting());
|
||||
|
||||
var result = await gate.Validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items),
|
||||
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Null(Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the device validation chain
|
||||
|
||||
[Fact]
|
||||
public async Task A_validation_switched_on_since_runs_the_chain_once_more_and_no_further()
|
||||
{
|
||||
// The Cloud PC validation is on in both rows, but this gate has no Cloud PC validator wired:
|
||||
// nothing admits the device, on either pass. What counts here is the control flow.
|
||||
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
|
||||
var stored = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; c.ValidateIntuneDeviceBinding = true; });
|
||||
var gate = Build(cached, stored);
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("Device not registered", result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Rerun, Stamp(gate));
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_chain_does_not_run_again_when_the_stored_row_asks_nothing_new()
|
||||
{
|
||||
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
|
||||
var gate = Build(cached, stored: ConfigRepoMockExtensions.Clone(cached));
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.Equal("Device not registered", result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_a_validation_that_was_off_and_is_now_on_asks_for_another_pass()
|
||||
{
|
||||
var used = new TenantConfiguration { ValidateAutopilotDevice = true };
|
||||
|
||||
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true }));
|
||||
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration()));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true, ValidateCorporateIdentifier = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateDeviceAssociation = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateCloudPcDevice = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateIntuneDeviceBinding = true }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the patch marker
|
||||
|
||||
[Fact]
|
||||
public void A_field_patch_counts_the_sent_fields_the_stored_row_already_held()
|
||||
{
|
||||
static PatchOutcome Applied(params string[] changed)
|
||||
=> new(true, PatchFailure.None, null, null, changed, new Dictionary<string, string>(), null);
|
||||
|
||||
// Wire names are camelCase, applied names are the model's property names.
|
||||
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "logLevel" }, Applied("LogLevel")));
|
||||
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
|
||||
new[] { "enableTimezoneAutoSet", "rebootOnComplete" }, Applied("RebootOnComplete")));
|
||||
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "diagnosticsUploadMode" }, Applied()));
|
||||
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
|
||||
new[] { "logLevel" }, PatchOutcome.Fail(PatchFailure.ValidationFailed, "invalid")));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent counts short-lived console processes and reports them as one console_script_activity
|
||||
/// per window. A script that keeps spawning them is not enrollment progress: the count never heals
|
||||
/// a Stalled session. A console the agent reports as such stays real activity.
|
||||
/// </summary>
|
||||
public class ConsoleScriptActivityClassificationTests
|
||||
{
|
||||
private static readonly DateTime At = new(2026, 11, 11, 14, 41, 35, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void A_count_of_script_processes_alone_is_no_real_activity()
|
||||
{
|
||||
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
|
||||
{
|
||||
Event(Constants.EventTypes.ConsoleScriptActivity),
|
||||
});
|
||||
|
||||
Assert.False(classification.HasNonPeriodicRealEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reported_console_is_real_activity()
|
||||
{
|
||||
var classification = EventIngestProcessor.ClassifyEvents(new List<EnrollmentEvent>
|
||||
{
|
||||
Event(Constants.EventTypes.ConsoleScriptActivity),
|
||||
Event(Constants.EventTypes.OobeConsoleSpawned),
|
||||
});
|
||||
|
||||
Assert.True(classification.HasNonPeriodicRealEvent);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Event(string eventType) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
Timestamp = At,
|
||||
EventType = eventType,
|
||||
Source = "ConsoleBypassWatcher",
|
||||
Data = new Dictionary<string, object> { ["count"] = 3 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Operator tool, not a check: reduces a downloaded NVD dictionary feed with the real reader and
|
||||
/// writes down what came out and what it cost, so a change to the reader or to NVD's feed can be
|
||||
/// measured before it ships.
|
||||
/// <code>AM_CPE_FEED_FILE=<path to nvdcpe-2.0.tar.gz> dotnet test --filter CpeDictionaryFeedCheckTests</code>
|
||||
/// writes <c><feed>.reduced.json</c> (product keys and titles) and <c><feed>.summary.json</c> next to
|
||||
/// the feed. Without the variable the test does nothing.
|
||||
/// </summary>
|
||||
public class CpeDictionaryFeedCheckTests
|
||||
{
|
||||
private const string FileVariable = "AM_CPE_FEED_FILE";
|
||||
|
||||
[Fact]
|
||||
public async Task Reduce_a_downloaded_feed()
|
||||
{
|
||||
var file = Environment.GetEnvironmentVariable(FileVariable);
|
||||
if (string.IsNullOrWhiteSpace(file)) return;
|
||||
|
||||
var heapBefore = GC.GetTotalMemory(forceFullCollection: true);
|
||||
long heapPeak = heapBefore;
|
||||
using var sampling = new CancellationTokenSource();
|
||||
var sampler = Task.Run(async () =>
|
||||
{
|
||||
while (!sampling.IsCancellationRequested)
|
||||
{
|
||||
heapPeak = Math.Max(heapPeak, GC.GetTotalMemory(forceFullCollection: false));
|
||||
await Task.Delay(20);
|
||||
}
|
||||
});
|
||||
|
||||
var watch = Stopwatch.StartNew();
|
||||
Dictionary<string, CpeDictionaryProduct> products;
|
||||
await using (var feed = File.OpenRead(file))
|
||||
products = await CpeDictionaryService.ReduceAsync(feed, CancellationToken.None);
|
||||
watch.Stop();
|
||||
sampling.Cancel();
|
||||
await sampler;
|
||||
|
||||
var stored = JsonSerializer.SerializeToUtf8Bytes(products);
|
||||
File.WriteAllText(file + ".reduced.json", JsonSerializer.Serialize(
|
||||
new SortedDictionary<string, string>(products.ToDictionary(p => p.Key, p => p.Value.T), StringComparer.Ordinal)));
|
||||
File.WriteAllText(file + ".summary.json", JsonSerializer.Serialize(new
|
||||
{
|
||||
products = products.Count,
|
||||
seconds = Math.Round(watch.Elapsed.TotalSeconds, 1),
|
||||
storedBytes = stored.Length,
|
||||
heapBeforeMb = heapBefore / 1024 / 1024,
|
||||
heapPeakMb = heapPeak / 1024 / 1024,
|
||||
processPeakWorkingSetMb = Process.GetCurrentProcess().PeakWorkingSet64 / 1024 / 1024,
|
||||
}));
|
||||
|
||||
Assert.True(products.Count >= CpeDictionaryService.MinProducts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeDictionaryResolver"/> gives a program an automatic mapping only when its name is
|
||||
/// exactly an NVD product name, a scan could report a CVE for that product, and none of those
|
||||
/// CVEs would be reported for every install whatever its version. It asks NVD once per product
|
||||
/// and keeps what it learned as the product's cache row, writes nothing for an answer NVD did
|
||||
/// not give, and a nightly pass stops asking when NVD stops answering or the pass has used its
|
||||
/// requests or its time. A program that occurs only as a Store package is left to the operator,
|
||||
/// and a cached row of the older shape is asked about again instead of being taken as an answer.
|
||||
/// </summary>
|
||||
[Collection(NvdTestState.Collection)]
|
||||
public sealed class CpeDictionaryResolverTests : IDisposable
|
||||
{
|
||||
private const string Auto = CpeMappingStore.AutoPartition;
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string GadgetCpe = "cpe:2.3:a:fabrikam:gadget";
|
||||
private const string ToolCpe = "cpe:2.3:a:northwind:tool";
|
||||
private const string ReaderCpe = "cpe:2.3:a:adatum:reader";
|
||||
|
||||
private static readonly (string ProductKey, string Title)[] Dictionary =
|
||||
{
|
||||
("contoso:widget", "Contoso Widget"),
|
||||
("fabrikam:gadget", "Fabrikam Gadget"),
|
||||
("northwind:tool", "Northwind Tool"),
|
||||
("adatum:reader", "Adatum Reader"),
|
||||
};
|
||||
|
||||
public CpeDictionaryResolverTests() => NvdTestState.Reset();
|
||||
|
||||
public void Dispose() => NvdTestState.Reset();
|
||||
|
||||
/// <summary>A rig in which the gadget is mapped by the seed and nothing else is.</summary>
|
||||
private static Task<MappingRig> RigAsync() => MappingRig.CreateAsync(
|
||||
VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", GadgetCpe, "fabrikam gadget"));
|
||||
|
||||
private static CpeDictionaryResolver ResolverOf(MappingRig rig) => rig.Resolver(CpeDictionaryFixture.Stored(Dictionary));
|
||||
|
||||
/// <summary>NVD lists one CVE for whatever product it is asked about.</summary>
|
||||
private static HttpResponseMessage OneCve(NvdRequest request) =>
|
||||
NvdFixture.Page(NvdFixture.Cve("CVE-2026-10001", 8.8, NvdFixture.Below(request.Cpe, "9.0")));
|
||||
|
||||
private static IEnumerable<string> AskedFor(MappingRig rig) => rig.Scan.Nvd.Requests.Select(r => r.Cpe);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// One name
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NoDictionaryStored_IsNoDictionary()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
var resolver = rig.Resolver(CpeDictionaryFixture.Stored());
|
||||
|
||||
var result = await resolver.ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NoDictionary, result.Outcome);
|
||||
Assert.False(result.AskedNvd);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NameThatIsExactlyAProductWithCves_GetsAnAutomaticMapping_AndItsFirstCacheFill()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await ResolverOf(rig).ResolveAsync("Contoso", "Contoso Widget 4.2 (x64)", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, result.Outcome);
|
||||
Assert.Equal(WidgetCpe, result.CpeUri);
|
||||
Assert.Equal("Contoso Widget", result.ProductTitle);
|
||||
Assert.True(result.AskedNvd);
|
||||
Assert.False(result.NvdDown);
|
||||
|
||||
// Filed under the vendor and the name without its version, so one row covers every version.
|
||||
var row = Assert.Single(rig.Tables.Rows(Auto));
|
||||
Assert.Equal(CpeSeedFile.RowKeyOf("contoso", "contoso widget"), row["RowKey"]);
|
||||
Assert.Equal("contoso", row["NormalizedVendor"]);
|
||||
Assert.Equal("contoso widget", row["NormalizedProduct"]);
|
||||
Assert.Equal("contoso", row["CpeVendor"]);
|
||||
Assert.Equal("widget", row["CpeProduct"]);
|
||||
Assert.Equal(WidgetCpe, row["CpeUri"]);
|
||||
Assert.Equal("auto", row["Source"]);
|
||||
Assert.Equal("Contoso Widget", row["ProductTitle"]);
|
||||
Assert.Equal("[]", row["DisplayNamePatternsJson"]);
|
||||
Assert.Equal("[]", row["PublisherPatternsJson"]);
|
||||
Assert.Equal("[]", row["ExcludePatternsJson"]);
|
||||
var createdAt = DateTime.Parse((string)row["CreatedAt"], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
Assert.InRange(createdAt, before, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(new[] { WidgetCpe }, AskedFor(rig));
|
||||
var cached = rig.Scan.CacheRows[CveCacheFiller.CacheKey(WidgetCpe)];
|
||||
Assert.Equal("filled", cached["State"]);
|
||||
Assert.Equal(1, cached["NvdTotal"]);
|
||||
|
||||
// The row is a mapping the matching reads, ranked as automatic.
|
||||
var match = (await rig.Store.ReloadAsync()).Matcher.Match("contoso", "contoso widget 5.0", "Contoso Widget 5.0", "Contoso Ltd");
|
||||
Assert.Equal(WidgetCpe, match!.Value.Mapping.CpeUri);
|
||||
Assert.Equal(CpeMappingSource.Auto, match.Value.Mapping.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_ProductNameWhoseVendorIsThePublisher_IsFiledUnderTheProgramsOwnVendor()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("northwind traders", "Tool 3.1", "Northwind Traders Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, result.Outcome);
|
||||
Assert.Equal(ToolCpe, result.CpeUri);
|
||||
var row = Assert.Single(rig.Tables.Rows(Auto));
|
||||
Assert.Equal("northwind traders:tool", row["RowKey"]);
|
||||
Assert.Equal("northwind traders", row["NormalizedVendor"]);
|
||||
Assert.Equal("tool", row["NormalizedProduct"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_ProductWhoseCveDataIsAlreadyCached_IsMappedWithoutAskingNvd()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
WidgetCpe, new[] { VulnerabilityCacheRows.Cve("CVE-2026-10002", 7.5, WidgetCpe, "9.0") }, DateTime.UtcNow.AddDays(-2), nvdTotal: 3));
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, result.Outcome);
|
||||
Assert.False(result.AskedNvd);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Single(rig.Tables.Rows(Auto));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NvdListsNoCve_IsNoCves_AndTheEmptyAnswerIsNotAskedForAgain()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Page();
|
||||
var resolver = ResolverOf(rig);
|
||||
|
||||
var first = await resolver.ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
var second = await resolver.ResolveAsync("contoso", "Contoso Widget 5.0", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NoCves, first.Outcome);
|
||||
Assert.Equal(WidgetCpe, first.CpeUri);
|
||||
Assert.True(first.AskedNvd);
|
||||
Assert.False(first.NvdDown);
|
||||
Assert.Equal(CpeAutoMapOutcome.NoCves, second.Outcome);
|
||||
Assert.False(second.AskedNvd);
|
||||
|
||||
// A mapping would only make the program look checked.
|
||||
Assert.Empty(rig.Tables.Rows(Auto));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
// The note is the product's cache row with nothing in it.
|
||||
Assert.Equal(new[] { WidgetCpe }, AskedFor(rig));
|
||||
var note = rig.Scan.CacheRows[CveCacheFiller.CacheKey(WidgetCpe)];
|
||||
Assert.Equal("filled", note["State"]);
|
||||
Assert.Equal(0, note["NvdTotal"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("the product is only the platform another product runs on")]
|
||||
[InlineData("the product's CVEs are older than the cache keeps")]
|
||||
public async Task Resolve_NvdCountsCvesButAScanCouldReportNone_IsNoCves_AndIsNotAskedForAgain(string why)
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = request => NvdFixture.Page(why switch
|
||||
{
|
||||
"the product is only the platform another product runs on" => NvdFixture.Cve(
|
||||
"CVE-2026-10003", 9.8,
|
||||
NvdFixture.Below(GadgetCpe, "3.0"),
|
||||
new NvdCpeMatch { Vulnerable = false, Criteria = $"{request.Cpe}:*:*:*:*:*:*:*:*" }),
|
||||
_ => NvdFixture.Cve("CVE-2026-10003", 9.8, DateTime.UtcNow.AddYears(-7), NvdFixture.Below(request.Cpe, "3.0")),
|
||||
});
|
||||
var resolver = ResolverOf(rig);
|
||||
|
||||
var first = await resolver.ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
var second = await resolver.ResolveAsync("contoso", "Contoso Widget 5.0", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NoCves, first.Outcome);
|
||||
Assert.True(first.AskedNvd);
|
||||
Assert.False(first.NvdDown);
|
||||
Assert.Equal(CpeAutoMapOutcome.NoCves, second.Outcome);
|
||||
Assert.False(second.AskedNvd);
|
||||
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Equal(new[] { WidgetCpe }, AskedFor(rig));
|
||||
// NVD's own count says one; what the product's row holds is what a scan can report.
|
||||
var note = rig.Scan.CacheRows[CveCacheFiller.CacheKey(WidgetCpe)];
|
||||
Assert.Equal(1, note["NvdTotal"]);
|
||||
Assert.Equal(0, note["TotalCves"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// The EPSS walk rewrites a row from before the total was recorded with a total of 0.
|
||||
[InlineData(0)]
|
||||
[InlineData(40)]
|
||||
public async Task Resolve_CachedRowWithCves_IsMapped_WhateverTotalItCarries(int nvdTotal)
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
WidgetCpe, new[] { VulnerabilityCacheRows.Cve("CVE-2026-10002", 7.5, WidgetCpe, "9.0") }, DateTime.UtcNow.AddDays(-2), nvdTotal));
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, result.Outcome);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_CachedRowWithoutCves_IsNoCves_WhateverTotalItCarries()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(WidgetCpe, Array.Empty<CachedCve>(), DateTime.UtcNow.AddDays(-2), nvdTotal: 40));
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NoCves, result.Outcome);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CVEs that name no version
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>NVD lists one CVE for whatever product it is asked about, and for every version of it.</summary>
|
||||
private static HttpResponseMessage OneUnversionedCve(NvdRequest request) =>
|
||||
NvdFixture.Page(NvdFixture.Cve("CVE-2026-10004", 9.8, NvdFixture.EveryVersion(request.Cpe)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("*")]
|
||||
[InlineData("windows")]
|
||||
public async Task Resolve_ProductWithACveThatNamesNoVersion_IsLeftToTheOperator_AndIsNotAskedForAgain(string targetSw)
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
// One record of that kind is enough, next to any number of ordinary ones.
|
||||
rig.Scan.NvdAnswer = request => NvdFixture.Page(
|
||||
NvdFixture.Cve("CVE-2026-10001", 8.8, NvdFixture.Below(request.Cpe, "9.0")),
|
||||
NvdFixture.Cve("CVE-2026-10004", 9.8, NvdFixture.EveryVersion(request.Cpe, targetSw)));
|
||||
var resolver = ResolverOf(rig);
|
||||
|
||||
var first = await resolver.ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
var second = await resolver.ResolveAsync("contoso", "Contoso Widget 5.0", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.UnversionedCves, first.Outcome);
|
||||
Assert.Equal(WidgetCpe, first.CpeUri);
|
||||
Assert.Equal("Contoso Widget", first.ProductTitle);
|
||||
Assert.True(first.AskedNvd);
|
||||
Assert.False(first.NvdDown);
|
||||
Assert.Equal(CpeAutoMapOutcome.UnversionedCves, second.Outcome);
|
||||
Assert.False(second.AskedNvd);
|
||||
|
||||
// With a mapping, every install of the program would carry that CVE, whatever its version.
|
||||
Assert.Empty(rig.Tables.Rows(Auto));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
// The answer is kept as the product's cache row, so asking again costs no request.
|
||||
Assert.Equal(new[] { WidgetCpe }, AskedFor(rig));
|
||||
var kept = rig.Scan.CacheRows[CveCacheFiller.CacheKey(WidgetCpe)];
|
||||
Assert.Equal("filled", kept["State"]);
|
||||
Assert.Equal(2, kept["TotalCves"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a range with an end")]
|
||||
[InlineData("a range with a start")]
|
||||
[InlineData("one version")]
|
||||
// NVD's "-" stands for a version it does not know; no install is reported for it.
|
||||
[InlineData("the unknown version")]
|
||||
// Never reported for an install on Windows, so it cannot become a false alarm there.
|
||||
[InlineData("every version on another platform")]
|
||||
public async Task Resolve_ProductWhoseCveIsNotReportedForEveryInstall_IsMapped(string range)
|
||||
{
|
||||
var match = range switch
|
||||
{
|
||||
"a range with an end" => NvdFixture.Below(WidgetCpe, "9.0"),
|
||||
"a range with a start" => new NvdCpeMatch { Vulnerable = true, Criteria = $"{WidgetCpe}:*:*:*:*:*:*:*:*", VersionStartIncluding = "2.0" },
|
||||
"one version" => new NvdCpeMatch { Vulnerable = true, Criteria = $"{WidgetCpe}:4.2:*:*:*:*:*:*:*" },
|
||||
"the unknown version" => new NvdCpeMatch { Vulnerable = true, Criteria = $"{WidgetCpe}:-:*:*:*:*:*:*:*" },
|
||||
_ => NvdFixture.EveryVersion(WidgetCpe, "android"),
|
||||
};
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Page(NvdFixture.Cve("CVE-2026-10005", 9.8, match));
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, result.Outcome);
|
||||
Assert.Single(rig.Tables.Rows(Auto));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_CachedCveThatNamesNoVersion_IsFoundWithoutAskingNvd()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
WidgetCpe, new[] { VulnerabilityCacheRows.CveOfEveryVersion("CVE-2026-10004", 9.8, WidgetCpe) }, DateTime.UtcNow.AddDays(-2), nvdTotal: 1));
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.UnversionedCves, result.Outcome);
|
||||
Assert.False(result.AskedNvd);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Without asking NVD
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NotAskingNvd_ProductWithoutCachedCves_IsUnanswered_AndNothingIsSentOrNoted()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd", askNvd: false);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NvdUnavailable, result.Outcome);
|
||||
Assert.Equal(WidgetCpe, result.CpeUri);
|
||||
Assert.False(result.AskedNvd);
|
||||
// Nothing was learned about NVD: what the caller knows about it stays what it was.
|
||||
Assert.False(result.NvdDown);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Scan.CacheRows);
|
||||
Assert.Empty(rig.Scan.PendingMarkers);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NotAskingNvd_StillSaysWhateverNeedsNoRequest()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
var ignore = CpeIgnoreEntry.Build("northwind", "Northwind Tool", "manual")!.Value;
|
||||
rig.Tables.Put(CpeMappingStore.IgnoredPartition, ignore.RowKey, ignore.Properties);
|
||||
var resolver = ResolverOf(rig);
|
||||
|
||||
var alreadyMapped = await resolver.ResolveAsync("fabrikam", "Fabrikam Gadget 2.0", "Fabrikam Inc", askNvd: false);
|
||||
var ignored = await resolver.ResolveAsync("northwind", "Northwind Tool 3.1", "Northwind Traders", askNvd: false);
|
||||
var noProduct = await resolver.ResolveAsync("tailspin", "Tailspin Launcher", "Tailspin Toys", askNvd: false);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.AlreadyMapped, alreadyMapped.Outcome);
|
||||
Assert.Equal(CpeAutoMapOutcome.Ignored, ignored.Outcome);
|
||||
Assert.Equal(CpeAutoMapOutcome.NoExactProduct, noProduct.Outcome);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NotAskingNvd_ProductWhoseCvesAreCached_GetsTheAnswerTheCacheGives()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
var cachedAt = DateTime.UtcNow.AddDays(-2);
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
WidgetCpe, new[] { VulnerabilityCacheRows.Cve("CVE-2026-10002", 7.5, WidgetCpe, "9.0") }, cachedAt, nvdTotal: 1));
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
ToolCpe, new[] { VulnerabilityCacheRows.CveOfEveryVersion("CVE-2026-10004", 9.8, ToolCpe) }, cachedAt, nvdTotal: 1));
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(ReaderCpe, Array.Empty<CachedCve>(), cachedAt));
|
||||
var resolver = ResolverOf(rig);
|
||||
|
||||
var mapped = await resolver.ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd", askNvd: false);
|
||||
var unversioned = await resolver.ResolveAsync("northwind", "Northwind Tool 3.1", "Northwind Traders", askNvd: false);
|
||||
var noCves = await resolver.ResolveAsync("adatum", "Adatum Reader 2.0", "Adatum Corporation", askNvd: false);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, mapped.Outcome);
|
||||
Assert.Equal(CpeAutoMapOutcome.UnversionedCves, unversioned.Outcome);
|
||||
Assert.Equal(CpeAutoMapOutcome.NoCves, noCves.Outcome);
|
||||
Assert.Equal(new[] { "contoso:contoso widget" }, rig.Tables.RowKeys(Auto));
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError, true)]
|
||||
[InlineData(HttpStatusCode.TooManyRequests, true)]
|
||||
// The gateway in front of NVD refusing the client leaves the next request no better off than a throttle.
|
||||
[InlineData(HttpStatusCode.Forbidden, true)]
|
||||
// NVD answered and turned this one request down: that says nothing about the next product.
|
||||
[InlineData(HttpStatusCode.NotFound, false)]
|
||||
public async Task Resolve_NvdGivesNoUsableAnswer_IsUnavailable_AndWritesNothing(HttpStatusCode status, bool nvdDown)
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Status(status);
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NvdUnavailable, result.Outcome);
|
||||
Assert.Equal(nvdDown, result.NvdDown);
|
||||
Assert.True(result.AskedNvd);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Empty(rig.Scan.CacheWrites);
|
||||
Assert.Empty(rig.Scan.CacheRows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_AfterAnAnswerNvdDidNotGive_TheNameIsTriedAgain()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Status(HttpStatusCode.InternalServerError);
|
||||
var resolver = ResolverOf(rig);
|
||||
await resolver.ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
var result = await resolver.ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, result.Outcome);
|
||||
Assert.Equal(new[] { WidgetCpe, WidgetCpe }, AskedFor(rig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NameAMappingAlreadyCovers_IsAlreadyMapped()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("fabrikam", "Fabrikam Gadget 2.0", "Fabrikam Inc");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.AlreadyMapped, result.Outcome);
|
||||
Assert.Equal(GadgetCpe, result.CpeUri);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_NameOnTheIgnoreList_IsIgnored()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
var ignore = CpeIgnoreEntry.Build("contoso", "Contoso Widget 4.2 (x64)", "manual")!.Value;
|
||||
rig.Tables.Put(CpeMappingStore.IgnoredPartition, ignore.RowKey, ignore.Properties);
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 5.0", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Ignored, result.Outcome);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("contoso", "Contoso Widget Pro 4.2", "Contoso Ltd")] // one word more than the product
|
||||
[InlineData("tailspin", "Widget 4.2", "Tailspin Toys")] // the product's name under another vendor
|
||||
[InlineData("tailspin", "Tailspin Launcher", "Tailspin Toys")] // not in the dictionary at all
|
||||
[InlineData("contoso", "4.2 (x64)", "Contoso Ltd")] // no name left
|
||||
public async Task Resolve_NameThatIsNotExactlyAProduct_IsNoExactProduct(string vendor, string displayName, string publisher)
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync(vendor, displayName, publisher);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NoExactProduct, result.Outcome);
|
||||
Assert.Null(result.CpeUri);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The nightly pass over the inventory
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_NoDictionaryStored_SaysSoAndAsksNothing()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2");
|
||||
|
||||
var run = await rig.Resolver(CpeDictionaryFixture.Stored()).ResolveInventoryAsync();
|
||||
|
||||
Assert.True(run.NoDictionary);
|
||||
Assert.Equal(0, run.UnmappedNames);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_TriesTheMostUsedNamesFirst_WithTheSessionsOfAllRowsOfAName()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0", sessions: 5);
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 1.0", "1.0", sessions: 3);
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 2.0", "2.0", sessions: 4);
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", sessions: 6);
|
||||
// Used most of all, but mapped already, or no product NVD knows: neither costs a request.
|
||||
rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget 1.0", "1.0", sessions: 100);
|
||||
rig.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0", sessions: 50);
|
||||
var resolver = ResolverOf(rig);
|
||||
var heldBefore = await rig.Store.GetAsync();
|
||||
|
||||
var run = await resolver.ResolveInventoryAsync();
|
||||
|
||||
Assert.False(run.NoDictionary);
|
||||
Assert.Equal(4, run.UnmappedNames);
|
||||
Assert.Equal(3, run.Mapped);
|
||||
Assert.Equal(0, run.NoCves);
|
||||
Assert.Equal(0, run.NvdUnavailable);
|
||||
Assert.Equal(0, run.Postponed);
|
||||
// Widget: 3 + 4 sessions, ahead of the tool with 6 and the reader with 5.
|
||||
Assert.Equal(new[] { WidgetCpe, ToolCpe, ReaderCpe }, AskedFor(rig));
|
||||
Assert.Equal(
|
||||
new[] { "adatum:adatum reader", "contoso:contoso widget", "northwind:northwind tool" }, rig.Tables.RowKeys(Auto));
|
||||
|
||||
// This instance's copy of the mappings is dropped, so its next reader sees the new ones.
|
||||
var heldAfter = await rig.Store.GetAsync();
|
||||
Assert.NotSame(heldBefore, heldAfter);
|
||||
Assert.NotNull(heldAfter.Matcher.Match("northwind", "northwind tool 3.1", "Northwind Tool 3.1", "northwind"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_StopsAskingOnceNvdLeftTwoRequestsUnanswered_AndPostponesTheRest()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Status(HttpStatusCode.InternalServerError);
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2", sessions: 9);
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", sessions: 7);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0", sessions: 5);
|
||||
rig.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0", sessions: 3);
|
||||
|
||||
var run = await ResolverOf(rig).ResolveInventoryAsync();
|
||||
|
||||
Assert.Equal(2, CpeDictionaryResolver.MaxUnansweredPerRun);
|
||||
Assert.Equal(new[] { WidgetCpe, ToolCpe }, AskedFor(rig));
|
||||
Assert.Equal(2, run.NvdUnavailable);
|
||||
// Only the reader waits for the next pass: the launcher is no product NVD could be asked about.
|
||||
Assert.Equal(1, run.Postponed);
|
||||
Assert.Equal(0, run.Mapped);
|
||||
Assert.Equal(4, run.UnmappedNames);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_RequestNvdTurnsDown_DoesNotEndThePass()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = request => request.Cpe == ReaderCpe ? OneCve(request) : NvdFixture.Status(HttpStatusCode.NotFound);
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2", sessions: 9);
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", sessions: 7);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0", sessions: 5);
|
||||
|
||||
var run = await ResolverOf(rig).ResolveInventoryAsync();
|
||||
|
||||
Assert.Equal(new[] { WidgetCpe, ToolCpe, ReaderCpe }, AskedFor(rig));
|
||||
Assert.Equal(2, run.NvdUnavailable);
|
||||
Assert.Equal(1, run.Mapped);
|
||||
Assert.Equal(0, run.Postponed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_SendsNoMoreThanTheRequestsOfOnePass_AndTakesUpTheRestInTheNext()
|
||||
{
|
||||
const int extra = 7;
|
||||
var names = Enumerable.Range(0, CpeDictionaryResolver.MaxNvdRequestsPerRun + extra)
|
||||
.Select(n => $"{(char)('a' + n / 26)}{(char)('a' + n % 26)}")
|
||||
.ToList();
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Page();
|
||||
var resolver = rig.Resolver(CpeDictionaryFixture.Stored(
|
||||
names.Select(name => ($"contoso:tool_{name}", $"Contoso Tool {name}")).ToArray()));
|
||||
// Each name is used a little less than the one before it.
|
||||
for (var n = 0; n < names.Count; n++)
|
||||
rig.Tables.AddInventory("contoso", $"Contoso Tool {names[n]} 1.0", "1.0", sessions: 1000 - n);
|
||||
rig.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0", sessions: 5000);
|
||||
var expectedOrder = names.Select(name => $"cpe:2.3:a:contoso:tool_{name}").ToList();
|
||||
|
||||
var first = await resolver.ResolveInventoryAsync();
|
||||
|
||||
Assert.Equal(names.Count + 1, first.UnmappedNames);
|
||||
Assert.Equal(CpeDictionaryResolver.MaxNvdRequestsPerRun, first.NoCves);
|
||||
Assert.Equal(extra, first.Postponed);
|
||||
Assert.Equal(expectedOrder.Take(CpeDictionaryResolver.MaxNvdRequestsPerRun), AskedFor(rig));
|
||||
|
||||
var second = await resolver.ResolveInventoryAsync();
|
||||
|
||||
// What the first pass learned costs no request now, so the pass reaches the rest.
|
||||
Assert.Equal(names.Count, second.NoCves);
|
||||
Assert.Equal(0, second.Postponed);
|
||||
Assert.Equal(expectedOrder, AskedFor(rig));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
public async Task ResolveInventory_NvdRefusesTheClient_SendsNothingMoreThisPass(HttpStatusCode status)
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Status(status);
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2", sessions: 9);
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", sessions: 7);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0", sessions: 5);
|
||||
|
||||
var run = await ResolverOf(rig).ResolveInventoryAsync();
|
||||
|
||||
// The second product is turned away by the client's own cooldown, without a request.
|
||||
Assert.Equal(new[] { WidgetCpe }, AskedFor(rig));
|
||||
Assert.Equal(0, run.Mapped);
|
||||
Assert.Equal(3, run.NvdUnavailable + run.Postponed);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Empty(rig.Scan.CacheRows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_PassWithoutTimeLeft_AsksNothing_AndPostponesEveryProductItWouldHaveAskedAbout()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2", sessions: 9);
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", sessions: 7);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader", "1.0.3", sessions: 5, registrySource: "AppX");
|
||||
rig.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0", sessions: 3);
|
||||
|
||||
var run = await rig.Resolver(CpeDictionaryFixture.Stored(Dictionary), runBudget: TimeSpan.Zero).ResolveInventoryAsync();
|
||||
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Equal(4, run.UnmappedNames);
|
||||
// Only what waits for a request is postponed: the launcher is no product NVD could be
|
||||
// asked about, and the reader is a Store package whatever time the pass has.
|
||||
Assert.Equal(2, run.Postponed);
|
||||
Assert.Equal(1, run.StorePackages);
|
||||
Assert.Equal(0, run.Mapped);
|
||||
Assert.Equal(0, run.NvdUnavailable);
|
||||
Assert.Empty(run.Created);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_ListsEachMappingItCreated_AsProgramAndProduct_MostUsedFirst()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = request => request.Cpe == ReaderCpe ? NvdFixture.Page() : OneCve(request);
|
||||
rig.Tables.AddInventory("northwind traders", "Tool 3.1", "3.1", sessions: 7, publisher: "Northwind Traders Ltd");
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2 (x64)", "4.2", sessions: 9);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0", sessions: 8);
|
||||
|
||||
var run = await ResolverOf(rig).ResolveInventoryAsync();
|
||||
|
||||
Assert.Equal(2, run.Mapped);
|
||||
Assert.Equal(1, run.NoCves);
|
||||
// The program by its name without version and architecture, the product as vendor:product.
|
||||
Assert.Equal(new[] { "contoso widget -> contoso:widget", "tool -> northwind:tool" }, run.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_CountsProductsLeftForACveWithoutVersion_AndDoesNotAskAboutThemTheNextNight()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = request => request.Cpe == WidgetCpe ? OneUnversionedCve(request) : OneCve(request);
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2", sessions: 9);
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", sessions: 7);
|
||||
var resolver = ResolverOf(rig);
|
||||
|
||||
var first = await resolver.ResolveInventoryAsync();
|
||||
var second = await resolver.ResolveInventoryAsync();
|
||||
|
||||
Assert.Equal(1, first.Unversioned);
|
||||
Assert.Equal(1, first.Mapped);
|
||||
Assert.Equal(new[] { "northwind tool -> northwind:tool" }, first.Created);
|
||||
Assert.Equal(new[] { "northwind:northwind tool" }, rig.Tables.RowKeys(Auto));
|
||||
|
||||
// The widget is still unmapped, and still for the same reason; the tool is mapped now.
|
||||
Assert.Equal(1, second.UnmappedNames);
|
||||
Assert.Equal(1, second.Unversioned);
|
||||
Assert.Equal(0, second.Mapped);
|
||||
Assert.Empty(second.Created);
|
||||
Assert.Equal(new[] { WidgetCpe, ToolCpe }, AskedFor(rig));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Store packages
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveInventory_ProgramThatOccursOnlyAsAStorePackage_IsLeftToTheOperator_WithoutARequest()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
// A package carries the version of its packaging, which need not be the product's.
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget", "1.0.3", sessions: 9, registrySource: "AppX");
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget", "1.0.4", sessions: 2, registrySource: "AppX");
|
||||
// Also installed the classic way on some devices: those rows carry the product's own version.
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "1.0.3", sessions: 7, registrySource: "AppX");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", sessions: 1);
|
||||
// A package whose name is no NVD product is not counted: nothing was held back for it.
|
||||
rig.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0", sessions: 3, registrySource: "AppX");
|
||||
|
||||
var run = await ResolverOf(rig).ResolveInventoryAsync();
|
||||
|
||||
Assert.Equal(3, run.UnmappedNames);
|
||||
Assert.Equal(1, run.StorePackages);
|
||||
Assert.Equal(1, run.Mapped);
|
||||
Assert.Equal(0, run.Postponed);
|
||||
Assert.Equal(new[] { ToolCpe }, AskedFor(rig));
|
||||
Assert.Equal(new[] { "northwind:northwind tool" }, rig.Tables.RowKeys(Auto));
|
||||
Assert.DoesNotContain(CveCacheFiller.CacheKey(WidgetCpe), rig.Scan.CacheRows.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreOnlyKeys_AreReadFromTheInventory_ForACallerThatResolvesSingleNames()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget", "1.0.3", registrySource: "AppX");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "1.0.3", registrySource: "AppX");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1");
|
||||
|
||||
var keys = await ResolverOf(rig).StoreOnlyKeysAsync();
|
||||
|
||||
Assert.Equal(new[] { CpeMappingMatcher.SoftwareKey("contoso", "Contoso Widget") }, keys);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// A cached row of the older shape
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A row as it was written before only the product's own ranges were kept: what it holds for
|
||||
/// the widget is a CVE of the gadget, which merely names the widget next to it.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> OlderWidgetRow() => VulnerabilityCacheRows.OlderShape(
|
||||
WidgetCpe, new[] { VulnerabilityCacheRows.Cve("CVE-2026-10006", 7.5, GadgetCpe, "9.0") }, DateTime.UtcNow.AddDays(-20));
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_CachedRowOfTheOlderShape_IsNoAnswer_NvdIsAsked_AndTheRowRewritten()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.Cache(OlderWidgetRow());
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Page();
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
// Taken at its word, the row would have said "this product has CVEs".
|
||||
Assert.Equal(CpeAutoMapOutcome.NoCves, result.Outcome);
|
||||
Assert.True(result.AskedNvd);
|
||||
Assert.Equal(new[] { WidgetCpe }, AskedFor(rig));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
var rewritten = rig.Scan.CacheRows[CveCacheFiller.CacheKey(WidgetCpe)];
|
||||
Assert.Equal(CveCacheFiller.SchemaVersion, rewritten["SchemaVersion"]);
|
||||
Assert.Equal(0, rewritten["TotalCves"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_CachedRowOfTheOlderShape_ProductWithCvesOfItsOwn_IsMappedFromTheFreshAnswer()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.Cache(OlderWidgetRow());
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd");
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.Mapped, result.Outcome);
|
||||
Assert.Equal(new[] { WidgetCpe }, AskedFor(rig));
|
||||
Assert.Single(rig.Tables.Rows(Auto));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(null)]
|
||||
public async Task Resolve_CachedRowOfTheOlderShape_WithoutAnAnswerFromNvd_IsUnavailable_AndTheRowStays(HttpStatusCode? nvdStatus)
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
var older = OlderWidgetRow();
|
||||
rig.Scan.Cache(older);
|
||||
rig.Scan.NvdAnswer = _ => NvdFixture.Status(nvdStatus ?? HttpStatusCode.OK);
|
||||
|
||||
// Without a status NVD is not asked at all.
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget 4.2", "Contoso Ltd", askNvd: nvdStatus != null);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.NvdUnavailable, result.Outcome);
|
||||
Assert.Equal(nvdStatus != null, result.AskedNvd);
|
||||
Assert.Equal(nvdStatus == null ? 0 : 1, rig.Scan.Nvd.Requests.Count);
|
||||
// A scan of a program a mapping names goes on reading the row until the walk rewrites it.
|
||||
Assert.Same(older, rig.Scan.CacheRows[CveCacheFiller.CacheKey(WidgetCpe)]);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// A caller that says the program is a Store package
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_StorePackage_GetsNoMapping_AndCostsNoRequest()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.NvdAnswer = OneCve;
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget", "Contoso Ltd", storePackage: true);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.StorePackage, result.Outcome);
|
||||
Assert.Equal(WidgetCpe, result.CpeUri);
|
||||
Assert.Equal("Contoso Widget", result.ProductTitle);
|
||||
Assert.False(result.AskedNvd);
|
||||
Assert.False(result.NvdDown);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
Assert.Empty(rig.Scan.CacheReads);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_StorePackage_WhoseCvesAreCached_StillGetsNoMapping()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(
|
||||
WidgetCpe, new[] { VulnerabilityCacheRows.Cve("CVE-2026-10002", 7.5, WidgetCpe, "9.0") }, DateTime.UtcNow.AddDays(-2), nvdTotal: 1));
|
||||
|
||||
var result = await ResolverOf(rig).ResolveAsync("contoso", "Contoso Widget", "Contoso Ltd", storePackage: true);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.StorePackage, result.Outcome);
|
||||
Assert.Empty(rig.Tables.Rows(Auto));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_StorePackage_IsSaidOnlyAfterWhatIsKnownAboutTheProgramAnyway()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
var ignore = CpeIgnoreEntry.Build("northwind", "Northwind Tool", "manual")!.Value;
|
||||
rig.Tables.Put(CpeMappingStore.IgnoredPartition, ignore.RowKey, ignore.Properties);
|
||||
var resolver = ResolverOf(rig);
|
||||
|
||||
// A curated mapping covers a Store package knowingly, and an operator's "ignore" stands.
|
||||
var alreadyMapped = await resolver.ResolveAsync("fabrikam", "Fabrikam Gadget", "Fabrikam Inc", storePackage: true);
|
||||
var ignored = await resolver.ResolveAsync("northwind", "Northwind Tool", "Northwind Traders", storePackage: true);
|
||||
var noProduct = await resolver.ResolveAsync("tailspin", "Tailspin Launcher", "Tailspin Toys", storePackage: true);
|
||||
|
||||
Assert.Equal(CpeAutoMapOutcome.AlreadyMapped, alreadyMapped.Outcome);
|
||||
Assert.Equal(GadgetCpe, alreadyMapped.CpeUri);
|
||||
Assert.Equal(CpeAutoMapOutcome.Ignored, ignored.Outcome);
|
||||
Assert.Equal(CpeAutoMapOutcome.NoExactProduct, noProduct.Outcome);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeDictionary"/> answers one question: is this program name an NVD product name?
|
||||
/// A name resolves only when its words are the product's words and the vendor agrees, or when
|
||||
/// they are the vendor's words followed by the product's, and only when exactly one product fits.
|
||||
/// There is no similarity search: a near miss stays unresolved.
|
||||
/// </summary>
|
||||
public class CpeDictionaryTests
|
||||
{
|
||||
private static CpeDictionary Dictionary(params string[] productKeys) => new(
|
||||
productKeys.ToDictionary(key => key, key => new CpeDictionaryProduct { T = $"Title of {key}" }, StringComparer.Ordinal),
|
||||
"2026-11-11T03:00:00.0000000Z");
|
||||
|
||||
private static string? Resolved(CpeDictionary dictionary, string name, string? vendor = null, string? publisher = null) =>
|
||||
dictionary.Resolve(name, vendor, publisher)?.CpeUri;
|
||||
|
||||
[Theory]
|
||||
[InlineData("contoso", null)]
|
||||
[InlineData(null, "Contoso")]
|
||||
[InlineData("contoso inc", "")]
|
||||
[InlineData("", "Contoso Ltd.")]
|
||||
[InlineData("fabrikam", "The Contoso Company")] // either of the program's two vendor forms may agree
|
||||
[InlineData("contoso software", null)] // a trailing word after the vendor's name
|
||||
public void Resolve_NameThatIsTheProductName_ResolvesWhenTheProgramsVendorAgrees(string? vendor, string? publisher)
|
||||
{
|
||||
var match = Dictionary("contoso:widget").Resolve("widget", vendor, publisher);
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.Equal("contoso", match!.Value.Vendor);
|
||||
Assert.Equal("widget", match.Value.Product);
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget", match.Value.CpeUri);
|
||||
Assert.Equal("Title of contoso:widget", match.Value.Title);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fabrikam", "Fabrikam Inc")]
|
||||
[InlineData("contosoft", null)]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", "")]
|
||||
[InlineData("inc", "Ltd")] // nothing is left of a vendor that is only a legal form
|
||||
public void Resolve_NameThatIsTheProductName_DoesNotResolveWithoutAnAgreeingVendor(string? vendor, string? publisher) =>
|
||||
Assert.Null(Dictionary("contoso:widget").Resolve("widget", vendor, publisher));
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SameProductNameUnderTwoVendors_TakesTheOneWhoseVendorAgrees()
|
||||
{
|
||||
var dictionary = Dictionary("contoso:reader", "fabrikam:reader");
|
||||
|
||||
Assert.Equal("cpe:2.3:a:fabrikam:reader", Resolved(dictionary, "reader", "fabrikam"));
|
||||
Assert.Equal("cpe:2.3:a:contoso:reader", Resolved(dictionary, "reader", publisher: "Contoso Ltd"));
|
||||
Assert.Null(Resolved(dictionary, "reader"));
|
||||
// Vendor and publisher each name one of the two: the name is not decided.
|
||||
Assert.Null(Resolved(dictionary, "reader", "contoso", "Fabrikam Inc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_VendorWordsFollowedByProductWords_ResolveWithoutAVendor()
|
||||
{
|
||||
var dictionary = Dictionary("contoso:widget", "northwind_traders:tool_box");
|
||||
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget", Resolved(dictionary, "contoso widget"));
|
||||
Assert.Equal("cpe:2.3:a:northwind_traders:tool_box", Resolved(dictionary, "northwind traders tool box"));
|
||||
// The product's words first is another name.
|
||||
Assert.Null(Resolved(dictionary, "widget contoso"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_TwoProductsThatFit_ResolveToNothing()
|
||||
{
|
||||
var sameWordsSplitDifferently = Dictionary("contoso:widget_pro", "contoso_widget:pro");
|
||||
var productNameAndVendorPlusProduct = Dictionary("adatum:contoso_widget", "contoso:widget");
|
||||
|
||||
Assert.Null(Resolved(sameWordsSplitDifferently, "contoso widget pro"));
|
||||
// Adatum's "contoso widget" and Contoso's "widget" both read "contoso widget".
|
||||
Assert.Null(Resolved(productNameAndVendorPlusProduct, "contoso widget", "adatum"));
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget", Resolved(productNameAndVendorPlusProduct, "contoso widget"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("widget", "contoso")] // one word short
|
||||
[InlineData("widget pro max", "contoso")] // one word more
|
||||
[InlineData("pro widget", "contoso")] // other order
|
||||
[InlineData("contoso widget", null)]
|
||||
[InlineData("contoso widget pro 2", null)]
|
||||
[InlineData("contoso widgets pro", null)] // nearly the same word
|
||||
public void Resolve_NameWithAWordMoreOrLess_DoesNotResolve(string name, string? vendor)
|
||||
{
|
||||
var dictionary = Dictionary("contoso:widget_pro");
|
||||
|
||||
Assert.Null(Resolved(dictionary, name, vendor));
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget_pro", Resolved(dictionary, "widget pro", "contoso"));
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget_pro", Resolved(dictionary, "contoso widget pro"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CpeEscapesAndUnderscores_AreReadAsTheWordsTheyStandFor()
|
||||
{
|
||||
var dictionary = Dictionary(@"fabrikam:notepad\+\+", "contoso:visual_studio_code", "contoso:node.js", "northwind_traders:x-ray");
|
||||
|
||||
var escaped = dictionary.Resolve("notepad++", "fabrikam", null);
|
||||
Assert.Equal(@"notepad\+\+", escaped!.Value.Product);
|
||||
Assert.Equal(@"cpe:2.3:a:fabrikam:notepad\+\+", escaped.Value.CpeUri);
|
||||
Assert.Equal(@"cpe:2.3:a:fabrikam:notepad\+\+", Resolved(dictionary, "fabrikam notepad++"));
|
||||
// The plus signs are part of the word: "notepad" alone is another name.
|
||||
Assert.Null(Resolved(dictionary, "notepad", "fabrikam"));
|
||||
|
||||
Assert.Equal("cpe:2.3:a:contoso:visual_studio_code", Resolved(dictionary, "visual studio code", "contoso"));
|
||||
Assert.Equal("cpe:2.3:a:contoso:visual_studio_code", Resolved(dictionary, "Contoso Visual Studio Code"));
|
||||
Assert.Null(Resolved(dictionary, "visual studio", "contoso"));
|
||||
|
||||
Assert.Equal("cpe:2.3:a:contoso:node.js", Resolved(dictionary, "node.js", "contoso"));
|
||||
Assert.Equal("cpe:2.3:a:northwind_traders:x-ray", Resolved(dictionary, "X-Ray", publisher: "Northwind Traders Ltd"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("--")]
|
||||
public void Resolve_NameWithoutAWord_IsNothing(string name) =>
|
||||
Assert.Null(Dictionary("contoso:widget").Resolve(name, "contoso", "Contoso Ltd"));
|
||||
|
||||
[Fact]
|
||||
public void Entries_WithoutAVendorOrAProduct_AreNeverResolved_AndDoNotGetInTheWayOfTheOthers()
|
||||
{
|
||||
var dictionary = Dictionary("widget", ":widget", "contoso:", "contoso:-", "contoso:reader");
|
||||
|
||||
Assert.Equal("2026-11-11T03:00:00.0000000Z", dictionary.LastRefresh);
|
||||
Assert.Null(Resolved(dictionary, "widget", "contoso"));
|
||||
Assert.Null(Resolved(dictionary, "contoso"));
|
||||
Assert.Equal("cpe:2.3:a:contoso:reader", Resolved(dictionary, "reader", "contoso"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Operator tool, not a check: runs an exported software inventory through the production
|
||||
/// matcher and, when NVD answers are at hand, through the production projection, budget and
|
||||
/// version filter. A change to the mapping list can so be compared with what is in service
|
||||
/// before it ships, and a product where nearly every install has a finding can be looked at by hand.
|
||||
/// <code>AM_CPE_DIFF_DIR=<dir> dotnet test --filter CpeMappingExportMatchTests</code>
|
||||
/// reads <c>inventory.json</c> (an array of SoftwareInventory rows) from the directory and works
|
||||
/// once per mapping set: every <c>mappings*.json</c> is an array of mapping table rows, every
|
||||
/// <c>seed*.json</c> a mapping list file as the rules repository holds it. The rows of an optional
|
||||
/// <c>next-to-seed.json</c> (table rows, an operator's own mappings for example) join every list
|
||||
/// file. Per set it writes <c>matches-<file>.json</c> and, when the directory has an
|
||||
/// <c>nvd</c> folder, <c>scan-<file>.json</c>; both hold one entry per inventory row, in the
|
||||
/// same order. A file in <c>nvd</c> is one NVD 2.0 answer for a product, named like its CPE in
|
||||
/// lower case with every character outside <c>a-z 0-9 . _ + -</c> replaced by an underscore.
|
||||
/// Without the variable the test does nothing. An export holds tenant data: the directory lies
|
||||
/// outside the repository.
|
||||
/// </summary>
|
||||
public class CpeMappingExportMatchTests
|
||||
{
|
||||
private const string DirectoryVariable = "AM_CPE_DIFF_DIR";
|
||||
|
||||
private sealed record MappingSet(string Name, List<Dictionary<string, object>> Rows, HashSet<string> SelfUpdating);
|
||||
|
||||
[Fact]
|
||||
public void Map_an_exported_inventory()
|
||||
{
|
||||
var directory = Environment.GetEnvironmentVariable(DirectoryVariable);
|
||||
if (string.IsNullOrWhiteSpace(directory)) return;
|
||||
|
||||
var inventory = ReadRows(Path.Combine(directory, "inventory.json"));
|
||||
var nextToSeedFile = Path.Combine(directory, "next-to-seed.json");
|
||||
var nextToSeed = File.Exists(nextToSeedFile) ? ReadRows(nextToSeedFile) : new List<Dictionary<string, object>>();
|
||||
var nvdDirectory = Path.Combine(directory, "nvd");
|
||||
|
||||
var sets = new List<MappingSet>();
|
||||
foreach (var file in Directory.GetFiles(directory, "mappings*.json"))
|
||||
sets.Add(new MappingSet(Path.GetFileNameWithoutExtension(file), ReadRows(file), new HashSet<string>(StringComparer.Ordinal)));
|
||||
foreach (var file in Directory.GetFiles(directory, "seed*.json"))
|
||||
sets.Add(SeedSet(file, nextToSeed));
|
||||
Assert.NotEmpty(sets);
|
||||
|
||||
foreach (var set in sets)
|
||||
{
|
||||
var mappings = set.Rows
|
||||
.Select(CpeMappingStore.MappingFromRow)
|
||||
.Where(m => m != null)
|
||||
.Select(m => m!)
|
||||
.OrderBy(m => m.PartitionKey, StringComparer.Ordinal)
|
||||
.ThenBy(m => m.RowKey, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var matcher = new CpeMappingMatcher(mappings);
|
||||
var scan = Directory.Exists(nvdDirectory) ? new ExportScan(nvdDirectory, set.SelfUpdating) : null;
|
||||
|
||||
var matches = new List<Dictionary<string, object?>>(inventory.Count);
|
||||
var findings = new List<Dictionary<string, object?>>(inventory.Count);
|
||||
foreach (var row in inventory)
|
||||
{
|
||||
var vendor = Text(row, "NormalizedVendor");
|
||||
var name = Text(row, "NormalizedName");
|
||||
var display = Text(row, "DisplayName");
|
||||
var shown = display.Length > 0 ? display : name;
|
||||
|
||||
// A stored key is canonicalized again on load, so a second pass must not move it.
|
||||
var canonical = SoftwareNameCanonicalizer.Canonicalize(shown);
|
||||
Assert.Equal(canonical, SoftwareNameCanonicalizer.Canonicalize(canonical));
|
||||
|
||||
var match = matcher.Match(vendor, name, display, Text(row, "Publisher"));
|
||||
matches.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["key"] = CpeMappingMatcher.SoftwareKey(vendor, shown),
|
||||
["cpeUri"] = match?.Mapping.CpeUri,
|
||||
["edition"] = match?.Mapping.Edition,
|
||||
["confidence"] = match?.Confidence,
|
||||
["source"] = match?.Mapping.Source.ToString(),
|
||||
["mapping"] = match?.Mapping.RowKey,
|
||||
});
|
||||
if (scan != null)
|
||||
findings.Add(scan.Evaluate(match?.Mapping, Text(row, "NormalizedVersion"), Text(row, "RegistrySource")));
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(directory, $"matches-{set.Name}.json"), JsonSerializer.Serialize(matches));
|
||||
if (scan != null)
|
||||
File.WriteAllText(Path.Combine(directory, $"scan-{set.Name}.json"), JsonSerializer.Serialize(findings));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the scan would report for one program version, from NVD answers on disk.</summary>
|
||||
private sealed class ExportScan
|
||||
{
|
||||
private readonly string _nvdDirectory;
|
||||
private readonly HashSet<string> _selfUpdating;
|
||||
private readonly Dictionary<string, List<CachedCve>?> _cves = new(StringComparer.Ordinal);
|
||||
|
||||
public ExportScan(string nvdDirectory, HashSet<string> selfUpdating)
|
||||
{
|
||||
_nvdDirectory = nvdDirectory;
|
||||
_selfUpdating = selfUpdating;
|
||||
}
|
||||
|
||||
public Dictionary<string, object?> Evaluate(CpeMapping? mapping, string version, string registrySource)
|
||||
{
|
||||
var entry = new Dictionary<string, object?> { ["product"] = mapping?.ProductKey };
|
||||
if (mapping == null) return entry;
|
||||
|
||||
entry["selfUpdating"] = _selfUpdating.Contains(mapping.ProductKey);
|
||||
var cves = CvesOf(mapping.CpeUri);
|
||||
if (cves == null)
|
||||
{
|
||||
entry["state"] = "no_data";
|
||||
return entry;
|
||||
}
|
||||
if (VulnerabilityCorrelationService.IsPlaceholderVersion(registrySource, version))
|
||||
{
|
||||
entry["state"] = "placeholder_version";
|
||||
return entry;
|
||||
}
|
||||
if (!VulnerabilityCorrelationService.HasVersionNumber(version))
|
||||
{
|
||||
entry["state"] = "no_version";
|
||||
return entry;
|
||||
}
|
||||
|
||||
var filtered = CveVersionFilter.Filter(cves, mapping.CpeUri, version, mapping.Edition, mapping.ExcludeEditions);
|
||||
var exploited = new HashSet<string>(cves.Where(c => c.KevAdded != null).Select(c => c.CveId), StringComparer.OrdinalIgnoreCase);
|
||||
var found = filtered.Vulnerabilities;
|
||||
entry["state"] = found.Count > 0 ? "findings" : filtered.IncomparableCves > 0 ? "version_scheme" : "clean";
|
||||
entry["cached"] = cves.Count;
|
||||
entry["findings"] = found.Count;
|
||||
entry["critical"] = found.Count(v => string.Equals(v.GetValueOrDefault("cvssSeverity")?.ToString(), "CRITICAL", StringComparison.OrdinalIgnoreCase));
|
||||
entry["kev"] = found.Count(v => exploited.Contains(v.GetValueOrDefault("cveId")?.ToString() ?? string.Empty));
|
||||
entry["everyVersion"] = found.Count(v => v.ContainsKey("versionMatchConfidence"));
|
||||
entry["incomparable"] = filtered.IncomparableCves;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private List<CachedCve>? CvesOf(string cpeUri)
|
||||
{
|
||||
if (_cves.TryGetValue(cpeUri, out var held)) return held;
|
||||
|
||||
var file = Path.Combine(_nvdDirectory, Regex.Replace(cpeUri.ToLowerInvariant(), "[^a-z0-9._+-]", "_") + ".json");
|
||||
List<CachedCve>? cves = null;
|
||||
if (File.Exists(file))
|
||||
{
|
||||
var answer = JsonSerializer.Deserialize<NvdCveResponse>(File.ReadAllText(file), CveCacheFiller.JsonOptions);
|
||||
cves = CveCacheBudget.Apply(CveCacheFiller.Project(answer, cpeUri), DateTime.UtcNow).Cves;
|
||||
}
|
||||
_cves[cpeUri] = cves;
|
||||
return cves;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The table rows a list file becomes on import; a file the import would refuse fails here.</summary>
|
||||
private static MappingSet SeedSet(string path, List<Dictionary<string, object>> nextToSeed)
|
||||
{
|
||||
var (document, problems) = CpeSeedFile.Read(File.ReadAllText(path), mappingsInService: 0);
|
||||
Assert.True(problems.Count == 0, $"{Path.GetFileName(path)}: {string.Join("; ", problems.Take(20))}");
|
||||
|
||||
var rows = CpeSeedFile.ToRows(document!, "seed")
|
||||
.Select(row => new Dictionary<string, object>(row.Properties, StringComparer.Ordinal)
|
||||
{
|
||||
["PartitionKey"] = CpeMappingStore.SeedPartition,
|
||||
["RowKey"] = row.RowKey,
|
||||
})
|
||||
.Concat(nextToSeed)
|
||||
.ToList();
|
||||
var selfUpdating = new HashSet<string>(
|
||||
(document!.SelfUpdatingProducts ?? new List<string>()).Select(p => p.Trim().ToLowerInvariant()), StringComparer.Ordinal);
|
||||
return new MappingSet(Path.GetFileNameWithoutExtension(path), rows, selfUpdating);
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object>> ReadRows(string path)
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var rows = new List<Dictionary<string, object>>();
|
||||
foreach (var element in document.RootElement.EnumerateArray())
|
||||
{
|
||||
var row = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.String) row[property.Name] = property.Value.GetString()!;
|
||||
else if (property.Value.ValueKind != JsonValueKind.Null) row[property.Name] = property.Value.ToString();
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string Text(Dictionary<string, object> row, string column) =>
|
||||
row.TryGetValue(column, out var value) ? value?.ToString() ?? string.Empty : string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using static AutopilotMonitor.Functions.Tests.MappingListFixture;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeMappingImporter"/> replaces the mappings of one list: fetch, check, write, then
|
||||
/// remove what the list no longer holds. Whatever goes wrong before the first write leaves the
|
||||
/// mappings in service exactly as they were, so a list that cannot be fetched or is refused never
|
||||
/// empties them. A refused list is recorded as an ops event, whoever asked for the import.
|
||||
/// </summary>
|
||||
public class CpeMappingImporterTests
|
||||
{
|
||||
private const string Seed = CpeMappingStore.SeedPartition;
|
||||
private const string Community = CpeMappingStore.CommunityPartition;
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string GadgetCpe = "cpe:2.3:a:fabrikam:gadget";
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
Store = new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance);
|
||||
Importer = new CpeMappingImporter(
|
||||
Tables.Repo.Object,
|
||||
Rules.Repository(),
|
||||
new CpeProductFlagsService(Tables.Repo.Object, Store),
|
||||
Store,
|
||||
OpsEvents.Object,
|
||||
NullLogger<CpeMappingImporter>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Records nothing; a test verifies what the import reported.</summary>
|
||||
public Mock<OpsEventService> OpsEvents { get; } = new(null!, null!, null!) { CallBase = false };
|
||||
|
||||
/// <summary>The lists reported as refused, each with its problems, one entry per report.</summary>
|
||||
public List<(string List, IReadOnlyList<string> Problems)> RefusalsReported => OpsEvents.Invocations
|
||||
.Where(invocation => invocation.Method.Name == nameof(OpsEventService.RecordCpeMappingListRefusedAsync))
|
||||
.Select(invocation => ((string)invocation.Arguments[0]!, (IReadOnlyList<string>)invocation.Arguments[1]!))
|
||||
.ToList();
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
public RulesRepositoryStub Rules { get; } = new();
|
||||
public CpeMappingStore Store { get; }
|
||||
public CpeMappingImporter Importer { get; }
|
||||
|
||||
/// <summary>Puts <paramref name="count"/> seed mappings into service, as an earlier import left them.</summary>
|
||||
public void SeedInService(int count)
|
||||
{
|
||||
foreach (var entry in Tools(count))
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping(entry.NormalizedVendor!, entry.NormalizedProduct!, entry.CpeUri!));
|
||||
}
|
||||
|
||||
/// <summary>Makes <paramref name="product"/> self-updating by default, as an earlier seed import left it.</summary>
|
||||
public void DefaultInService(string product) => Tables.Put(
|
||||
CpeMappingStore.ProductFlagsPartition, product,
|
||||
new Dictionary<string, object> { ["Product"] = product, ["SeedSelfUpdating"] = true });
|
||||
}
|
||||
|
||||
/// <summary>A rig with widget, gadget and tool in service and a seed list that corrects the widget, keeps the gadget, drops the tool and adds a reader.</summary>
|
||||
private static Rig RigWithAChangedSeedList()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.Put(VulnerabilityCacheRows.Mapping("contoso", "contoso widget", "cpe:2.3:a:contoso:widget_old", "contoso widget"));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", GadgetCpe, "fabrikam gadget"));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.Mapping("northwind", "northwind tool", "cpe:2.3:a:northwind:tool", "northwind tool"));
|
||||
rig.DefaultInService("northwind:tool");
|
||||
rig.Rules.SeedList = Json(
|
||||
new[]
|
||||
{
|
||||
Entry("contoso", "contoso widget", WidgetCpe),
|
||||
Entry("fabrikam", "fabrikam gadget", GadgetCpe),
|
||||
Entry("adatum", "adatum reader", "cpe:2.3:a:adatum:reader"),
|
||||
},
|
||||
"contoso:widget");
|
||||
return rig;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportSeed_WritesEveryRow_ThenRemovesWhatTheListNoLongerHolds_AndAppliesTheDefaults()
|
||||
{
|
||||
var rig = RigWithAChangedSeedList();
|
||||
var custom = VulnerabilityCacheRows.MappingIn(
|
||||
CpeMappingStore.CustomPartition, "manual", "northwind", "northwind tool", "cpe:2.3:a:northwind:tool_custom");
|
||||
rig.Tables.Put(custom);
|
||||
var before = await rig.Store.GetAsync();
|
||||
|
||||
var result = await rig.Importer.ImportSeedAsync();
|
||||
|
||||
Assert.False(result.Refused);
|
||||
Assert.Empty(result.Problems);
|
||||
Assert.Equal(3, result.Written);
|
||||
Assert.Equal(1, result.Removed);
|
||||
Assert.Equal(new[] { RulesRepositoryStub.SeedListPath }, rig.Rules.RequestedPaths);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "adatum:adatum reader", "contoso:contoso widget", "fabrikam:fabrikam gadget" }, rig.Tables.RowKeys(Seed));
|
||||
var widget = rig.Tables.Row(Seed, "contoso:contoso widget")!;
|
||||
Assert.Equal(WidgetCpe, widget["CpeUri"]);
|
||||
Assert.Equal("seed", widget["Source"]);
|
||||
Assert.Equal("""["contoso widget"]""", widget["DisplayNamePatternsJson"]);
|
||||
// Another list's rows are not this import's to remove.
|
||||
Assert.Equal(custom["CpeUri"], rig.Tables.Row(CpeMappingStore.CustomPartition, "northwind:northwind tool")!["CpeUri"]);
|
||||
|
||||
// Every row is in place before the first one goes.
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
$"upsert {Seed}/contoso:contoso widget",
|
||||
$"upsert {Seed}/fabrikam:fabrikam gadget",
|
||||
$"upsert {Seed}/adatum:adatum reader",
|
||||
$"delete {Seed}/northwind:northwind tool",
|
||||
},
|
||||
rig.Tables.Writes.Where(write => write.Contains($" {Seed}/", StringComparison.Ordinal)));
|
||||
|
||||
// The list's defaults replace the ones in service.
|
||||
var flags = Assert.Single(rig.Tables.Rows(CpeMappingStore.ProductFlagsPartition));
|
||||
Assert.Equal("contoso:widget", flags["Product"]);
|
||||
Assert.Equal(true, flags["SeedSelfUpdating"]);
|
||||
|
||||
// The next reader on this instance sees the list, not the copy read before.
|
||||
var after = await rig.Store.GetAsync();
|
||||
Assert.NotSame(before, after);
|
||||
Assert.Equal(WidgetCpe, after.Matcher.Match("contoso", "contoso widget", "Contoso Widget 4.2", "Contoso Ltd")!.Value.Mapping.CpeUri);
|
||||
Assert.NotNull(after.Matcher.Match("adatum", "adatum reader", "Adatum Reader", "Adatum"));
|
||||
Assert.True(after.IsSelfUpdating("contoso:widget"));
|
||||
Assert.False(after.IsSelfUpdating("northwind:tool"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportSeed_WriteThatFailsHalfWay_RemovesNothing()
|
||||
{
|
||||
var rig = RigWithAChangedSeedList();
|
||||
rig.Tables.WriteFailure = write =>
|
||||
write == $"upsert {Seed}/fabrikam:fabrikam gadget" ? new RequestFailedException(503, "busy") : null;
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => rig.Importer.ImportSeedAsync());
|
||||
|
||||
// A failure on the way leaves more mappings in service than the list holds, never fewer.
|
||||
Assert.Equal(new[] { $"upsert {Seed}/contoso:contoso widget" }, rig.Tables.Writes);
|
||||
Assert.Equal(
|
||||
new[] { "contoso:contoso widget", "fabrikam:fabrikam gadget", "northwind:northwind tool" }, rig.Tables.RowKeys(Seed));
|
||||
Assert.Equal("northwind:tool", Assert.Single(rig.Tables.Rows(CpeMappingStore.ProductFlagsPartition))["Product"]);
|
||||
}
|
||||
|
||||
public static TheoryData<string, string?> RefusedSeedLists() => new()
|
||||
{
|
||||
{ "shrinks too much", Json(Tools(1)) },
|
||||
{ "not json", "<html>maintenance</html>" },
|
||||
{ "empty list", Json(Array.Empty<CpeSeedEntry>()) },
|
||||
{ "empty body", "" },
|
||||
{ "entry with a version in its CPE", Json(Tools(3).Append(Entry("contoso", "contoso widget", "cpe:2.3:a:contoso:widget:4.2"))) },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RefusedSeedLists))]
|
||||
public async Task ImportSeed_RefusedList_WritesAndRemovesNothing_AndReturnsTheProblems(string reason, string? list)
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.SeedInService(4);
|
||||
rig.DefaultInService("contoso:tool_a");
|
||||
var inService = rig.Tables.RowKeys(Seed).ToList();
|
||||
rig.Rules.SeedList = list;
|
||||
|
||||
var result = await rig.Importer.ImportSeedAsync();
|
||||
|
||||
Assert.True(result.Refused, reason);
|
||||
Assert.NotEmpty(result.Problems);
|
||||
Assert.Equal(0, result.Written);
|
||||
Assert.Equal(0, result.Removed);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Equal(inService, rig.Tables.RowKeys(Seed));
|
||||
Assert.Single(rig.Tables.Rows(CpeMappingStore.ProductFlagsPartition));
|
||||
// The refusal is an operator's business whichever button asked for the import.
|
||||
var reported = Assert.Single(rig.RefusalsReported);
|
||||
Assert.Equal("seed", reported.List);
|
||||
Assert.Equal(result.Problems, reported.Problems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportCommunity_EmptyListWithNothingInService_ImportsNothingWithoutBeingRefused()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.SeedInService(2);
|
||||
rig.Rules.CommunityList = """{ "version": "1.0.0", "lastUpdated": "2025-03-20", "mappings": [] }""";
|
||||
|
||||
var result = await rig.Importer.ImportCommunityAsync();
|
||||
|
||||
Assert.False(result.Refused);
|
||||
Assert.Equal(0, result.Written);
|
||||
Assert.Equal(0, result.Removed);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Equal(new[] { RulesRepositoryStub.CommunityListPath }, rig.Rules.RequestedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportCommunity_EmptyListWhileCommunityMappingsAreInService_IsRefused()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var inService = VulnerabilityCacheRows.MappingIn(Community, "community", "contoso", "contoso widget", WidgetCpe);
|
||||
rig.Tables.Put(inService);
|
||||
rig.Rules.CommunityList = """{ "version": "1.0.0", "mappings": [] }""";
|
||||
|
||||
var result = await rig.Importer.ImportCommunityAsync();
|
||||
|
||||
Assert.True(result.Refused);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Equal(new[] { (string)inService["RowKey"] }, rig.Tables.RowKeys(Community));
|
||||
Assert.Equal("community", Assert.Single(rig.RefusalsReported).List);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportCommunity_WritesIntoItsOwnPartition_AndLeavesTheSeedAndItsDefaultsAlone()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.SeedInService(2);
|
||||
rig.DefaultInService("contoso:tool_a");
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(Community, "community", "northwind", "northwind tool", "cpe:2.3:a:northwind:tool"));
|
||||
var seedBefore = rig.Tables.RowKeys(Seed).ToList();
|
||||
rig.Rules.CommunityList = Json(new[] { Entry("contoso", "contoso widget", WidgetCpe) });
|
||||
|
||||
var result = await rig.Importer.ImportCommunityAsync();
|
||||
|
||||
Assert.False(result.Refused);
|
||||
Assert.Empty(rig.RefusalsReported);
|
||||
Assert.Equal(1, result.Written);
|
||||
Assert.Equal(1, result.Removed);
|
||||
var row = Assert.Single(rig.Tables.Rows(Community));
|
||||
Assert.Equal("contoso:contoso widget", row["RowKey"]);
|
||||
Assert.Equal("community", row["Source"]);
|
||||
Assert.Equal(seedBefore, rig.Tables.RowKeys(Seed));
|
||||
// The default set of self-updating products belongs to the seed alone.
|
||||
Assert.All(rig.Tables.Writes, write => Assert.Contains($" {Community}/", write));
|
||||
Assert.Single(rig.Tables.Rows(CpeMappingStore.ProductFlagsPartition));
|
||||
Assert.Equal(CpeMappingSource.Community, (await rig.Store.GetAsync()).Matcher
|
||||
.Match("contoso", "contoso widget", "Contoso Widget", "Contoso Ltd")!.Value.Mapping.Source);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("the file is not there")]
|
||||
[InlineData("the connection breaks")]
|
||||
public async Task Import_FetchThatFails_LeavesTheTableUntouched(string failure)
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.SeedInService(3);
|
||||
var inService = rig.Tables.RowKeys(Seed).ToList();
|
||||
if (failure == "the connection breaks") rig.Rules.Failure = new HttpRequestException("connection reset");
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => rig.Importer.ImportSeedAsync());
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => rig.Importer.ImportCommunityAsync());
|
||||
// A list that could not be fetched was not refused: the caller hears about the failure.
|
||||
Assert.Empty(rig.RefusalsReported);
|
||||
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Equal(inService, rig.Tables.RowKeys(Seed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The committed mapping list against program names as devices report them. Each entry of
|
||||
/// <c>TestData/cpe-mapping-names.json</c> is a display name with its publisher and the product
|
||||
/// the list must map it to, or no product when the list must leave it alone: an add-in, a
|
||||
/// language pack, a program that only shares a word with a mapped one. A change to the list or
|
||||
/// to the matcher that moves one of them fails here by name.
|
||||
/// </summary>
|
||||
public class CpeMappingListNamesTests
|
||||
{
|
||||
private static readonly Lazy<CpeMappingMatcher> CommittedList = new(BuildMatcher);
|
||||
|
||||
private static readonly Regex FromTheVersionOn = new(@"\s+v?\d+(\.\d+)+.*$", RegexOptions.CultureInvariant);
|
||||
private static readonly Regex TrailingMarker = new(@"\s*\((?:x64|x86|64-bit|32-bit|user)\)$", RegexOptions.CultureInvariant);
|
||||
private static readonly Regex TrailingLegalForm = new(@"\s+(?:inc|llc|ltd|gmbh|corporation|corp)$", RegexOptions.CultureInvariant);
|
||||
|
||||
public static TheoryData<string, string, string?, string?> Names()
|
||||
{
|
||||
var data = new TheoryData<string, string, string?, string?>();
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions.Tests", "TestData", "cpe-mapping-names.json")));
|
||||
foreach (var entry in document.RootElement.EnumerateArray())
|
||||
{
|
||||
data.Add(
|
||||
entry.GetProperty("name").GetString()!,
|
||||
entry.GetProperty("publisher").GetString()!,
|
||||
entry.TryGetProperty("product", out var product) ? product.GetString() : null,
|
||||
entry.TryGetProperty("edition", out var edition) ? edition.GetString() : null);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <param name="product">The <c>vendor:product</c> the name must map to; null when it must stay unmapped.</param>
|
||||
/// <param name="edition">When given, the edition of the mapping it lands on; empty for a mapping without one.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public void Name_MapsToTheProductTheListMeansForIt(string name, string publisher, string? product, string? edition)
|
||||
{
|
||||
var match = CommittedList.Value.Match(NormalizedVendor(publisher), NormalizedName(name), name, publisher);
|
||||
|
||||
Assert.Equal(product, match?.Mapping.ProductKey);
|
||||
if (edition != null)
|
||||
Assert.Equal(edition, match?.Mapping.Edition ?? string.Empty);
|
||||
}
|
||||
|
||||
// The file holds what a device shows, the display name and the publisher. An inventory row
|
||||
// also carries both in normalized form, and the matcher reads all four; the expectations in
|
||||
// the file were worked out with the two derived by exactly these rules.
|
||||
private static string NormalizedVendor(string publisher) =>
|
||||
TrailingLegalForm.Replace(publisher.ToLowerInvariant().Replace(",", string.Empty).Replace(".", string.Empty).Trim(), string.Empty).Trim();
|
||||
|
||||
private static string NormalizedName(string name) =>
|
||||
TrailingMarker.Replace(FromTheVersionOn.Replace(name.ToLowerInvariant(), string.Empty).Trim(), string.Empty).Trim();
|
||||
|
||||
/// <summary>The matcher over the rows the committed list becomes on import, in the order the store reads them.</summary>
|
||||
private static CpeMappingMatcher BuildMatcher()
|
||||
{
|
||||
var (document, problems) = CpeSeedFile.Read(
|
||||
File.ReadAllText(Path.Combine(FindRepoRoot(), "rules", "dist", "cpe-mappings.json")), mappingsInService: 0);
|
||||
Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems.Take(20)));
|
||||
|
||||
var mappings = CpeSeedFile.ToRows(document!, "seed")
|
||||
.Select(row => new Dictionary<string, object>(row.Properties, StringComparer.Ordinal)
|
||||
{
|
||||
["PartitionKey"] = CpeMappingStore.SeedPartition,
|
||||
["RowKey"] = row.RowKey,
|
||||
})
|
||||
.Select(CpeMappingStore.MappingFromRow)
|
||||
.Where(mapping => mapping != null)
|
||||
.Select(mapping => mapping!)
|
||||
.OrderBy(mapping => mapping.PartitionKey, StringComparer.Ordinal)
|
||||
.ThenBy(mapping => mapping.RowKey, StringComparer.Ordinal);
|
||||
return new CpeMappingMatcher(mappings);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
return dir?.FullName ?? throw new InvalidOperationException("Repository root (AutopilotMonitor.sln) not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Rules of <see cref="CpeMappingMatcher"/>. The negative cases are names that the former
|
||||
/// substring matching filed under a foreign product.
|
||||
/// </summary>
|
||||
public class CpeMappingMatcherTests
|
||||
{
|
||||
private static CpeMapping Map(
|
||||
string vendor, string product, string cpe,
|
||||
string[]? patterns = null, string[]? publishers = null, string[]? excludes = null,
|
||||
CpeMappingSource source = CpeMappingSource.Seed)
|
||||
=> new()
|
||||
{
|
||||
PartitionKey = "test",
|
||||
RowKey = $"{vendor}:{product}",
|
||||
Source = source,
|
||||
Vendor = vendor,
|
||||
Product = product,
|
||||
CpeUri = cpe,
|
||||
ProductKey = CpeName.ProductKeyOf(cpe),
|
||||
DisplayNamePatterns = patterns ?? Array.Empty<string>(),
|
||||
PublisherPatterns = publishers ?? Array.Empty<string>(),
|
||||
ExcludePatterns = excludes ?? Array.Empty<string>(),
|
||||
};
|
||||
|
||||
private static readonly CpeMapping Chrome = Map("google", "google chrome", "cpe:2.3:a:google:chrome",
|
||||
new[] { "google chrome", "chrome" }, new[] { "google", "google llc" });
|
||||
private static readonly CpeMapping Edge = Map("microsoft", "microsoft edge", "cpe:2.3:a:microsoft:edge_chromium",
|
||||
new[] { "microsoft edge", "microsoft edge webview2", "webview2 runtime" }, new[] { "microsoft" }, new[] { "edge update" });
|
||||
private static readonly CpeMapping EdgeUpdate = Map("microsoft", "microsoft edge update", "cpe:2.3:a:microsoft:edge_update",
|
||||
new[] { "microsoft edge update" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping Rust = Map("rust-lang", "rust", "cpe:2.3:a:rust-lang:rust",
|
||||
new[] { "rust" }, new[] { "the rust project developers" });
|
||||
private static readonly CpeMapping Redis = Map("redis", "redis", "cpe:2.3:a:redis:redis",
|
||||
new[] { "redis" }, new[] { "redis", "redis labs" });
|
||||
private static readonly CpeMapping RLanguage = Map("r-project", "r", "cpe:2.3:a:r-project:r",
|
||||
new[] { "r for windows" }, new[] { "r core team" });
|
||||
private static readonly CpeMapping Opera = Map("opera", "opera", "cpe:2.3:a:opera:opera_browser",
|
||||
new[] { "opera stable", "opera" }, new[] { "opera software" });
|
||||
private static readonly CpeMapping Visio = Map("microsoft", "microsoft visio", "cpe:2.3:a:microsoft:visio",
|
||||
new[] { "microsoft visio", "visio" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping OracleJdk = Map("oracle", "java se development kit", "cpe:2.3:a:oracle:jdk",
|
||||
new[] { "java se development kit", "jdk" }, new[] { "oracle", "oracle corporation" });
|
||||
private static readonly CpeMapping VisualStudio = Map("microsoft", "visual studio", "cpe:2.3:a:microsoft:visual_studio",
|
||||
new[] { "visual studio" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping VsCode = Map("microsoft", "visual studio code", "cpe:2.3:a:microsoft:visual_studio_code",
|
||||
new[] { "microsoft visual studio code", "visual studio code" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping GoLanguage = Map("golang", "go", "cpe:2.3:a:golang:go",
|
||||
new[] { "go programming language" }, new[] { "google", "the go authors" });
|
||||
private static readonly CpeMapping Access = Map("microsoft", "microsoft access", "cpe:2.3:a:microsoft:access",
|
||||
new[] { "microsoft access" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping Office = Map("microsoft", "microsoft office", "cpe:2.3:a:microsoft:office",
|
||||
new[] { "microsoft office" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping Teams = Map("microsoft", "microsoft teams", "cpe:2.3:a:microsoft:teams",
|
||||
new[] { "microsoft teams", "msteams", "teams" }, new[] { "microsoft" }, new[] { "add-in", "addin" });
|
||||
private static readonly CpeMapping VisualCpp = Map("microsoft", "visual c++", @"cpe:2.3:a:microsoft:visual_c\+\+",
|
||||
new[] { "microsoft visual c++" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping NotepadPp = Map("notepad++", "notepad++", @"cpe:2.3:a:notepad-plus-plus:notepad\+\+",
|
||||
new[] { "notepad++" }, new[] { "notepad++ team" });
|
||||
private static readonly CpeMapping DotNet = Map("microsoft", ".net", "cpe:2.3:a:microsoft:.net",
|
||||
new[] { "microsoft .net runtime", ".net runtime" }, new[] { "microsoft" });
|
||||
private static readonly CpeMapping NodeJs = Map("openjs foundation", "node.js", "cpe:2.3:a:nodejs:node.js",
|
||||
new[] { "node.js" }, new[] { "node.js foundation", "openjs foundation" });
|
||||
|
||||
private static readonly CpeMappingMatcher Matcher = new(new[]
|
||||
{
|
||||
Chrome, Edge, EdgeUpdate, Rust, Redis, RLanguage, Opera, Visio, OracleJdk, VisualStudio, VsCode,
|
||||
GoLanguage, Access, Office, Teams, VisualCpp, NotepadPp, DotNet, NodeJs,
|
||||
});
|
||||
|
||||
private static string? CpeOf(string vendor, string name, string display, string publisher = "")
|
||||
=> Matcher.Match(vendor, name, display, publisher)?.Mapping.CpeUri;
|
||||
|
||||
// ---------------------------------------------------------------- key match
|
||||
|
||||
[Fact]
|
||||
public void KeyMatch_VendorAndCanonicalName_IsHighConfidence()
|
||||
{
|
||||
var match = Matcher.Match("google", "google chrome", "Google Chrome", "Google LLC");
|
||||
|
||||
Assert.Equal("cpe:2.3:a:google:chrome", match!.Value.Mapping.CpeUri);
|
||||
Assert.Equal("high", match.Value.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyMatch_UsesTheDisplayNameNotTheAgentKey()
|
||||
{
|
||||
// The agent's normalized name can be mangled; the shown name carries the product.
|
||||
var match = Matcher.Match("notepad++", "notepad++x64)", "Notepad++ (64-bit x64)", "Notepad++ Team");
|
||||
|
||||
Assert.Equal(@"cpe:2.3:a:notepad-plus-plus:notepad\+\+", match!.Value.Mapping.CpeUri);
|
||||
Assert.Equal("high", match.Value.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyMatch_HigherSourceWinsForTheSameKey()
|
||||
{
|
||||
var seed = Map("contoso", "contoso widget", "cpe:2.3:a:contoso:widget_old");
|
||||
var custom = Map("contoso", "Contoso Widget 4.2 (x64)", "cpe:2.3:a:contoso:widget", source: CpeMappingSource.Custom);
|
||||
var auto = Map("contoso", "contoso widget", "cpe:2.3:a:contoso:widget_guess", source: CpeMappingSource.Auto);
|
||||
|
||||
foreach (var order in new[] { new[] { seed, custom, auto }, new[] { auto, custom, seed }, new[] { custom, auto, seed } })
|
||||
{
|
||||
var match = new CpeMappingMatcher(order).Match("contoso", "widget", "Contoso Widget 5.0", "Contoso Ltd");
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget", match!.Value.Mapping.CpeUri);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyMatch_UnknownVendorPlaceholderEqualsNoVendor()
|
||||
{
|
||||
var custom = Map("unknown", "Microsoft Edge WebView2 Runtime", "cpe:2.3:a:microsoft:edge_chromium", source: CpeMappingSource.Custom);
|
||||
|
||||
var match = new CpeMappingMatcher(new[] { custom }).Match("", "microsoft edge webview2 runtime", "Microsoft Edge WebView2 Runtime", "");
|
||||
|
||||
Assert.Equal("high", match!.Value.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyMatch_AnotherVendorWithTheSameNameIsNotMatched()
|
||||
{
|
||||
var custom = Map("contoso", "Agent", "cpe:2.3:a:contoso:agent", source: CpeMappingSource.Custom);
|
||||
|
||||
Assert.Null(new CpeMappingMatcher(new[] { custom }).Match("fabrikam", "agent", "Agent 2.1", "Fabrikam Inc"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- patterns
|
||||
|
||||
[Fact]
|
||||
public void Pattern_WholeWordsInOrder_IsMediumConfidence()
|
||||
{
|
||||
var match = Matcher.Match("", "microsoft edge webview2 runtime", "Microsoft Edge WebView2 Runtime", "");
|
||||
|
||||
Assert.Equal("cpe:2.3:a:microsoft:edge_chromium", match!.Value.Mapping.CpeUri);
|
||||
Assert.Equal("medium", match.Value.Confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("beyondtrust", "BeyondTrust Privilege Management for Windows (x64) 24.5.220.0", "BeyondTrust Corporation")] // "rust"
|
||||
[InlineData("microsoft", "Microsoft Visual C++ 2013 Redistributable (x86) - 12.0.40664", "Microsoft Corporation")] // "redis"
|
||||
[InlineData("microsoft", "Microsoft Interoperability Forms Redistributable Package 2.0a", "Microsoft Corporation")] // "opera"
|
||||
[InlineData("contoso", "Envision Viewer 3.1", "Contoso Ltd")] // "visio"
|
||||
[InlineData("google", "Google Earth Pro", "Google LLC")] // "go"
|
||||
[InlineData("microsoft", "Global Secure Access Client", "Microsoft Corporation")] // "access"
|
||||
public void Pattern_NeverMatchesInsideALongerWordOrByVendorAlone(string vendor, string display, string publisher)
|
||||
{
|
||||
var cpe = CpeOf(vendor, display.ToLowerInvariant(), display, publisher);
|
||||
|
||||
Assert.True(cpe is null or @"cpe:2.3:a:microsoft:visual_c\+\+", $"'{display}' was filed under {cpe}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pattern_LanguageSuffixDoesNotTriggerAOneLetterProduct()
|
||||
=> Assert.Null(CpeOf("microsoft", "microsoft onenote - fr-fr", "Microsoft OneNote - fr-fr", "Microsoft Corporation"));
|
||||
|
||||
[Fact]
|
||||
public void Pattern_MostWordsWins()
|
||||
{
|
||||
Assert.Equal("cpe:2.3:a:microsoft:visual_studio_code",
|
||||
CpeOf("microsoft", "microsoft visual studio code (user)", "Microsoft Visual Studio Code (User)", "Microsoft Corporation"));
|
||||
Assert.Equal("cpe:2.3:a:microsoft:visual_studio",
|
||||
CpeOf("microsoft", "visual studio professional 2022", "Visual Studio Professional 2022", "Microsoft Corporation"));
|
||||
Assert.Equal("cpe:2.3:a:microsoft:edge_update",
|
||||
CpeOf("", "microsoft edge update", "Microsoft Edge Update", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pattern_AfterForIsAnAddOnNotTheProduct()
|
||||
{
|
||||
Assert.Null(CpeOf("contoso", "remote desktop host for google chrome", "Remote Desktop Host for Google Chrome", "Contoso Ltd"));
|
||||
Assert.Null(CpeOf("contoso", "druckertreiber für microsoft office", "Druckertreiber für Microsoft Office", "Contoso GmbH"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pattern_ExcludeWordsBlockTheMapping()
|
||||
{
|
||||
// The add-in is not Teams, and what follows "for" is not Office either.
|
||||
Assert.Null(CpeOf("microsoft", "microsoft teams meeting add-in for microsoft office",
|
||||
"Microsoft Teams Meeting Add-in for Microsoft Office", "Microsoft"));
|
||||
Assert.Equal("cpe:2.3:a:microsoft:teams",
|
||||
CpeOf("microsoft", "teams machine-wide installer", "Teams Machine-Wide Installer", "Microsoft Corporation"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneWordPattern_NeedsTheVendorOrTheWholeName()
|
||||
{
|
||||
// Vendor agrees.
|
||||
Assert.Equal("cpe:2.3:a:google:chrome", CpeOf("google", "chrome beta", "Chrome Beta 131.0", "Google LLC"));
|
||||
// Vendor does not: a helper that carries the browser's name.
|
||||
Assert.Null(CpeOf("contoso", "contoso chrome helper", "Contoso Chrome Helper 3.0", "Contoso Ltd"));
|
||||
// The word is the whole name: an AppX package has no publisher to compare.
|
||||
Assert.Equal("cpe:2.3:a:microsoft:teams", CpeOf("msteams", "msteams", "MSTeams", "MSTeams"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneWordPattern_VendorAgreesUpToLegalFormAndTrailingWords()
|
||||
{
|
||||
Assert.Equal("cpe:2.3:a:oracle:jdk", CpeOf("oracle", "jdk 21", "JDK 21.0.2", "Oracle Corporation"));
|
||||
Assert.Equal("cpe:2.3:a:google:chrome", CpeOf("google llc", "chrome", "Chrome Canary", "Google LLC"));
|
||||
// Another vendor's JDK is not Oracle's.
|
||||
Assert.Null(CpeOf("azul", "azul zulu jdk", "Azul Zulu JDK 17.0.9 (64-bit)", "Azul Systems, Inc."));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("microsoft", "Microsoft Visual C++ 2015-2022 Redistributable (x64) - 14.38.33135", @"cpe:2.3:a:microsoft:visual_c\+\+")]
|
||||
[InlineData("contoso", "Notepad++ Portable Launcher", null)] // one word, foreign vendor
|
||||
[InlineData("notepad++ team", "Notepad++ Portable Launcher", @"cpe:2.3:a:notepad-plus-plus:notepad\+\+")]
|
||||
[InlineData("microsoft", "Microsoft .NET Runtime - 8.0.11 (x64)", "cpe:2.3:a:microsoft:.net")]
|
||||
[InlineData("node.js foundation", "Node.js 20.11.0", "cpe:2.3:a:nodejs:node.js")]
|
||||
public void Tokens_KeepPlusSignsAndSplitOnDots(string vendor, string display, string? expectedCpe)
|
||||
=> Assert.Equal(expectedCpe, CpeOf(vendor, display.ToLowerInvariant(), display, vendor));
|
||||
|
||||
[Fact]
|
||||
public void Tokenize_SplitsOnPunctuationAndKeepsPlusAndHash()
|
||||
{
|
||||
Assert.Equal(new[] { "microsoft", "visual", "c++", "2015", "2022" }, CpeMappingMatcher.Tokenize("Microsoft Visual C++ 2015-2022"));
|
||||
Assert.Equal(new[] { "f#", "tools" }, CpeMappingMatcher.Tokenize("F# Tools"));
|
||||
Assert.Equal(new[] { "intel", "wireless" }, CpeMappingMatcher.Tokenize("Intel(R) Wireless™"));
|
||||
Assert.Empty(CpeMappingMatcher.Tokenize(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_NothingKnown_IsNull()
|
||||
{
|
||||
Assert.Null(Matcher.Match("contoso", "widget", "Contoso Widget 4.2", "Contoso Ltd"));
|
||||
Assert.Null(Matcher.Match(null, null, null, null));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- keys
|
||||
|
||||
[Theory]
|
||||
[InlineData("Google", "Google Chrome 131.0.6778.86", "google|google chrome")]
|
||||
[InlineData("google llc", "Google Chrome", "google llc|google chrome")]
|
||||
[InlineData("unknown", "7-Zip 24.08 (x64)", "|7-zip")]
|
||||
[InlineData(null, "7-Zip", "|7-zip")]
|
||||
[InlineData("contoso", "", "")]
|
||||
public void SoftwareKey_IsVendorAndCanonicalName(string? vendor, string name, string expected)
|
||||
=> Assert.Equal(expected, CpeMappingMatcher.SoftwareKey(vendor, name));
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The two events that say the software-to-CPE mappings are not what they should be: the table
|
||||
/// could not be read (<see cref="OpsEventService.RecordCpeMappingUnavailableAsync"/>), or an
|
||||
/// import fetched a list the check refused (<see cref="OpsEventService.RecordCpeMappingListRefusedAsync"/>).
|
||||
/// Every instance runs into the same outage, so each of them reads today's Maintenance events
|
||||
/// first and records one event per UTC day, cause (for a refusal: list) and severity.
|
||||
/// </summary>
|
||||
public class CpeMappingOpsEventTests
|
||||
{
|
||||
private const string UnreadableWithoutACopy = "CPE mappings could not be loaded; vulnerability scans are postponed";
|
||||
private const string UnreadableWithACopy = "CPE mappings could not be reloaded; an earlier copy keeps serving";
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
var repository = new Mock<IOpsEventRepository>();
|
||||
repository.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Returns<OpsEventEntry>(entry =>
|
||||
{
|
||||
Recorded.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
repository.Setup(r => r.GetOpsEventsAsync(
|
||||
It.IsAny<string?>(), It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<OpsEventQueryFilters?>()))
|
||||
.Returns<string?, DateTime?, DateTime?, OpsEventQueryFilters?>((category, from, to, _) =>
|
||||
{
|
||||
Reads.Add((category, from, to));
|
||||
if (ReadFailure != null) return Task.FromException<List<OpsEventEntry>>(ReadFailure);
|
||||
return Task.FromResult(AlreadyInTheTable.Concat(Recorded)
|
||||
.Where(e => (category == null || e.Category == category)
|
||||
&& (from == null || e.Timestamp >= from)
|
||||
&& (to == null || e.Timestamp < to))
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
Service = new OpsEventService(
|
||||
repository.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
}
|
||||
|
||||
public OpsEventService Service { get; }
|
||||
|
||||
/// <summary>The events the service recorded, in order.</summary>
|
||||
public List<OpsEventEntry> Recorded { get; } = new();
|
||||
|
||||
/// <summary>Events another instance, or an earlier day, left in the table.</summary>
|
||||
public List<OpsEventEntry> AlreadyInTheTable { get; } = new();
|
||||
|
||||
/// <summary>Every read of the events: the category and the window that were asked for.</summary>
|
||||
public List<(string? Category, DateTime? From, DateTime? To)> Reads { get; } = new();
|
||||
|
||||
/// <summary>When set, reading the events fails with it.</summary>
|
||||
public Exception? ReadFailure { get; set; }
|
||||
|
||||
public IEnumerable<(string Severity, string Message)> RecordedAs => Recorded.Select(e => (e.Severity, e.Message));
|
||||
}
|
||||
|
||||
private static OpsEventEntry Stored(string eventType, string severity, string message, DateTime timestampUtc) => new()
|
||||
{
|
||||
Category = OpsEventCategory.Maintenance,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Message = message,
|
||||
Timestamp = timestampUtc,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The mapping table could not be read
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_WithoutAnEarlierCopy_IsAnError_BecauseScansWait()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("RequestFailedException: storage down", servingEarlierCopy: false);
|
||||
|
||||
var recorded = Assert.Single(rig.Recorded);
|
||||
Assert.Equal(OpsEventCategory.Maintenance, recorded.Category);
|
||||
Assert.Equal(OpsEventTypes.CpeMappingUnavailable, recorded.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Error, recorded.Severity);
|
||||
Assert.Equal(UnreadableWithoutACopy, recorded.Message);
|
||||
Assert.Null(recorded.TenantId);
|
||||
using var details = JsonDocument.Parse(recorded.Details!);
|
||||
Assert.Equal("RequestFailedException: storage down", details.RootElement.GetProperty("error").GetString());
|
||||
Assert.False(details.RootElement.GetProperty("servingEarlierCopy").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_WhileAnEarlierCopyServes_IsAWarning()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("RequestFailedException: storage down", servingEarlierCopy: true);
|
||||
|
||||
var recorded = Assert.Single(rig.Recorded);
|
||||
Assert.Equal(OpsEventTypes.CpeMappingUnavailable, recorded.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, recorded.Severity);
|
||||
Assert.Equal(UnreadableWithACopy, recorded.Message);
|
||||
using var details = JsonDocument.Parse(recorded.Details!);
|
||||
Assert.True(details.RootElement.GetProperty("servingEarlierCopy").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_ReadsTodaysMaintenanceEventsBeforeItRecords()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var dayAtTheStart = DateTime.UtcNow.Date;
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
|
||||
var read = Assert.Single(rig.Reads);
|
||||
Assert.Equal(OpsEventCategory.Maintenance, read.Category);
|
||||
// The day as it was when the call ran, which is the next one only across midnight.
|
||||
Assert.InRange(read.From!.Value, dayAtTheStart, DateTime.UtcNow.Date);
|
||||
Assert.Equal(TimeSpan.Zero, read.From.Value.TimeOfDay);
|
||||
Assert.Equal(read.From.Value.AddDays(1), read.To);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Unreadable_ASecondTimeOnTheSameDay_RecordsNothing(bool servingEarlierCopy)
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy);
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down again", servingEarlierCopy);
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("and again", servingEarlierCopy);
|
||||
|
||||
Assert.Single(rig.Recorded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Unreadable_WhenAnotherInstanceRecordedItToday_RecordsNothing(bool servingEarlierCopy)
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.AlreadyInTheTable.Add(Stored(
|
||||
OpsEventTypes.CpeMappingUnavailable,
|
||||
servingEarlierCopy ? OpsEventSeverity.Warning : OpsEventSeverity.Error,
|
||||
servingEarlierCopy ? UnreadableWithACopy : UnreadableWithoutACopy,
|
||||
DateTime.UtcNow));
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy);
|
||||
|
||||
Assert.Empty(rig.Recorded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_AnErrorAfterAWarning_IsRecorded()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: true);
|
||||
// An instance without a copy cannot scan at all: that is worse news than the warning was.
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: true);
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { (OpsEventSeverity.Warning, UnreadableWithACopy), (OpsEventSeverity.Error, UnreadableWithoutACopy) },
|
||||
rig.RecordedAs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_AWarningAfterAnError_IsNotRecorded()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: true);
|
||||
|
||||
Assert.Equal(new[] { (OpsEventSeverity.Error, UnreadableWithoutACopy) }, rig.RecordedAs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreadable_OnTheDayAfterAnEvent_IsRecordedAgain()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.AlreadyInTheTable.Add(Stored(
|
||||
OpsEventTypes.CpeMappingUnavailable, OpsEventSeverity.Error, UnreadableWithoutACopy, DateTime.UtcNow.Date.AddMinutes(-1)));
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
|
||||
Assert.Single(rig.Recorded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NeitherEvent_IsHeldBackByOtherMaintenanceEventsOfTheDay()
|
||||
{
|
||||
var rig = new Rig();
|
||||
// The night's run already said that its sync fell short, for this very reason.
|
||||
rig.AlreadyInTheTable.Add(Stored(
|
||||
OpsEventTypes.VulnerabilityDataSyncDegraded, OpsEventSeverity.Warning,
|
||||
"Vulnerability data sync degraded: automatic mappings or inventory reconcile failed", DateTime.UtcNow));
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", new[] { "the list holds no mapping" });
|
||||
|
||||
Assert.Equal(2, rig.Recorded.Count);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// A list the check refused
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RefusedList_IsAnError_ThatNamesTheFirstProblem_AndCarriesUpToTwentyOfThem()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var problems = Enumerable.Range(1, 25).Select(n => $"mapping {n} (contoso:contoso tool {n}): normalizedProduct is missing").ToList();
|
||||
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", problems);
|
||||
|
||||
var recorded = Assert.Single(rig.Recorded);
|
||||
Assert.Equal(OpsEventCategory.Maintenance, recorded.Category);
|
||||
Assert.Equal(OpsEventTypes.CpeMappingUnavailable, recorded.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Error, recorded.Severity);
|
||||
Assert.Equal($"CPE mapping list refused (seed), the mappings in service were kept: {problems[0]}", recorded.Message);
|
||||
using var details = JsonDocument.Parse(recorded.Details!);
|
||||
Assert.Equal("seed", details.RootElement.GetProperty("list").GetString());
|
||||
Assert.Equal(problems.Take(20), details.RootElement.GetProperty("problems").EnumerateArray().Select(p => p.GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefusedList_ASecondTimeOnTheSameDay_RecordsNothing()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", new[] { "the list holds no mapping" });
|
||||
// Another import of the same list, turned down for another reason: still the same news for the day.
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", new[] { "not valid JSON: unexpected end of data" });
|
||||
|
||||
Assert.Contains("the list holds no mapping", Assert.Single(rig.Recorded).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefusedList_TheOtherListOnTheSameDay_IsItsOwnEvent()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("community", new[] { "not valid JSON: unexpected end of data" });
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", new[] { "the list holds no mapping" });
|
||||
|
||||
// One reseed imports both lists; the second refusal must not disappear behind the first.
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"CPE mapping list refused (community), the mappings in service were kept: not valid JSON: unexpected end of data",
|
||||
"CPE mapping list refused (seed), the mappings in service were kept: the list holds no mapping",
|
||||
},
|
||||
rig.Recorded.Select(e => e.Message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefusedList_WithoutAProblem_RecordsNothing()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", Array.Empty<string>());
|
||||
|
||||
Assert.Empty(rig.Recorded);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The two causes next to each other
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RefusedListAndUnreadableTable_DoNotHoldEachOtherBack(bool refusedFirst)
|
||||
{
|
||||
var rig = new Rig();
|
||||
Task Refused() => rig.Service.RecordCpeMappingListRefusedAsync("seed", new[] { "the list holds no mapping" });
|
||||
Task Unreadable() => rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
|
||||
await (refusedFirst ? Refused() : Unreadable());
|
||||
await (refusedFirst ? Unreadable() : Refused());
|
||||
|
||||
Assert.Equal(2, rig.Recorded.Count);
|
||||
Assert.Single(rig.Recorded, e => e.Message == UnreadableWithoutACopy);
|
||||
Assert.Single(rig.Recorded, e => e.Message.StartsWith("CPE mapping list refused", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefusedList_DoesNotHoldBackTheWarningOfAnUnreadableTable_ThoughItIsTheMoreSevereEvent()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", new[] { "the list holds no mapping" });
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: true);
|
||||
|
||||
Assert.Equal(new[] { OpsEventSeverity.Error, OpsEventSeverity.Warning }, rig.Recorded.Select(e => e.Severity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TodaysEventsThatCannotBeRead_AreNoReasonToStaySilent()
|
||||
{
|
||||
var rig = new Rig { ReadFailure = new InvalidOperationException("table storage is not reachable") };
|
||||
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
await rig.Service.RecordCpeMappingUnavailableAsync("storage down", servingEarlierCopy: false);
|
||||
await rig.Service.RecordCpeMappingListRefusedAsync("seed", new[] { "the list holds no mapping" });
|
||||
|
||||
// One event too many is the smaller harm.
|
||||
Assert.Equal(3, rig.Recorded.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeMappingStore.LoadFailed"/> carries a failed load of the mappings to the ops
|
||||
/// events. A load that keeps failing is retried again and again, by every scan without a copy
|
||||
/// and once a minute otherwise, so the report is raised once per
|
||||
/// <see cref="CpeMappingStore.ReportPause"/> and not per attempt. A load that succeeds ends the
|
||||
/// pause: the next failure is a new incident.
|
||||
/// </summary>
|
||||
public class CpeMappingStoreLoadReportTests
|
||||
{
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping("contoso", "contoso widget", "cpe:2.3:a:contoso:widget", "contoso widget"));
|
||||
Store = new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance)
|
||||
{
|
||||
LoadFailed = (_, servingEarlierCopy) =>
|
||||
{
|
||||
Reports.Add(servingEarlierCopy);
|
||||
return ReportFailure == null ? Task.CompletedTask : Task.FromException(ReportFailure);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
public CpeMappingStore Store { get; }
|
||||
|
||||
/// <summary>One entry per report raised: whether an earlier copy was being served.</summary>
|
||||
public List<bool> Reports { get; } = new();
|
||||
|
||||
/// <summary>When set, raising the report fails with it.</summary>
|
||||
public Exception? ReportFailure { get; set; }
|
||||
|
||||
public void StorageGoesDown() => Tables.ReadFailure = new InvalidOperationException("table storage is not reachable");
|
||||
|
||||
public void StorageComesBack() => Tables.ReadFailure = null;
|
||||
|
||||
public int LoadAttempts => Tables.Repo.Invocations.Count(i => i.Method.Name == nameof(IVulnerabilityRepository.GetAllCpeMappingsAsync));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadThatKeepsFailing_WithoutAnEarlierCopy_IsReportedOnce_ThoughEveryCallTriesAgain()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.StorageGoesDown();
|
||||
|
||||
for (var call = 0; call < 4; call++)
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
|
||||
Assert.Equal(4, rig.LoadAttempts);
|
||||
Assert.Equal(new[] { false }, rig.Reports);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadThatKeepsFailing_WhileAnEarlierCopyIsServed_IsReportedOnce()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var good = await rig.Store.GetAsync();
|
||||
rig.StorageGoesDown();
|
||||
|
||||
// Every writer of a mapping asks for a fresh read, whatever the pause between retries says.
|
||||
for (var call = 0; call < 4; call++)
|
||||
Assert.Same(good, await rig.Store.ReloadAsync());
|
||||
|
||||
Assert.Equal(5, rig.LoadAttempts);
|
||||
Assert.Equal(new[] { true }, rig.Reports);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadThatSucceedsAgain_EndsThePause_SoTheNextFailureIsReported()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.StorageGoesDown();
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
|
||||
rig.StorageComesBack();
|
||||
var good = await rig.Store.GetAsync();
|
||||
Assert.Equal(new[] { false }, rig.Reports);
|
||||
|
||||
rig.StorageGoesDown();
|
||||
Assert.Same(good, await rig.Store.ReloadAsync());
|
||||
Assert.Same(good, await rig.Store.ReloadAsync());
|
||||
|
||||
// The second outage is told apart from the first: a copy is being served this time.
|
||||
Assert.Equal(new[] { false, true }, rig.Reports);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets the pause between two reports run out. The hour cannot be waited for, and the store
|
||||
/// reads the clock itself.
|
||||
/// </summary>
|
||||
private static void LetTheReportPauseRunOut(CpeMappingStore store)
|
||||
{
|
||||
var notBefore = typeof(CpeMappingStore).GetField("_reportNotBeforeTicks", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(notBefore);
|
||||
|
||||
var until = new DateTime((long)notBefore!.GetValue(store)!, DateTimeKind.Utc);
|
||||
Assert.InRange(until, DateTime.UtcNow.Add(CpeMappingStore.ReportPause).AddMinutes(-5), DateTime.UtcNow.Add(CpeMappingStore.ReportPause));
|
||||
notBefore.SetValue(store, DateTime.UtcNow.AddSeconds(-1).Ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutageThatOutlastsThePause_IsReportedAgain()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.StorageGoesDown();
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
Assert.Single(rig.Reports);
|
||||
|
||||
LetTheReportPauseRunOut(rig.Store);
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
|
||||
// Once per pause for as long as the outage lasts, not once for good.
|
||||
Assert.Equal(new[] { false, false }, rig.Reports);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportThatCannotBeRaised_ChangesNothingAboutWhatTheCallerGets()
|
||||
{
|
||||
var rig = new Rig { ReportFailure = new InvalidOperationException("the ops events cannot be written either") };
|
||||
rig.StorageGoesDown();
|
||||
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
|
||||
rig.StorageComesBack();
|
||||
var good = await rig.Store.GetAsync();
|
||||
rig.StorageGoesDown();
|
||||
|
||||
Assert.Same(good, await rig.Store.ReloadAsync());
|
||||
Assert.Equal(new[] { false, true }, rig.Reports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeMappingStore"/> serves one consistent snapshot, keeps the last good one when a
|
||||
/// reload fails, and refuses to serve an empty one: correlating without mappings would take the
|
||||
/// CPE off every inventory row it touches.
|
||||
/// </summary>
|
||||
public class CpeMappingStoreTests
|
||||
{
|
||||
private static Dictionary<string, object> Row(
|
||||
string partition, string rowKey, string vendor, string product, string cpeUri,
|
||||
string? source = null, string[]? patterns = null)
|
||||
{
|
||||
var row = new Dictionary<string, object>
|
||||
{
|
||||
["PartitionKey"] = partition,
|
||||
["RowKey"] = rowKey,
|
||||
["NormalizedVendor"] = vendor,
|
||||
["NormalizedProduct"] = product,
|
||||
["CpeUri"] = cpeUri,
|
||||
["DisplayNamePatternsJson"] = System.Text.Json.JsonSerializer.Serialize(patterns ?? Array.Empty<string>()),
|
||||
};
|
||||
if (source != null) row["Source"] = source;
|
||||
return row;
|
||||
}
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Mock<IVulnerabilityRepository> Repo { get; } = new();
|
||||
public List<Dictionary<string, object>> Mappings { get; set; } = new();
|
||||
public List<Dictionary<string, object>> Ignored { get; set; } = new();
|
||||
public List<Dictionary<string, object>> Flags { get; set; } = new();
|
||||
public Exception? Failure { get; set; }
|
||||
public int Loads { get; private set; }
|
||||
public CpeMappingStore Store { get; }
|
||||
|
||||
public Rig()
|
||||
{
|
||||
Repo.Setup(r => r.GetAllCpeMappingsAsync()).Returns(() =>
|
||||
{
|
||||
Loads++;
|
||||
return Failure != null ? Task.FromException<List<Dictionary<string, object>>>(Failure) : Task.FromResult(Mappings);
|
||||
});
|
||||
Repo.Setup(r => r.GetCpeMappingsByPartitionAsync(CpeMappingStore.IgnoredPartition)).Returns(() => Task.FromResult(Ignored));
|
||||
Repo.Setup(r => r.GetCpeMappingsByPartitionAsync(CpeMappingStore.ProductFlagsPartition)).Returns(() => Task.FromResult(Flags));
|
||||
Store = new CpeMappingStore(Repo.Object, NullLogger<CpeMappingStore>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ChromeSeed() =>
|
||||
Row(CpeMappingStore.SeedPartition, "google:google chrome", "google", "google chrome", "cpe:2.3:a:google:chrome", patterns: new[] { "google chrome" });
|
||||
|
||||
[Fact]
|
||||
public async Task Get_LoadsOnceAndServesTheSameSnapshot()
|
||||
{
|
||||
var rig = new Rig { Mappings = { ChromeSeed() } };
|
||||
|
||||
var first = await rig.Store.GetAsync();
|
||||
var second = await rig.Store.GetAsync();
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(1, rig.Loads);
|
||||
Assert.Equal("cpe:2.3:a:google:chrome",
|
||||
first.Matcher.Match("google", "google chrome", "Google Chrome", "Google LLC")!.Value.Mapping.CpeUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reload_ReadsAgain()
|
||||
{
|
||||
var rig = new Rig { Mappings = { ChromeSeed() } };
|
||||
await rig.Store.GetAsync();
|
||||
|
||||
rig.Mappings.Add(Row(CpeMappingStore.CustomPartition, "contoso:widget", "contoso", "Contoso Widget", "cpe:2.3:a:contoso:widget", "manual"));
|
||||
var reloaded = await rig.Store.ReloadAsync();
|
||||
|
||||
Assert.Equal(2, rig.Loads);
|
||||
Assert.NotNull(reloaded.Matcher.Match("contoso", "widget", "Contoso Widget 2.0", "Contoso Ltd"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_FailedReload_KeepsTheLastGoodSnapshotAndReportsIt()
|
||||
{
|
||||
var rig = new Rig { Mappings = { ChromeSeed() } };
|
||||
var reports = new List<bool>();
|
||||
rig.Store.LoadFailed = (_, servedEarlierCopy) => { reports.Add(servedEarlierCopy); return Task.CompletedTask; };
|
||||
var good = await rig.Store.GetAsync();
|
||||
|
||||
rig.Failure = new InvalidOperationException("storage down");
|
||||
var served = await rig.Store.ReloadAsync();
|
||||
|
||||
Assert.Same(good, served);
|
||||
Assert.Equal(new[] { true }, reports);
|
||||
|
||||
// While the pause after a failure runs, the held copy is served without another attempt.
|
||||
var loadsAfterFailure = rig.Loads;
|
||||
Assert.Same(good, await rig.Store.GetAsync());
|
||||
Assert.Equal(loadsAfterFailure, rig.Loads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_FailedFirstLoad_ThrowsUnavailable()
|
||||
{
|
||||
var rig = new Rig { Failure = new InvalidOperationException("storage down") };
|
||||
var reports = new List<bool>();
|
||||
rig.Store.LoadFailed = (_, servedEarlierCopy) => { reports.Add(servedEarlierCopy); return Task.CompletedTask; };
|
||||
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
|
||||
Assert.Equal(new[] { false }, reports);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_NoMappingAtAll_IsUnavailableNotAnEmptySnapshot()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Store.GetAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_EmptyReload_KeepsTheLastGoodSnapshot()
|
||||
{
|
||||
var rig = new Rig { Mappings = { ChromeSeed() } };
|
||||
var good = await rig.Store.GetAsync();
|
||||
|
||||
rig.Mappings = new List<Dictionary<string, object>>();
|
||||
|
||||
Assert.Same(good, await rig.Store.ReloadAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingFromRow_ReducesTheCpeToItsProductForm()
|
||||
{
|
||||
var typedInFull = CpeMappingStore.MappingFromRow(
|
||||
Row(CpeMappingStore.CustomPartition, "k", "Contoso", "Widget", "cpe:2.3:a:Contoso:Widget:*:*:*:*:*:*:*:*", "manual"));
|
||||
var urlEncoded = CpeMappingStore.MappingFromRow(
|
||||
Row(CpeMappingStore.CustomPartition, "k", "microsoft", "visual c++", "cpe:2.3:a:microsoft:visual_c%2b%2b", "manual"));
|
||||
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget", typedInFull!.CpeUri);
|
||||
Assert.Equal("contoso:widget", typedInFull.ProductKey);
|
||||
Assert.Equal(@"cpe:2.3:a:microsoft:visual_c\+\+", urlEncoded!.CpeUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingFromRow_WithoutAUsableCpe_IsSkipped()
|
||||
{
|
||||
Assert.Null(CpeMappingStore.MappingFromRow(Row(CpeMappingStore.CustomPartition, "k", "contoso", "widget", "")));
|
||||
Assert.Null(CpeMappingStore.MappingFromRow(Row(CpeMappingStore.CustomPartition, "k", "contoso", "widget", "cpe:2.3:a:*:*")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MappingFromRow_FallsBackToTheVendorAndProductColumns()
|
||||
{
|
||||
var row = Row(CpeMappingStore.SeedPartition, "k", "contoso", "widget", "");
|
||||
row["CpeVendor"] = "contoso";
|
||||
row["CpeProduct"] = "widget";
|
||||
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget", CpeMappingStore.MappingFromRow(row)!.CpeUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CpeMappingStore.SeedPartition, null, "Seed")]
|
||||
[InlineData(CpeMappingStore.CommunityPartition, null, "Community")]
|
||||
[InlineData(CpeMappingStore.AutoPartition, null, "Auto")]
|
||||
[InlineData(CpeMappingStore.CustomPartition, "manual", "Custom")]
|
||||
[InlineData(CpeMappingStore.CustomPartition, null, "Custom")]
|
||||
// Rows the retired name search wrote into the custom partition rank below the seed.
|
||||
[InlineData(CpeMappingStore.CustomPartition, "auto", "Auto")]
|
||||
public void SourceOf_ReadsPartitionAndSourceColumn(string partition, string? sourceColumn, string expected)
|
||||
=> Assert.Equal(expected, CpeMappingStore.SourceOf(partition, sourceColumn ?? string.Empty).ToString());
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_IgnoreListMatchesByVendorAndCanonicalName()
|
||||
{
|
||||
var rig = new Rig { Mappings = { ChromeSeed() } };
|
||||
rig.Ignored.Add(new Dictionary<string, object>
|
||||
{
|
||||
["PartitionKey"] = CpeMappingStore.IgnoredPartition,
|
||||
["RowKey"] = "contoso ltd:contoso widget",
|
||||
["NormalizedVendor"] = "Contoso Ltd", // older entries hold the raw publisher
|
||||
["NormalizedProduct"] = "Contoso Widget 4.2 (x64)",
|
||||
});
|
||||
|
||||
var snapshot = await rig.Store.GetAsync();
|
||||
|
||||
Assert.Equal(1, snapshot.IgnoredCount);
|
||||
Assert.True(snapshot.IsIgnored("contoso", "Contoso Ltd", "Contoso Widget 5.0"));
|
||||
Assert.True(snapshot.IsIgnored("contoso ltd", "", "Contoso Widget"));
|
||||
Assert.False(snapshot.IsIgnored("fabrikam", "Fabrikam Inc", "Contoso Widget 5.0"));
|
||||
Assert.False(snapshot.IsIgnored("contoso", "Contoso Ltd", "Contoso Gadget"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_SelfUpdating_AdminValueOverridesTheSeedDefault()
|
||||
{
|
||||
var rig = new Rig { Mappings = { ChromeSeed() } };
|
||||
rig.Flags.AddRange(new[]
|
||||
{
|
||||
Flag("google:chrome", seed: true, admin: null),
|
||||
Flag("mozilla:firefox", seed: true, admin: false),
|
||||
Flag("microsoft:visual_studio_code", seed: null, admin: true),
|
||||
Flag("contoso:widget", seed: false, admin: null),
|
||||
});
|
||||
|
||||
var snapshot = await rig.Store.GetAsync();
|
||||
|
||||
Assert.True(snapshot.IsSelfUpdating("google:chrome"));
|
||||
Assert.False(snapshot.IsSelfUpdating("mozilla:firefox"));
|
||||
Assert.True(snapshot.IsSelfUpdating("microsoft:visual_studio_code"));
|
||||
Assert.False(snapshot.IsSelfUpdating("contoso:widget"));
|
||||
Assert.False(snapshot.IsSelfUpdating("never:listed"));
|
||||
Assert.False(snapshot.IsSelfUpdating(null));
|
||||
Assert.Equal(new[] { "google:chrome", "microsoft:visual_studio_code" }, snapshot.SelfUpdatingProducts().OrderBy(p => p));
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> Flag(string product, bool? seed, bool? admin)
|
||||
{
|
||||
var row = new Dictionary<string, object>
|
||||
{
|
||||
["PartitionKey"] = CpeMappingStore.ProductFlagsPartition,
|
||||
["RowKey"] = product.Replace(':', '_'),
|
||||
["Product"] = product,
|
||||
};
|
||||
if (seed.HasValue) row["SeedSelfUpdating"] = seed.Value;
|
||||
if (admin.HasValue) row["AdminSelfUpdating"] = admin.Value;
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeName"/> is the one parser for CPE 2.3 strings: product identity for grouping,
|
||||
/// the version-less form mappings are keyed by, and the edition and platform fields the version
|
||||
/// filter reads.
|
||||
/// </summary>
|
||||
public class CpeNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:24.08", "7-zip:7-zip")]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:", "7-zip:7-zip")] // finding without a version
|
||||
[InlineData("cpe:2.3:a:Microsoft:Teams:1.6.00.4472", "microsoft:teams")]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*:1.2.3", "contoso:widget")] // custom mapping typed in full
|
||||
[InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+:14.0", @"microsoft:visual_c\+\+")] // CPE escapes stay part of the key
|
||||
[InlineData("cpe:2.3:a:*:*:1.0", "")]
|
||||
[InlineData("cpe:2.3:a", "")]
|
||||
[InlineData("not a cpe:at:all:really:no", "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void ProductKeyOf_IsLowerCaseVendorAndProduct(string? cpe, string expected)
|
||||
=> Assert.Equal(expected, CpeName.ProductKeyOf(cpe));
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:google:chrome", "cpe:2.3:a:google:chrome")]
|
||||
[InlineData("cpe:2.3:a:Google:Chrome:120.0.1:*:*:*:*:*:*:*", "cpe:2.3:a:google:chrome")]
|
||||
[InlineData(" cpe:2.3:a:mozilla:firefox:*:*:*:*:esr:*:*:* ", "cpe:2.3:a:mozilla:firefox")]
|
||||
[InlineData("typed by hand", "typed by hand")]
|
||||
[InlineData(null, "")]
|
||||
public void ToProductUri_DropsEverythingAfterTheProduct(string? cpe, string expected)
|
||||
=> Assert.Equal(expected, CpeName.ToProductUri(cpe));
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ReadsEditionAndPlatformFields()
|
||||
{
|
||||
Assert.True(CpeName.TryParse("cpe:2.3:a:mozilla:firefox:115.2.1:*:*:*:esr:android:arm64:*", out var name));
|
||||
|
||||
Assert.Equal("a", name.Part);
|
||||
Assert.Equal("mozilla", name.Vendor);
|
||||
Assert.Equal("firefox", name.Product);
|
||||
Assert.Equal("115.2.1", name.Version);
|
||||
Assert.Equal("esr", name.SwEdition);
|
||||
Assert.Equal("android", name.TargetSw);
|
||||
Assert.Equal("arm64", name.TargetHw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_FillsMissingFieldsWithTheWildcard()
|
||||
{
|
||||
Assert.True(CpeName.TryParse("cpe:2.3:a:google:chrome", out var name));
|
||||
|
||||
Assert.Equal("*", name.Version);
|
||||
Assert.Equal("*", name.SwEdition);
|
||||
Assert.Equal("*", name.TargetSw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_KeepsAnEscapedColonInsideItsField()
|
||||
{
|
||||
Assert.True(CpeName.TryParse(@"cpe:2.3:a:contoso:suite\:pro:2.0:*:*:*:*:*:*:*", out var name));
|
||||
|
||||
Assert.Equal(@"suite\:pro", name.Product);
|
||||
Assert.Equal("2.0", name.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSameProduct_ComparesVendorAndProductNotThePrefix()
|
||||
{
|
||||
Assert.True(CpeName.TryParse("cpe:2.3:a:adobe:acrobat", out var acrobat));
|
||||
Assert.True(CpeName.TryParse("cpe:2.3:a:adobe:acrobat_reader_dc:23.006.20320:*:*:*:continuous:*:*:*", out var reader));
|
||||
Assert.True(CpeName.TryParse("cpe:2.3:a:Adobe:Acrobat:20.0:*:*:*:classic:*:*:*", out var acrobatClassic));
|
||||
|
||||
Assert.False(acrobat.IsSameProduct(reader));
|
||||
Assert.True(acrobat.IsSameProduct(acrobatClassic));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeProductFlagsService"/> keeps, per product, the default the seed list ships and an
|
||||
/// operator's own value only while it differs from that default. The row is written in replace
|
||||
/// mode, so a value is unset by leaving its column out, and a row that would hold neither value
|
||||
/// is removed.
|
||||
/// </summary>
|
||||
public class CpeProductFlagsServiceTests
|
||||
{
|
||||
private const string Flags = CpeMappingStore.ProductFlagsPartition;
|
||||
private const string Widget = "contoso:widget";
|
||||
private const string Gadget = "fabrikam:gadget";
|
||||
private const string Operator = "admin@contoso.com";
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping("contoso", "contoso widget", "cpe:2.3:a:contoso:widget", "contoso widget"));
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", "cpe:2.3:a:fabrikam:gadget", "fabrikam gadget"));
|
||||
Store = new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance);
|
||||
Service = new CpeProductFlagsService(Tables.Repo.Object, Store);
|
||||
}
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
public CpeMappingStore Store { get; }
|
||||
public CpeProductFlagsService Service { get; }
|
||||
|
||||
public Dictionary<string, object>? RowOf(string product) => Tables.Row(Flags, product);
|
||||
|
||||
public async Task<string[]> SelfUpdatingAsync() =>
|
||||
(await Store.GetAsync()).SelfUpdatingProducts().OrderBy(p => p, StringComparer.Ordinal).ToArray();
|
||||
|
||||
/// <summary>How often the mappings were read from the table.</summary>
|
||||
public int MappingReads =>
|
||||
Tables.Repo.Invocations.Count(i => i.Method.Name == nameof(IVulnerabilityRepository.GetAllCpeMappingsAsync));
|
||||
}
|
||||
|
||||
/// <summary>The columns a flags row holds besides its keys.</summary>
|
||||
private static string[] ColumnsOf(Dictionary<string, object> row) => row.Keys
|
||||
.Where(column => column is not ("PartitionKey" or "RowKey"))
|
||||
.OrderBy(column => column, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Seed defaults
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ApplySeedDefaults_WritesOneRowPerListedProduct_AndNothingWhenCalledAgain()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var first = await rig.Service.ApplySeedDefaultsAsync(new[] { Widget, Gadget });
|
||||
var writesAfterFirst = rig.Tables.Writes.ToList();
|
||||
var second = await rig.Service.ApplySeedDefaultsAsync(new[] { Widget, Gadget });
|
||||
|
||||
Assert.Equal(2, first);
|
||||
Assert.Equal(new[] { Widget, Gadget }, rig.Tables.RowKeys(Flags));
|
||||
Assert.All(new[] { Widget, Gadget }, product =>
|
||||
{
|
||||
var row = rig.RowOf(product)!;
|
||||
Assert.Equal(new[] { "Product", "SeedSelfUpdating" }, ColumnsOf(row));
|
||||
Assert.Equal(product, row["Product"]);
|
||||
Assert.Equal(true, row["SeedSelfUpdating"]);
|
||||
});
|
||||
|
||||
Assert.Equal(0, second);
|
||||
Assert.Equal(writesAfterFirst, rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplySeedDefaults_KeysAreTrimmedAndLowerCased_AndEmptyOnesSkipped()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var changed = await rig.Service.ApplySeedDefaultsAsync(new[] { " Contoso:Widget ", "contoso:widget", "", " " });
|
||||
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal(new[] { Widget }, rig.Tables.RowKeys(Flags));
|
||||
Assert.Equal(Widget, rig.RowOf(Widget)!["Product"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplySeedDefaults_DefaultThatNowEqualsTheOperatorsValue_DropsTheOperatorsValue()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Service.SetSelfUpdatingAsync(Widget, true, Operator);
|
||||
Assert.Equal(new[] { "AdminSelfUpdating", "Product", "UpdatedAt", "UpdatedBy" }, ColumnsOf(rig.RowOf(Widget)!));
|
||||
|
||||
var changed = await rig.Service.ApplySeedDefaultsAsync(new[] { Widget });
|
||||
|
||||
// The product follows the default again, so a later change of the default reaches it.
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal(new[] { "Product", "SeedSelfUpdating" }, ColumnsOf(rig.RowOf(Widget)!));
|
||||
Assert.Equal(new[] { Widget }, await rig.SelfUpdatingAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplySeedDefaults_ProductLeftTheList_LosesItsRow_WhateverTheOperatorSaidAboutTheDefault()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Service.ApplySeedDefaultsAsync(new[] { Widget, Gadget });
|
||||
// The operator switched the default off for the widget; with the default gone, "off" says nothing.
|
||||
await rig.Service.SetSelfUpdatingAsync(Widget, false, Operator);
|
||||
|
||||
var changed = await rig.Service.ApplySeedDefaultsAsync(Array.Empty<string>());
|
||||
|
||||
Assert.Equal(2, changed);
|
||||
Assert.Empty(rig.Tables.Rows(Flags));
|
||||
Assert.Empty(await rig.SelfUpdatingAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplySeedDefaults_LeavesAnOperatorsOwnFlagAlone()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Service.SetSelfUpdatingAsync(Gadget, true, Operator);
|
||||
var before = rig.RowOf(Gadget)!;
|
||||
var writes = rig.Tables.Writes.Count;
|
||||
|
||||
var changed = await rig.Service.ApplySeedDefaultsAsync(new[] { Widget });
|
||||
|
||||
// Only the widget's default was written: the gadget is flagged by the operator, not by the list.
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal(writes + 1, rig.Tables.Writes.Count);
|
||||
Assert.Equal(before, rig.RowOf(Gadget));
|
||||
Assert.Equal(new[] { Widget, Gadget }, await rig.SelfUpdatingAsync());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The operator's value
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SetSelfUpdating_AwayFromTheDefault_StoresTheOperatorsValueNextToIt()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Service.ApplySeedDefaultsAsync(new[] { Widget });
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var flags = await rig.Service.SetSelfUpdatingAsync(Widget, false, Operator);
|
||||
|
||||
var row = rig.RowOf(Widget)!;
|
||||
Assert.Equal(new[] { "AdminSelfUpdating", "Product", "SeedSelfUpdating", "UpdatedAt", "UpdatedBy" }, ColumnsOf(row));
|
||||
Assert.Equal(true, row["SeedSelfUpdating"]);
|
||||
Assert.Equal(false, row["AdminSelfUpdating"]);
|
||||
Assert.Equal(Operator, row["UpdatedBy"]);
|
||||
var updatedAt = DateTime.Parse((string)row["UpdatedAt"], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
Assert.InRange(updatedAt, before, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(Widget, flags.Product);
|
||||
Assert.False(flags.SelfUpdating);
|
||||
Assert.True(flags.SeedSelfUpdating);
|
||||
Assert.False(flags.AdminSelfUpdating);
|
||||
Assert.Equal(Operator, flags.UpdatedBy);
|
||||
Assert.Equal(row["UpdatedAt"], flags.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetSelfUpdating_BackToTheDefault_LeavesOnlyTheDefaultOnTheRow()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Service.ApplySeedDefaultsAsync(new[] { Widget });
|
||||
await rig.Service.SetSelfUpdatingAsync(Widget, false, Operator);
|
||||
|
||||
var flags = await rig.Service.SetSelfUpdatingAsync(Widget, true, Operator);
|
||||
|
||||
// A merge would have kept the operator's columns; the row is replaced without them.
|
||||
var row = rig.RowOf(Widget)!;
|
||||
Assert.Equal(new[] { "Product", "SeedSelfUpdating" }, ColumnsOf(row));
|
||||
Assert.Equal(true, row["SeedSelfUpdating"]);
|
||||
|
||||
Assert.True(flags.SelfUpdating);
|
||||
Assert.True(flags.SeedSelfUpdating);
|
||||
Assert.Null(flags.AdminSelfUpdating);
|
||||
Assert.Equal(string.Empty, flags.UpdatedBy);
|
||||
Assert.Equal(string.Empty, flags.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetSelfUpdating_ProductTheSeedDoesNotList_StoresOnlyTheOperatorsValue_AndUnflaggingRemovesTheRow()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var flagged = await rig.Service.SetSelfUpdatingAsync(Gadget, true, Operator);
|
||||
var row = rig.RowOf(Gadget)!;
|
||||
var unflagged = await rig.Service.SetSelfUpdatingAsync(Gadget, false, Operator);
|
||||
|
||||
Assert.Equal(new[] { "AdminSelfUpdating", "Product", "UpdatedAt", "UpdatedBy" }, ColumnsOf(row));
|
||||
Assert.Equal(true, row["AdminSelfUpdating"]);
|
||||
Assert.True(flagged.SelfUpdating);
|
||||
Assert.Null(flagged.SeedSelfUpdating);
|
||||
Assert.True(flagged.AdminSelfUpdating);
|
||||
|
||||
Assert.Empty(rig.Tables.Rows(Flags));
|
||||
Assert.Equal($"delete {Flags}/{Gadget}", rig.Tables.Writes[^1]);
|
||||
Assert.False(unflagged.SelfUpdating);
|
||||
Assert.Null(unflagged.SeedSelfUpdating);
|
||||
Assert.Null(unflagged.AdminSelfUpdating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetSelfUpdating_ToTheValueItAlreadyHas_KeepsTheRowAsItIs()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Service.ApplySeedDefaultsAsync(new[] { Widget });
|
||||
|
||||
var flags = await rig.Service.SetSelfUpdatingAsync(Widget, true, Operator);
|
||||
|
||||
Assert.True(flags.SelfUpdating);
|
||||
Assert.Equal(new[] { "Product", "SeedSelfUpdating" }, ColumnsOf(rig.RowOf(Widget)!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("contoso:gadget")] // vendor and product of two different mappings
|
||||
[InlineData("widget")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SetSelfUpdating_ProductNoMappingPointsTo_ThrowsAndWritesNothing(string product)
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => rig.Service.SetSelfUpdatingAsync(product, true, Operator));
|
||||
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Empty(rig.Tables.Rows(Flags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetSelfUpdating_ProductKeyIsTrimmedAndLowerCased()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var flags = await rig.Service.SetSelfUpdatingAsync(" Contoso:WIDGET ", true, Operator);
|
||||
|
||||
Assert.Equal(Widget, flags.Product);
|
||||
Assert.Equal(new[] { Widget }, rig.Tables.RowKeys(Flags));
|
||||
Assert.Equal(Widget, rig.RowOf(Widget)!["Product"]);
|
||||
Assert.Equal(new[] { Widget }, await rig.SelfUpdatingAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetSelfUpdating_MappingsUnreadable_IsUnavailableAndWritesNothing()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.ReadFailure = new InvalidOperationException("storage down");
|
||||
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Service.SetSelfUpdatingAsync(Widget, true, Operator));
|
||||
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetSelfUpdating_ProductMappedAfterThisInstanceReadTheMappings_IsFoundRightAway()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Store.GetAsync();
|
||||
// Saved on another instance a moment ago; this one's copy is good for minutes yet.
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(
|
||||
CpeMappingStore.CustomPartition, "manual", "northwind", "northwind tool", "cpe:2.3:a:northwind:tool"));
|
||||
|
||||
var flags = await rig.Service.SetSelfUpdatingAsync("northwind:tool", true, Operator);
|
||||
|
||||
Assert.True(flags.SelfUpdating);
|
||||
Assert.Equal(true, rig.RowOf("northwind:tool")!["AdminSelfUpdating"]);
|
||||
Assert.Equal(new[] { "northwind:tool" }, await rig.SelfUpdatingAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// A product this instance knows is taken from the copy it holds.
|
||||
[InlineData(Widget, false)]
|
||||
// Before a product is refused, the mappings are read once more, and only once.
|
||||
[InlineData("tailspin:launcher", true)]
|
||||
public async Task SetSelfUpdating_ReadsTheMappingsAgain_OnlyBeforeItRefusesAProduct(string product, bool refused)
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Store.GetAsync();
|
||||
var readsBefore = rig.MappingReads;
|
||||
|
||||
var error = await Record.ExceptionAsync(() => rig.Service.SetSelfUpdatingAsync(product, true, Operator));
|
||||
|
||||
Assert.Equal(refused, error is ArgumentException);
|
||||
Assert.Equal(readsBefore + (refused ? 1 : 0), rig.MappingReads);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// What the matching sees
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task EffectiveValue_SeenThroughTheMappingStore_FollowsEveryChange()
|
||||
{
|
||||
var rig = new Rig();
|
||||
Assert.Empty(await rig.SelfUpdatingAsync());
|
||||
|
||||
await rig.Service.ApplySeedDefaultsAsync(new[] { Widget });
|
||||
Assert.Equal(new[] { Widget }, await rig.SelfUpdatingAsync());
|
||||
Assert.True((await rig.Store.GetAsync()).IsSelfUpdating(Widget));
|
||||
Assert.False((await rig.Store.GetAsync()).IsSelfUpdating(Gadget));
|
||||
|
||||
await rig.Service.SetSelfUpdatingAsync(Widget, false, Operator);
|
||||
Assert.Empty(await rig.SelfUpdatingAsync());
|
||||
|
||||
await rig.Service.SetSelfUpdatingAsync(Gadget, true, Operator);
|
||||
Assert.Equal(new[] { Gadget }, await rig.SelfUpdatingAsync());
|
||||
|
||||
await rig.Service.SetSelfUpdatingAsync(Widget, true, Operator);
|
||||
Assert.Equal(new[] { Widget, Gadget }, await rig.SelfUpdatingAsync());
|
||||
|
||||
// The widget loses its default, the gadget keeps what the operator set.
|
||||
await rig.Service.ApplySeedDefaultsAsync(Array.Empty<string>());
|
||||
Assert.Equal(new[] { Gadget }, await rig.SelfUpdatingAsync());
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeMatchScorer.ProductKey"/> turns a finding's CPE ("base CPE:installed version")
|
||||
/// into the product identity the exposure summary groups by.
|
||||
/// </summary>
|
||||
public class CpeProductKeyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:24.08", "7-zip:7-zip")]
|
||||
[InlineData("cpe:2.3:a:7-zip:7-zip:", "7-zip:7-zip")] // finding without a version
|
||||
[InlineData("cpe:2.3:a:Microsoft:Teams:1.6.00.4472", "microsoft:teams")]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*:1.2.3", "contoso:widget")] // custom mapping typed in full
|
||||
[InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+:14.0", @"microsoft:visual_c\+\+")] // CPE escapes stay part of the key
|
||||
[InlineData("cpe:2.3:a:*:*:1.0", "")]
|
||||
[InlineData("cpe:2.3:a", "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void ProductKey_IsLowerCaseVendorAndProduct(string? cpe, string expected)
|
||||
=> Assert.Equal(expected, CpeMatchScorer.ProductKey(cpe));
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CpeSeedFile"/> decides whether a mapping list may replace the mappings in service.
|
||||
/// Every reason to refuse one is a problem in the result, never an exception, and a list with a
|
||||
/// problem is not imported at all. The same check runs here over the committed community list.
|
||||
/// </summary>
|
||||
public class CpeSeedFileTests
|
||||
{
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
|
||||
private static readonly JsonSerializerOptions FileJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static CpeSeedEntry Entry(string? vendor, string? product, string? cpeUri) =>
|
||||
new() { NormalizedVendor = vendor, NormalizedProduct = product, CpeUri = cpeUri };
|
||||
|
||||
private static CpeSeedEntry Widget() => Entry("contoso", "contoso widget", WidgetCpe);
|
||||
|
||||
/// <summary><paramref name="count"/> entries of different programs, each with a product of its own.</summary>
|
||||
private static CpeSeedEntry[] Entries(int count) => Enumerable.Range(0, count)
|
||||
.Select(n => Entry("contoso", $"contoso tool {(char)('a' + n)}", $"cpe:2.3:a:contoso:tool_{(char)('a' + n)}"))
|
||||
.ToArray();
|
||||
|
||||
private static string List(IEnumerable<CpeSeedEntry?> mappings, params string[] selfUpdatingProducts) =>
|
||||
JsonSerializer.Serialize(new CpeSeedDocument
|
||||
{
|
||||
Version = "2.0.0",
|
||||
LastUpdated = "2026-11-12",
|
||||
SelfUpdatingProducts = selfUpdatingProducts.Length == 0 ? null : selfUpdatingProducts.ToList(),
|
||||
Mappings = mappings.ToList()!,
|
||||
}, FileJson);
|
||||
|
||||
private static List<string> ProblemsOf(params CpeSeedEntry?[] mappings) =>
|
||||
CpeSeedFile.Read(List(mappings), mappingsInService: 0).Problems;
|
||||
|
||||
private static void AssertOnlyProblem(string expectedPart, List<string> problems) =>
|
||||
Assert.Contains(expectedPart, Assert.Single(problems));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The list as a whole
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Read_WellFormedList_HasNoProblemsAndReturnsTheDocument()
|
||||
{
|
||||
// Written like the file in the rules repository: camelCase, a comment, a trailing comma,
|
||||
// and the two CPE columns the earlier format carried next to the CPE itself.
|
||||
const string json = """
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"lastUpdated": "2026-11-12",
|
||||
// products that update themselves unless an operator says otherwise
|
||||
"selfUpdatingProducts": ["contoso:widget"],
|
||||
"mappings": [
|
||||
{
|
||||
"normalizedVendor": "contoso",
|
||||
"normalizedProduct": "contoso widget",
|
||||
"displayNamePatterns": ["contoso widget", "widget"],
|
||||
"publisherPatterns": ["contoso", "contoso ltd"],
|
||||
"excludePatterns": ["widget viewer"],
|
||||
"cpeVendor": "contoso",
|
||||
"cpeProduct": "widget",
|
||||
"cpeUri": "cpe:2.3:a:contoso:widget",
|
||||
"category": "utility",
|
||||
"edition": "esr",
|
||||
"excludeEditions": ["beta", "preview_2"],
|
||||
},
|
||||
{
|
||||
"normalizedVendor": "fabrikam",
|
||||
"normalizedProduct": "fabrikam gadget++",
|
||||
"displayNamePatterns": ["fabrikam gadget++"],
|
||||
"cpeUri": "cpe:2.3:a:fabrikam:gadget\\+\\+",
|
||||
},
|
||||
],
|
||||
}
|
||||
""";
|
||||
|
||||
var (document, problems) = CpeSeedFile.Read(json, mappingsInService: 2);
|
||||
|
||||
Assert.Empty(problems);
|
||||
Assert.Equal("2.0.0", document!.Version);
|
||||
Assert.Equal(new[] { "contoso:widget" }, document.SelfUpdatingProducts);
|
||||
Assert.Equal(2, document.Mappings!.Count);
|
||||
var widget = document.Mappings[0];
|
||||
Assert.Equal("contoso widget", widget.NormalizedProduct);
|
||||
Assert.Equal(new[] { "contoso widget", "widget" }, widget.DisplayNamePatterns);
|
||||
Assert.Equal(new[] { "widget viewer" }, widget.ExcludePatterns);
|
||||
Assert.Equal("esr", widget.Edition);
|
||||
Assert.Equal(new[] { "beta", "preview_2" }, widget.ExcludeEditions);
|
||||
Assert.Equal(@"cpe:2.3:a:fabrikam:gadget\+\+", document.Mappings[1].CpeUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{ \"mappings\": [ { \"normalizedVendor\": ")]
|
||||
[InlineData("<html>maintenance</html>")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("{ \"mappings\": \"none\" }")]
|
||||
[InlineData("{ \"mappings\": [ 4 ] }")]
|
||||
public void Read_TextThatIsNotAList_IsRefusedAsInvalidJson(string json)
|
||||
{
|
||||
var (document, problems) = CpeSeedFile.Read(json, mappingsInService: 0, mayBeEmpty: true);
|
||||
|
||||
Assert.Null(document);
|
||||
AssertOnlyProblem("not valid JSON", problems);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" \r\n ")]
|
||||
[InlineData("null")]
|
||||
public void Read_NoTextAtAll_IsRefusedEvenWhereTheListMayBeEmpty(string? json)
|
||||
{
|
||||
var (document, problems) = CpeSeedFile.Read(json, mappingsInService: 0, mayBeEmpty: true);
|
||||
|
||||
Assert.Null(document);
|
||||
AssertOnlyProblem("holds no mapping", problems);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{ \"version\": \"2.0.0\" }")]
|
||||
[InlineData("{ \"version\": \"2.0.0\", \"mappings\": null }")]
|
||||
[InlineData("{ \"version\": \"2.0.0\", \"mappings\": [] }")]
|
||||
public void Read_ListWithoutMappings_IsAcceptedOnlyWhereItMayBeEmptyAndNothingIsInService(string json)
|
||||
{
|
||||
var asSeed = CpeSeedFile.Read(json, mappingsInService: 0);
|
||||
var asCommunityListWithNothingInService = CpeSeedFile.Read(json, mappingsInService: 0, mayBeEmpty: true);
|
||||
var asCommunityListThatWouldRemoveMappings = CpeSeedFile.Read(json, mappingsInService: 1, mayBeEmpty: true);
|
||||
|
||||
AssertOnlyProblem("holds no mapping", asSeed.Problems);
|
||||
Assert.Empty(asCommunityListWithNothingInService.Problems);
|
||||
Assert.Equal("2.0.0", asCommunityListWithNothingInService.Document!.Version);
|
||||
AssertOnlyProblem("holds no mapping", asCommunityListThatWouldRemoveMappings.Problems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ListThatLostMoreThanTheAllowedShare_IsRefused_TheShareItselfIsAccepted()
|
||||
{
|
||||
const int inService = 10;
|
||||
var smallestAccepted = (int)Math.Ceiling(inService * (1 - CpeSeedFile.MaxShrink));
|
||||
|
||||
var atTheLimit = CpeSeedFile.Read(List(Entries(smallestAccepted)), inService);
|
||||
var belowIt = CpeSeedFile.Read(List(Entries(smallestAccepted - 1)), inService);
|
||||
var withNothingInService = CpeSeedFile.Read(List(Entries(1)), mappingsInService: 0);
|
||||
|
||||
Assert.Equal(5, smallestAccepted);
|
||||
Assert.Empty(atTheLimit.Problems);
|
||||
AssertOnlyProblem("shrinks", belowIt.Problems);
|
||||
Assert.Contains("4 mappings", belowIt.Problems[0]);
|
||||
Assert.Contains("10", belowIt.Problems[0]);
|
||||
// The first import has nothing to compare with.
|
||||
Assert.Empty(withNothingInService.Problems);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// One entry
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:4.2")]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*")]
|
||||
[InlineData("cpe:2.3:a:Contoso:Widget")]
|
||||
[InlineData(" cpe:2.3:a:contoso:widget")]
|
||||
public void Read_CpeWithAVersionPartOrUpperCase_IsRefusedWithTheFormToWrite(string cpeUri)
|
||||
{
|
||||
var problems = ProblemsOf(Entry("contoso", "contoso widget", cpeUri));
|
||||
|
||||
AssertOnlyProblem($"write the CPE as '{WidgetCpe}'", problems);
|
||||
Assert.StartsWith("mapping 1 (contoso:contoso widget)", problems[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("contoso widget")]
|
||||
[InlineData("cpe:2.3:a:contoso")]
|
||||
[InlineData("cpe:2.3:a:contoso:*")]
|
||||
[InlineData("cpe:2.3:a:-:widget")]
|
||||
public void Read_CpeThatNamesNoProduct_IsRefused(string? cpeUri) =>
|
||||
AssertOnlyProblem("names no CPE product", ProblemsOf(Entry("contoso", "contoso widget", cpeUri)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:fabrikam:gadget++")]
|
||||
[InlineData("cpe:2.3:a:fabrikam:gadget%2b%2b")]
|
||||
[InlineData(@"cpe:2.3:a:fabrikam:gadget\+\+")]
|
||||
public void Read_PlusSignInAnyOfItsSpellings_IsTheSameProduct(string cpeUri)
|
||||
{
|
||||
var (_, problems) = CpeSeedFile.Read(
|
||||
List(new[] { Entry("fabrikam", "fabrikam gadget++", cpeUri) }, @"fabrikam:gadget\+\+"), mappingsInService: 0);
|
||||
|
||||
Assert.Empty(problems);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("4.2.1 (x64)")] // nothing is left of it once version and architecture are gone
|
||||
public void Read_EntryWithoutAProgramName_IsRefused(string? product) =>
|
||||
AssertOnlyProblem("normalizedProduct is missing", ProblemsOf(Entry("contoso", product, WidgetCpe)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("contoso", "Contoso Widget 4.2 (x64)")] // the same program with its version
|
||||
[InlineData("Contoso", "CONTOSO WIDGET")]
|
||||
[InlineData(" contoso ", "contoso widget")]
|
||||
public void Read_TwoEntriesForTheSameProgram_AreRefused(string vendor, string product)
|
||||
{
|
||||
var problems = ProblemsOf(Widget(), Entry(vendor, product, "cpe:2.3:a:contoso:widget_pro"));
|
||||
|
||||
AssertOnlyProblem("occur twice", problems);
|
||||
Assert.StartsWith("mapping 2 ", problems[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_EntriesWithoutAVendor_ShareOneKeyWhetherItIsEmptyOrThePlaceholder()
|
||||
{
|
||||
var problems = ProblemsOf(Entry("unknown", "contoso widget", WidgetCpe), Entry(null, "contoso widget", WidgetCpe));
|
||||
|
||||
AssertOnlyProblem("occur twice", problems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_TwoEntriesThatWouldShareATableRow_AreRefused()
|
||||
{
|
||||
// '#' and '?' are part of a program's name, but a row key cannot hold either: both
|
||||
// become '_', and the second entry would overwrite the first.
|
||||
const string first = "widget#pro", second = "widget?pro";
|
||||
Assert.NotEqual(CpeMappingMatcher.SoftwareKey("contoso", first), CpeMappingMatcher.SoftwareKey("contoso", second));
|
||||
Assert.Equal(CpeSeedFile.RowKeyOf("contoso", first), CpeSeedFile.RowKeyOf("contoso", second));
|
||||
|
||||
var problems = ProblemsOf(
|
||||
Entry("contoso", first, "cpe:2.3:a:contoso:widget_pro"), Entry("contoso", second, "cpe:2.3:a:contoso:widget_pro_2"));
|
||||
|
||||
AssertOnlyProblem("row of another mapping", problems);
|
||||
Assert.StartsWith("mapping 2 (contoso:widget?pro)", problems[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("--")]
|
||||
[InlineData("")]
|
||||
[InlineData("(®)")]
|
||||
public void Read_PatternWithoutAWord_IsRefused_WhetherItSelectsOrExcludes(string pattern)
|
||||
{
|
||||
var selecting = Widget();
|
||||
selecting.DisplayNamePatterns = new List<string> { "contoso widget", pattern };
|
||||
var excluding = Widget();
|
||||
excluding.ExcludePatterns = new List<string> { pattern };
|
||||
|
||||
AssertOnlyProblem($"the pattern '{pattern}' holds no word", ProblemsOf(selecting));
|
||||
AssertOnlyProblem($"the pattern '{pattern}' holds no word", ProblemsOf(excluding));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ESR")]
|
||||
[InlineData("long term")]
|
||||
[InlineData("")]
|
||||
public void Read_EditionThatIsNotLowerCaseWithoutSpaces_IsRefused(string edition)
|
||||
{
|
||||
var only = Widget();
|
||||
only.Edition = edition;
|
||||
var excluded = Widget();
|
||||
excluded.ExcludeEditions = new List<string> { "beta", edition };
|
||||
|
||||
AssertOnlyProblem($"'{edition}' is not a CPE software edition", ProblemsOf(only));
|
||||
AssertOnlyProblem($"'{edition}' is not a CPE software edition", ProblemsOf(excluded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_EditionsInCpeForm_AreAccepted()
|
||||
{
|
||||
var entry = Widget();
|
||||
entry.Edition = "esr";
|
||||
entry.ExcludeEditions = new List<string> { "lts_2021", "x.y-z", "7" };
|
||||
|
||||
Assert.Empty(ProblemsOf(entry));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_SelfUpdatingProductNoMappingPointsTo_IsRefused()
|
||||
{
|
||||
var known = CpeSeedFile.Read(List(new[] { Widget() }, " Contoso:Widget "), mappingsInService: 0);
|
||||
var unknown = CpeSeedFile.Read(List(new[] { Widget() }, "contoso:widget", "contoso:gadget"), mappingsInService: 0);
|
||||
|
||||
// Compared the way the flags are keyed: trimmed, lower case.
|
||||
Assert.Empty(known.Problems);
|
||||
AssertOnlyProblem("selfUpdatingProducts names 'contoso:gadget'", unknown.Problems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ReportsEveryProblemOfAList_AndAnEmptyEntry()
|
||||
{
|
||||
var noWordPattern = Entry("fabrikam", "fabrikam gadget", "cpe:2.3:a:fabrikam:gadget");
|
||||
noWordPattern.DisplayNamePatterns = new List<string> { "..." };
|
||||
|
||||
var problems = ProblemsOf(Widget(), null, Entry("contoso", "", "cpe:2.3:a:contoso"), noWordPattern);
|
||||
|
||||
Assert.Equal(4, problems.Count);
|
||||
Assert.Equal("mapping 2 is empty", problems[0]);
|
||||
Assert.Contains("names no CPE product", problems[1]);
|
||||
Assert.Contains("normalizedProduct is missing", problems[2]);
|
||||
Assert.Contains("holds no word", problems[3]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rows
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ToRows_BuildsOneTableRowPerEntry()
|
||||
{
|
||||
var document = new CpeSeedDocument
|
||||
{
|
||||
Mappings = new List<CpeSeedEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
NormalizedVendor = "Contoso",
|
||||
NormalizedProduct = "Contoso Widget",
|
||||
CpeUri = "cpe:2.3:a:Contoso:Widget:*:*:*:*:*:*:*:*",
|
||||
Category = "utility",
|
||||
DisplayNamePatterns = new List<string> { "contoso widget", "widget" },
|
||||
PublisherPatterns = new List<string> { "contoso ltd" },
|
||||
ExcludePatterns = new List<string> { "widget viewer" },
|
||||
Edition = " ESR ",
|
||||
ExcludeEditions = new List<string> { " Beta ", "preview" },
|
||||
},
|
||||
new() { NormalizedVendor = "fabrikam", NormalizedProduct = "gadget#pro", CpeUri = "cpe:2.3:a:fabrikam:gadget%2b%2b" },
|
||||
},
|
||||
};
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var rows = CpeSeedFile.ToRows(document, "community");
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
var (widgetKey, widget) = rows[0];
|
||||
Assert.Equal(CpeSeedFile.RowKeyOf("Contoso", "Contoso Widget"), widgetKey);
|
||||
Assert.Equal("contoso:contoso widget", widgetKey);
|
||||
Assert.Equal("Contoso", widget["NormalizedVendor"]);
|
||||
Assert.Equal("Contoso Widget", widget["NormalizedProduct"]);
|
||||
Assert.Equal(WidgetCpe, widget["CpeUri"]);
|
||||
Assert.Equal("contoso", widget["CpeVendor"]);
|
||||
Assert.Equal("widget", widget["CpeProduct"]);
|
||||
Assert.Equal("utility", widget["Category"]);
|
||||
Assert.Equal("""["contoso widget","widget"]""", widget["DisplayNamePatternsJson"]);
|
||||
Assert.Equal("""["contoso ltd"]""", widget["PublisherPatternsJson"]);
|
||||
Assert.Equal("""["widget viewer"]""", widget["ExcludePatternsJson"]);
|
||||
Assert.Equal("community", widget["Source"]);
|
||||
Assert.Equal("esr", widget["Edition"]);
|
||||
Assert.Equal("""["beta","preview"]""", widget["ExcludeEditionsJson"]);
|
||||
var importedAt = DateTime.Parse((string)widget["ImportedAt"], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
Assert.InRange(importedAt, before, DateTime.UtcNow);
|
||||
|
||||
var (gadgetKey, gadget) = rows[1];
|
||||
Assert.Equal("fabrikam:gadget_pro", gadgetKey);
|
||||
Assert.Equal(@"cpe:2.3:a:fabrikam:gadget\+\+", gadget["CpeUri"]);
|
||||
Assert.Equal("fabrikam", gadget["CpeVendor"]);
|
||||
Assert.Equal(@"gadget\+\+", gadget["CpeProduct"]);
|
||||
Assert.Equal(string.Empty, gadget["Category"]);
|
||||
Assert.Equal("[]", gadget["DisplayNamePatternsJson"]);
|
||||
Assert.Equal("[]", gadget["PublisherPatternsJson"]);
|
||||
Assert.Equal("[]", gadget["ExcludePatternsJson"]);
|
||||
// An edition column is only written for an entry that restricts editions.
|
||||
Assert.False(gadget.ContainsKey("Edition"));
|
||||
Assert.False(gadget.ContainsKey("ExcludeEditionsJson"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRows_RowsAreWhatTheMappingStoreReadsBack()
|
||||
{
|
||||
var entry = Widget();
|
||||
entry.DisplayNamePatterns = new List<string> { "contoso widget" };
|
||||
entry.PublisherPatterns = new List<string> { "contoso ltd" };
|
||||
entry.ExcludePatterns = new List<string> { "widget viewer" };
|
||||
entry.Edition = "esr";
|
||||
entry.ExcludeEditions = new List<string> { "beta" };
|
||||
var (rowKey, properties) = Assert.Single(CpeSeedFile.ToRows(new CpeSeedDocument { Mappings = new List<CpeSeedEntry> { entry } }, "seed"));
|
||||
var stored = new Dictionary<string, object>(properties) { ["PartitionKey"] = CpeMappingStore.SeedPartition, ["RowKey"] = rowKey };
|
||||
|
||||
var mapping = CpeMappingStore.MappingFromRow(stored)!;
|
||||
|
||||
Assert.Equal(CpeMappingSource.Seed, mapping.Source);
|
||||
Assert.Equal(WidgetCpe, mapping.CpeUri);
|
||||
Assert.Equal("contoso:widget", mapping.ProductKey);
|
||||
Assert.Equal(new[] { "contoso widget" }, mapping.DisplayNamePatterns);
|
||||
Assert.Equal(new[] { "contoso ltd" }, mapping.PublisherPatterns);
|
||||
Assert.Equal(new[] { "widget viewer" }, mapping.ExcludePatterns);
|
||||
Assert.Equal("esr", mapping.Edition);
|
||||
Assert.Equal(new[] { "beta" }, mapping.ExcludeEditions);
|
||||
Assert.Equal(properties["ImportedAt"], mapping.CreatedAt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Contoso", "Contoso Widget", "contoso:contoso widget")]
|
||||
[InlineData(null, "Contoso Widget", "unknown:contoso widget")]
|
||||
[InlineData("", "Contoso Widget", "unknown:contoso widget")]
|
||||
[InlineData("contoso", null, "contoso:unknown")]
|
||||
[InlineData("contoso/emea", @"c:\tools\widget#1?", "contoso_emea:c:_tools_widget_1_")]
|
||||
public void RowKeyOf_IsVendorAndProductInLowerCase_WithoutWhatAKeyCannotHold(string? vendor, string? product, string expected) =>
|
||||
Assert.Equal(expected, CpeSeedFile.RowKeyOf(vendor, product));
|
||||
|
||||
[Fact]
|
||||
public void RowKeyOf_IsCutAtTheLengthAKeyMayHave()
|
||||
{
|
||||
var rowKey = CpeSeedFile.RowKeyOf("contoso", new string('w', 600));
|
||||
|
||||
Assert.Equal(512, rowKey.Length);
|
||||
Assert.StartsWith("contoso:www", rowKey);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The committed list
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CommittedCommunityList_PassesTheCheckAnImportRuns()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FindRepoRoot(), "rules", "dist", "cpe-community-mappings.json"));
|
||||
|
||||
var (document, problems) = CpeSeedFile.Read(json, mappingsInService: 0, mayBeEmpty: true);
|
||||
|
||||
Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems));
|
||||
Assert.NotNull(document);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommittedSeedList_PassesTheCheckAnImportRuns_AndHoldsTheWholeList()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FindRepoRoot(), "rules", "dist", "cpe-mappings.json"));
|
||||
|
||||
var (document, problems) = CpeSeedFile.Read(json, mappingsInService: 0);
|
||||
|
||||
Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems));
|
||||
// A list cut short by a merge accident would still pass the check above.
|
||||
var mappings = document!.Mappings!.Count;
|
||||
Assert.True(mappings >= 400, $"The committed list holds {mappings} mappings.");
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
return dir?.FullName ?? throw new InvalidOperationException("Repository root (AutopilotMonitor.sln) not found.");
|
||||
}
|
||||
}
|
||||
@@ -203,4 +203,72 @@ public class CveCacheBudgetTests
|
||||
Assert.Equal(0, r.TotalIn);
|
||||
Assert.Empty(r.Cves);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Known-exploited CVEs and the cap
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static CachedCve Exploited(string id, double score, DateTime publishedUtc)
|
||||
{
|
||||
var cve = Cve(id, score, publishedUtc);
|
||||
cve.KevAdded = "2026-03-01";
|
||||
return cve;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_KnownExploitedCves_SurviveTheCapAheadOfHigherScores()
|
||||
{
|
||||
// More critical CVEs in the window than a row holds: by score alone the three exploited
|
||||
// ones would be the first to go.
|
||||
var cves = Enumerable.Range(1, 105)
|
||||
.Select(i => Cve($"CVE-Critical-{i:000}", 9.8, Now.AddMonths(-2)))
|
||||
.Concat(new[]
|
||||
{
|
||||
Exploited("CVE-Kev-1", 5.0, Now.AddMonths(-3)),
|
||||
Exploited("CVE-Kev-2", 5.0, Now.AddMonths(-10)),
|
||||
Exploited("CVE-Kev-3", 5.0, Now.AddMonths(-20)),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var r = CveCacheBudget.Apply(cves, Now);
|
||||
|
||||
Assert.Equal(108, r.KeptAfterDateFilter);
|
||||
Assert.Equal(CveCacheBudget.MaxCachedCves, r.Cves.Count);
|
||||
Assert.Equal(new[] { "CVE-Kev-1", "CVE-Kev-2", "CVE-Kev-3" }, r.Cves.Take(3).Select(c => c.CveId));
|
||||
Assert.All(r.Cves.Skip(3), c => Assert.Equal(9.8, c.CvssScore));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_KnownExploitedCveOutsideTheDateWindow_IsStillDropped()
|
||||
{
|
||||
var cves = new[]
|
||||
{
|
||||
Exploited("CVE-Kev-OldMedium", 5.0, Now.AddMonths(-30)), // below HIGH: 24 months
|
||||
Exploited("CVE-Kev-OldCritical", 9.8, Now.AddMonths(-61)), // HIGH and above: 60 months
|
||||
Exploited("CVE-Kev-OldHigh", 7.5, Now.AddMonths(-40)),
|
||||
};
|
||||
|
||||
var r = CveCacheBudget.Apply(cves, Now);
|
||||
|
||||
Assert.Equal("CVE-Kev-OldHigh", Assert.Single(r.Cves).CveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_OrderAmongTheOtherCves_StaysScoreThenDate()
|
||||
{
|
||||
var cves = new[]
|
||||
{
|
||||
Cve("CVE-Medium", 5.0, Now.AddMonths(-1)),
|
||||
Cve("CVE-High-Older", 8.0, Now.AddMonths(-12)),
|
||||
Exploited("CVE-Kev-Low", 3.1, Now.AddMonths(-6)),
|
||||
Cve("CVE-Critical", 9.8, Now.AddMonths(-20)),
|
||||
Cve("CVE-High-Newer", 8.0, Now.AddMonths(-1)),
|
||||
};
|
||||
|
||||
var r = CveCacheBudget.Apply(cves, Now);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "CVE-Kev-Low", "CVE-Critical", "CVE-High-Newer", "CVE-High-Older", "CVE-Medium" },
|
||||
r.Cves.Select(c => c.CveId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CveCacheFiller"/> is the one path from an NVD answer to a product's cache row.
|
||||
/// Pinned here: a row holds only the product's own version ranges, an answer NVD did not give is
|
||||
/// never written, a marker for missing data never overwrites a row, and a rewrite hands back the
|
||||
/// columns the replace-mode write would otherwise lose.
|
||||
/// </summary>
|
||||
[Collection(NvdTestState.Collection)]
|
||||
public sealed class CveCacheFillerTests : IDisposable
|
||||
{
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private static readonly string WidgetKey = CveCacheFiller.CacheKey(WidgetCpe);
|
||||
|
||||
public CveCacheFillerTests() => NvdTestState.Reset();
|
||||
|
||||
public void Dispose() => NvdTestState.Reset();
|
||||
|
||||
private static NvdCpeMatch Match(string criteria, string? endExcluding = null, bool vulnerable = true) => new()
|
||||
{
|
||||
Vulnerable = vulnerable,
|
||||
Criteria = criteria,
|
||||
VersionEndExcluding = endExcluding,
|
||||
};
|
||||
|
||||
private static NvdCveResponse Response(params NvdVulnerabilityItem[] items) =>
|
||||
new() { TotalResults = items.Length, Vulnerabilities = items.ToList() };
|
||||
|
||||
private static CveCacheFiller Filler(
|
||||
Mock<IVulnerabilityRepository> repo, Func<NvdRequest, HttpResponseMessage> nvdAnswer, EpssApiClient? epss = null) =>
|
||||
new(repo.Object, NvdFixture.Client(new NvdStubHandler(nvdAnswer)), NullLogger<CveCacheFiller>.Instance, epss);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Project
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Project_KeepsOnlyTheRangesOfTheProductItself()
|
||||
{
|
||||
// A prefix compare on the string would take acrobat_reader_dc and acrobat_dc for acrobat.
|
||||
var response = Response(
|
||||
NvdFixture.Cve("CVE-2025-0001", 7.8,
|
||||
Match("cpe:2.3:a:adobe:acrobat:*:*:*:*:*:*:*:*", "24.0"),
|
||||
Match("cpe:2.3:a:adobe:acrobat_reader_dc:*:*:*:*:continuous:*:*:*", "25.0"),
|
||||
Match("cpe:2.3:a:adobe:acrobat_dc:*:*:*:*:continuous:*:*:*", "25.0"),
|
||||
Match("cpe:2.3:o:microsoft:windows:-:*:*:*:*:*:*:*")),
|
||||
NvdFixture.Cve("CVE-2025-0002", 9.8,
|
||||
Match("cpe:2.3:a:adobe:acrobat_reader_dc:*:*:*:*:continuous:*:*:*", "25.0")));
|
||||
|
||||
var cves = CveCacheFiller.Project(response, "cpe:2.3:a:adobe:acrobat");
|
||||
|
||||
var kept = Assert.Single(cves);
|
||||
Assert.Equal("CVE-2025-0001", kept.CveId);
|
||||
var range = Assert.Single(kept.AffectedVersions);
|
||||
Assert.Equal("cpe:2.3:a:adobe:acrobat:*:*:*:*:*:*:*:*", range.Criteria);
|
||||
Assert.Equal("24.0", range.VersionEndExcluding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_DropsOwnRangesThatAreNotVulnerable_AndACveLeftWithoutOne()
|
||||
{
|
||||
var response = Response(
|
||||
NvdFixture.Cve("CVE-2025-0003", 7.5,
|
||||
Match("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "3.0"),
|
||||
Match("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "9.0", vulnerable: false)),
|
||||
NvdFixture.Cve("CVE-2025-0004", 7.5,
|
||||
Match("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "9.0", vulnerable: false)));
|
||||
|
||||
var kept = Assert.Single(CveCacheFiller.Project(response, WidgetCpe));
|
||||
|
||||
Assert.Equal("CVE-2025-0003", kept.CveId);
|
||||
Assert.Equal("3.0", Assert.Single(kept.AffectedVersions).VersionEndExcluding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shaped like the public CVE-2024-3661: one CVE names two products and gives a version range
|
||||
/// for the other one only. The first product's row must not inherit that range.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Project_CveNamingTwoProducts_KeepsOnlyTheOwnRange()
|
||||
{
|
||||
const string otherCpe = "cpe:2.3:a:fabrikam:secure_client";
|
||||
var response = Response(NvdFixture.Cve("CVE-2024-3661", 7.6,
|
||||
Match("cpe:2.3:a:contoso:widget:-:*:*:*:*:*:*:*"),
|
||||
Match("cpe:2.3:a:fabrikam:secure_client:*:*:*:*:*:*:*:*", "24.06.1")));
|
||||
|
||||
var own = Assert.Single(CveCacheFiller.Project(response, WidgetCpe));
|
||||
var other = Assert.Single(CveCacheFiller.Project(response, otherCpe));
|
||||
|
||||
var ownRange = Assert.Single(own.AffectedVersions);
|
||||
Assert.Equal("cpe:2.3:a:contoso:widget:-:*:*:*:*:*:*:*", ownRange.Criteria);
|
||||
Assert.Null(ownRange.VersionEndExcluding);
|
||||
Assert.Equal("24.06.1", Assert.Single(other.AffectedVersions).VersionEndExcluding);
|
||||
|
||||
// Read back through the filter, the widget is not judged by the other product's range.
|
||||
Assert.Empty(CveVersionFilter.Filter(new[] { own }, WidgetCpe, "3.2.0").Vulnerabilities);
|
||||
Assert.Single(CveVersionFilter.Filter(new[] { other }, otherCpe, "3.2.0").Vulnerabilities);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2023-09-13", "2023-09-13")]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(" ", null)]
|
||||
public void Project_TakesTheExploitedDateFromNvd(string? cisaExploitAdd, string? expected)
|
||||
{
|
||||
var item = NvdFixture.Cve("CVE-2025-0005", 8.8, NvdFixture.Below(WidgetCpe, "2.0"));
|
||||
item.Cve!.CisaExploitAdd = cisaExploitAdd;
|
||||
|
||||
Assert.Equal(expected, Assert.Single(CveCacheFiller.Project(Response(item), WidgetCpe)).KevAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_FallsBackToCvss30_AndReportsUnknownWithoutMetrics()
|
||||
{
|
||||
var onlyV30 = NvdFixture.Cve("CVE-2025-0006", 0, NvdFixture.Below(WidgetCpe, "2.0"));
|
||||
onlyV30.Cve!.Metrics = new NvdMetrics
|
||||
{
|
||||
CvssMetricV30 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 6.5, BaseSeverity = "MEDIUM", VectorString = "CVSS:3.0/AV:N" } } },
|
||||
};
|
||||
var noMetrics = NvdFixture.Cve("CVE-2025-0007", 0, NvdFixture.Below(WidgetCpe, "2.0"));
|
||||
noMetrics.Cve!.Metrics = null;
|
||||
|
||||
var cves = CveCacheFiller.Project(Response(onlyV30, noMetrics), WidgetCpe);
|
||||
|
||||
Assert.Equal(6.5, cves[0].CvssScore);
|
||||
Assert.Equal("MEDIUM", cves[0].CvssSeverity);
|
||||
Assert.Equal("CVSS:3.0/AV:N", cves[0].CvssVector);
|
||||
Assert.Equal(0, cves[1].CvssScore);
|
||||
Assert.Equal("UNKNOWN", cves[1].CvssSeverity);
|
||||
Assert.Null(cves[1].CvssVector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_CutsTheDescriptionAt500Chars()
|
||||
{
|
||||
var tooLong = NvdFixture.Cve("CVE-2025-0008", 5.0, NvdFixture.Below(WidgetCpe, "2.0"));
|
||||
tooLong.Cve!.Descriptions = new List<NvdDescription>
|
||||
{
|
||||
new() { Lang = "es", Value = "Otra descripcion." },
|
||||
new() { Lang = "en", Value = new string('a', 501) },
|
||||
};
|
||||
var fits = NvdFixture.Cve("CVE-2025-0009", 5.0, NvdFixture.Below(WidgetCpe, "2.0"));
|
||||
fits.Cve!.Descriptions = new List<NvdDescription> { new() { Lang = "en", Value = new string('b', 500) } };
|
||||
|
||||
var cves = CveCacheFiller.Project(Response(tooLong, fits), WidgetCpe);
|
||||
|
||||
Assert.Equal(new string('a', 497) + "...", cves[0].Description);
|
||||
Assert.Equal(new string('b', 500), cves[1].Description);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// FillAsync
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Fill_WritesOneRowInTheCurrentShape_AndReturnsItsEntry()
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>();
|
||||
Dictionary<string, object>? written = null;
|
||||
repo.Setup(r => r.UpsertCveCacheEntryAsync(WidgetKey, CveCacheFiller.RowKey, It.IsAny<Dictionary<string, object>>()))
|
||||
.Callback<string, string, Dictionary<string, object>>((_, _, row) => written = row)
|
||||
.Returns(Task.CompletedTask);
|
||||
var listed = NvdFixture.Cve("CVE-2025-0010", 8.8, NvdFixture.Below(WidgetCpe, "2.0"));
|
||||
listed.Cve!.CisaExploitAdd = "2025-03-01";
|
||||
// NVD reports more than it sends here; the row keeps the figure NVD gave.
|
||||
var filler = Filler(repo, _ => NvdFixture.Page(4711, new[]
|
||||
{
|
||||
listed,
|
||||
NvdFixture.Cve("CVE-2025-0011", 5.3, NvdFixture.Below(WidgetCpe, "1.5")),
|
||||
NvdFixture.Cve("CVE-2025-0012", 9.8, NvdFixture.Below("cpe:2.3:a:fabrikam:gadget", "7.0")),
|
||||
}));
|
||||
|
||||
var started = DateTime.UtcNow;
|
||||
var result = await filler.FillAsync(WidgetCpe, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CveCacheFillOutcome.Filled, result.Outcome);
|
||||
Assert.Equal(4711, result.NvdTotal);
|
||||
Assert.Equal(2, result.InWindow);
|
||||
Assert.Equal(2, result.Kept);
|
||||
Assert.Equal(new[] { "CVE-2025-0010", "CVE-2025-0011" }, result.Entry!.Cves.Select(c => c.CveId));
|
||||
Assert.InRange(result.Entry.CachedAt, started, DateTime.UtcNow);
|
||||
|
||||
repo.Verify(r => r.UpsertCveCacheEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, object>>()), Times.Once);
|
||||
Assert.NotNull(written);
|
||||
Assert.Equal(CveCacheFiller.SchemaVersion, written!["SchemaVersion"]);
|
||||
Assert.Equal(2, written["SchemaVersion"]);
|
||||
Assert.Equal("filled", written["State"]);
|
||||
Assert.Equal(WidgetCpe, written["CpeUri"]);
|
||||
Assert.Equal(2, written["TotalCves"]);
|
||||
Assert.Equal(4711, written["NvdTotal"]);
|
||||
Assert.Equal(result.Entry.CachedAt.ToString("o"), written["CachedAt"]);
|
||||
|
||||
var stored = JsonSerializer.Deserialize<List<CachedCve>>((string)written["CveDataJson"], CveCacheFiller.JsonOptions)!;
|
||||
Assert.Equal(new[] { "CVE-2025-0010", "CVE-2025-0011" }, stored.Select(c => c.CveId));
|
||||
Assert.Equal("2025-03-01", stored[0].KevAdded);
|
||||
Assert.Equal("2.0", Assert.Single(stored[0].AffectedVersions).VersionEndExcluding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fill_SplitsALargePayloadOverSeveralColumns_AndTheReaderPutsItBackTogether()
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>();
|
||||
Dictionary<string, object>? written = null;
|
||||
repo.Setup(r => r.UpsertCveCacheEntryAsync(WidgetKey, CveCacheFiller.RowKey, It.IsAny<Dictionary<string, object>>()))
|
||||
.Callback<string, string, Dictionary<string, object>>((_, _, row) => written = row)
|
||||
.Returns(Task.CompletedTask);
|
||||
var items = Enumerable.Range(1, 80).Select(n =>
|
||||
{
|
||||
var item = NvdFixture.Cve($"CVE-2025-1{n:000}", 7.5, NvdFixture.Below(WidgetCpe, "2.0"));
|
||||
item.Cve!.Descriptions = new List<NvdDescription> { new() { Lang = "en", Value = new string('d', 500) } };
|
||||
return item;
|
||||
}).ToArray();
|
||||
var filler = Filler(repo, _ => NvdFixture.Page(items));
|
||||
|
||||
var result = await filler.FillAsync(WidgetCpe, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(written);
|
||||
Assert.False(written!.ContainsKey("CveDataJson"));
|
||||
Assert.True(int.Parse((string)written["CveDataJson_ChunkCount"], CultureInfo.InvariantCulture) >= 2);
|
||||
Assert.Equal(80, written["TotalCves"]);
|
||||
|
||||
var readBack = filler.EntryFromRow(written, WidgetKey);
|
||||
Assert.Equal(result.Entry!.Cves.Select(c => c.CveId), readBack!.Cves.Select(c => c.CveId));
|
||||
Assert.Equal(80, readBack.Cves.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.TooManyRequests, "Throttled")]
|
||||
[InlineData(HttpStatusCode.InternalServerError, "Unanswered")]
|
||||
[InlineData(HttpStatusCode.NotFound, "Failed")]
|
||||
public async Task Fill_AnswerNvdDidNotGive_WritesNothing(HttpStatusCode status, string expected)
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>(MockBehavior.Strict);
|
||||
var filler = Filler(repo, _ => NvdFixture.Status(status));
|
||||
|
||||
var result = await filler.FillAsync(WidgetCpe, CancellationToken.None);
|
||||
|
||||
// A server error says NVD is not serving; a client error says only that this request is wrong.
|
||||
Assert.Equal(expected, result.Outcome.ToString());
|
||||
Assert.Null(result.Entry);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fill_RowCannotBeWritten_StillReturnsTheData()
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>();
|
||||
repo.Setup(r => r.UpsertCveCacheEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, object>>()))
|
||||
.ThrowsAsync(new Azure.RequestFailedException(503, "busy"));
|
||||
var filler = Filler(repo, _ => NvdFixture.Page(NvdFixture.Cve("CVE-2025-0013", 8.8, NvdFixture.Below(WidgetCpe, "2.0"))));
|
||||
|
||||
var result = await filler.FillAsync(WidgetCpe, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CveCacheFillOutcome.Filled, result.Outcome);
|
||||
Assert.Equal("CVE-2025-0013", Assert.Single(result.Entry!.Cves).CveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fill_AddsEpssScoresInOneBatch_AndCachesWithoutThemWhenFirstFails()
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>();
|
||||
var epssRequests = 0;
|
||||
var epssUp = true;
|
||||
var epss = new EpssApiClient(new HttpClient(new LambdaHandler(_ =>
|
||||
{
|
||||
epssRequests++;
|
||||
return epssUp
|
||||
? NvdFixture.Json("""{"status":"OK","data":[{"cve":"CVE-2025-0014","epss":"0.42","percentile":"0.97","date":"2026-11-11"}]}""")
|
||||
: NvdFixture.Status(HttpStatusCode.BadGateway);
|
||||
})), NullLogger<EpssApiClient>.Instance);
|
||||
var filler = Filler(repo, _ => NvdFixture.Page(
|
||||
NvdFixture.Cve("CVE-2025-0014", 8.8, NvdFixture.Below(WidgetCpe, "2.0")),
|
||||
NvdFixture.Cve("CVE-2025-0015", 5.0, NvdFixture.Below(WidgetCpe, "2.0"))), epss);
|
||||
|
||||
var scored = await filler.FillAsync(WidgetCpe, CancellationToken.None);
|
||||
epssUp = false;
|
||||
var unscored = await filler.FillAsync(WidgetCpe, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, epssRequests);
|
||||
Assert.Equal(0.42, scored.Entry!.Cves.Single(c => c.CveId == "CVE-2025-0014").EpssScore);
|
||||
Assert.Null(scored.Entry.Cves.Single(c => c.CveId == "CVE-2025-0015").EpssScore);
|
||||
Assert.Equal(CveCacheFillOutcome.Filled, unscored.Outcome);
|
||||
Assert.All(unscored.Entry!.Cves, c => Assert.Null(c.EpssScore));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MarkPendingAsync
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task MarkPending_OnlyEverAddsTheRow()
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>(MockBehavior.Strict);
|
||||
Dictionary<string, object>? marker = null;
|
||||
repo.Setup(r => r.TryAddCveCacheEntryAsync(WidgetKey, CveCacheFiller.RowKey, It.IsAny<Dictionary<string, object>>()))
|
||||
.Callback<string, string, Dictionary<string, object>>((_, _, row) => marker = row)
|
||||
.ReturnsAsync(false); // a row is already there: it stays as it is
|
||||
var filler = Filler(repo, _ => NvdFixture.Page());
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
await filler.MarkPendingAsync(WidgetCpe);
|
||||
|
||||
Assert.NotNull(marker);
|
||||
Assert.Equal("pending", marker!["State"]);
|
||||
Assert.Equal(WidgetCpe, marker["CpeUri"]);
|
||||
Assert.Equal(CveCacheFiller.SchemaVersion, marker["SchemaVersion"]);
|
||||
var requestedAt = DateTime.Parse((string)marker["RequestedAt"], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
Assert.InRange(requestedAt, before, DateTime.UtcNow);
|
||||
Assert.False(marker.ContainsKey("CachedAt"));
|
||||
// Nothing else was called: an upsert would replace a filled row with the marker.
|
||||
repo.Verify(r => r.TryAddCveCacheEntryAsync(WidgetKey, CveCacheFiller.RowKey, It.IsAny<Dictionary<string, object>>()), Times.Once);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkPending_StorageFailure_IsSwallowed()
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>();
|
||||
repo.Setup(r => r.TryAddCveCacheEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, object>>()))
|
||||
.ThrowsAsync(new Azure.RequestFailedException(503, "busy"));
|
||||
|
||||
await Filler(repo, _ => NvdFixture.Page()).MarkPendingAsync(WidgetCpe);
|
||||
|
||||
repo.Verify(r => r.TryAddCveCacheEntryAsync(WidgetKey, CveCacheFiller.RowKey, It.IsAny<Dictionary<string, object>>()), Times.Once);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ReadAsync / EntryFromRow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly DateTime CachedAt = new(2026, 11, 3, 4, 5, 6, DateTimeKind.Utc);
|
||||
|
||||
private static async Task<CveCacheEntry?> ReadAsync(Dictionary<string, object>? row)
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>();
|
||||
repo.Setup(r => r.GetCveCacheEntryAsync(WidgetKey, CveCacheFiller.RowKey)).ReturnsAsync(row);
|
||||
return await Filler(repo, _ => NvdFixture.Page()).ReadAsync(WidgetCpe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_FilledRow_RoundTripsItsCvesAndFreshnessStamp()
|
||||
{
|
||||
var cve = VulnerabilityCacheRows.Cve("CVE-2025-0016", 8.8, WidgetCpe, "2.0");
|
||||
cve.KevAdded = "2025-03-01";
|
||||
cve.EpssScore = 0.42;
|
||||
|
||||
var entry = await ReadAsync(VulnerabilityCacheRows.Filled(WidgetCpe, new[] { cve }, CachedAt, nvdTotal: 12));
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(CachedAt, entry!.CachedAt);
|
||||
Assert.Equal(DateTimeKind.Utc, entry.CachedAt.Kind);
|
||||
var read = Assert.Single(entry.Cves);
|
||||
Assert.Equal("CVE-2025-0016", read.CveId);
|
||||
Assert.Equal("2025-03-01", read.KevAdded);
|
||||
Assert.Equal(0.42, read.EpssScore);
|
||||
Assert.Equal("2.0", Assert.Single(read.AffectedVersions).VersionEndExcluding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_RowOfTheOlderShape_StillServes()
|
||||
{
|
||||
var row = VulnerabilityCacheRows.OlderShape(
|
||||
WidgetCpe, new[] { VulnerabilityCacheRows.Cve("CVE-2025-0017", 7.5, WidgetCpe, "2.0") }, CachedAt);
|
||||
|
||||
var entry = await ReadAsync(row);
|
||||
|
||||
Assert.Equal("CVE-2025-0017", Assert.Single(entry!.Cves).CveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_PayloadSplitOverSeveralColumns_IsReassembled()
|
||||
{
|
||||
var cves = Enumerable.Range(1, 9)
|
||||
.Select(n => VulnerabilityCacheRows.Cve($"CVE-2025-2{n:000}", 7.5, WidgetCpe, "2.0"))
|
||||
.ToList();
|
||||
var json = JsonSerializer.Serialize(cves, CveCacheFiller.JsonOptions);
|
||||
var third = json.Length / 3;
|
||||
var row = new Dictionary<string, object>
|
||||
{
|
||||
["CachedAt"] = CachedAt.ToString("o"),
|
||||
["CpeUri"] = WidgetCpe,
|
||||
["State"] = "filled",
|
||||
["CveDataJson_0"] = json[..third],
|
||||
["CveDataJson_1"] = json[third..(2 * third)],
|
||||
["CveDataJson_2"] = json[(2 * third)..],
|
||||
["CveDataJson_ChunkCount"] = "3",
|
||||
};
|
||||
|
||||
var entry = await ReadAsync(row);
|
||||
|
||||
Assert.Equal(cves.Select(c => c.CveId), entry!.Cves.Select(c => c.CveId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_PendingMarker_MissingRow_OrUnreadableStamp_IsNoData()
|
||||
{
|
||||
var unreadable = VulnerabilityCacheRows.Filled(WidgetCpe, Array.Empty<CachedCve>(), CachedAt);
|
||||
unreadable["CachedAt"] = "not a date";
|
||||
|
||||
Assert.Null(await ReadAsync(VulnerabilityCacheRows.Pending(WidgetCpe, CachedAt)));
|
||||
Assert.Null(await ReadAsync(null));
|
||||
Assert.Null(await ReadAsync(unreadable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_StorageFailure_IsNoData()
|
||||
{
|
||||
var repo = new Mock<IVulnerabilityRepository>();
|
||||
repo.Setup(r => r.GetCveCacheEntryAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new Azure.RequestFailedException(503, "busy"));
|
||||
|
||||
Assert.Null(await Filler(repo, _ => NvdFixture.Page()).ReadAsync(WidgetCpe));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// BuildRow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildRow_HandsBackFreshnessTotalAndShapeAsGiven()
|
||||
{
|
||||
var cves = new List<CachedCve> { VulnerabilityCacheRows.Cve("CVE-2025-0018", 7.5, WidgetCpe, "2.0") };
|
||||
|
||||
// What an EPSS-only rewrite of an older row passes: its own stamp, total and shape.
|
||||
var rewritten = CveCacheFiller.BuildRow(WidgetCpe, cves, CachedAt, nvdTotal: 4711, schemaVersion: 1);
|
||||
var fresh = CveCacheFiller.BuildRow(WidgetCpe, cves, CachedAt, nvdTotal: 1);
|
||||
|
||||
Assert.Equal(CachedAt.ToString("o"), rewritten["CachedAt"]);
|
||||
Assert.Equal(4711, rewritten["NvdTotal"]);
|
||||
Assert.Equal(1, rewritten["SchemaVersion"]);
|
||||
Assert.Equal("filled", rewritten["State"]);
|
||||
Assert.Equal(1, rewritten["TotalCves"]);
|
||||
Assert.Equal(CveCacheFiller.SchemaVersion, fresh["SchemaVersion"]);
|
||||
}
|
||||
|
||||
/// <summary>The keys existing rows are stored under; the refresh walk compares them with the mappings.</summary>
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:contoso:widget", "cve_cpe:2.3:a:contoso:widget")]
|
||||
[InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+", "cve_cpe:2.3:a:microsoft:visual_c_+_+")] // a backslash cannot be part of a table key
|
||||
public void CacheKey_IsThePartitionKeyOfTheProductsRow(string cpeUri, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CveCacheFiller.CacheKey(cpeUri));
|
||||
Assert.Equal("cache_result", CveCacheFiller.RowKey);
|
||||
}
|
||||
|
||||
private sealed class LambdaHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
|
||||
|
||||
public LambdaHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_respond(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A session's CveIndex rows follow its report: <see cref="TableStorageService.ReplaceCveIndexEntriesAsync"/>
|
||||
/// writes the rows of the new findings and removes those only the replaced report had. A row that
|
||||
/// stays keeps its detection date, so re-evaluating an old session does not move it into the
|
||||
/// fleet summary's window, which <see cref="TableStorageService.ScanCveIndexAsync"/> applies in
|
||||
/// the query. The index takes the five most urgent CVEs of a software entry plus every
|
||||
/// known-exploited one.
|
||||
/// </summary>
|
||||
public class CveIndexReplaceTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Session = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static Dictionary<string, object> Vuln(string cveId, bool kev = false) => new()
|
||||
{
|
||||
["cveId"] = cveId,
|
||||
["cvssScore"] = 7.5,
|
||||
["cvssSeverity"] = "HIGH",
|
||||
["isKev"] = kev,
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> Finding(string software, params Dictionary<string, object>[] vulns) => new()
|
||||
{
|
||||
["softwareName"] = software,
|
||||
["cpeUri"] = "cpe:2.3:a:contoso:widget:3.2.0",
|
||||
["riskLevel"] = "high",
|
||||
["vulnerabilities"] = vulns.ToList(),
|
||||
};
|
||||
|
||||
private static List<Dictionary<string, object>> Findings(params string[] cveIds) =>
|
||||
new() { Finding("Contoso Widget", cveIds.Select(id => Vuln(id)).ToArray()) };
|
||||
|
||||
private sealed record Write(string Call, string CveId, bool HasDetectedAt, ETag IfMatch, TableUpdateMode Mode);
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public Mock<TableServiceClient> Service { get; } = new();
|
||||
public ConcurrentQueue<Write> Writes { get; } = new();
|
||||
public ConcurrentQueue<(string PartitionKey, string RowKey)> Deletes { get; } = new();
|
||||
public List<(string? Filter, List<string> Select)> Queries { get; } = new();
|
||||
public List<TableEntity> Rows { get; } = new();
|
||||
|
||||
/// <summary>Status the table answers with, per call and CVE id; none means success.</summary>
|
||||
public Func<string, string, int?> Fail { get; set; } = (_, _) => null;
|
||||
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(t => t.UpdateEntityAsync(It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((entity, ifMatch, mode, _) =>
|
||||
Record(new Write("update", entity.GetString("CveId"), entity.ContainsKey("DetectedAt"), ifMatch, mode)));
|
||||
Table.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((entity, mode, _) =>
|
||||
Record(new Write("upsert", entity.GetString("CveId"), entity.ContainsKey("DetectedAt"), default, mode)));
|
||||
Table.Setup(t => t.DeleteEntityAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, ETag, CancellationToken>((partitionKey, rowKey, _, _) =>
|
||||
{
|
||||
Deletes.Enqueue((partitionKey, rowKey));
|
||||
return Answer(Fail("delete", partitionKey.Substring(Tenant.Length + 1)));
|
||||
});
|
||||
Table.Setup(t => t.QueryAsync<TableEntity>(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((string? filter, int? _, IEnumerable<string> select, CancellationToken _) =>
|
||||
{
|
||||
Queries.Add((filter, select.ToList()));
|
||||
return AsyncPageable<TableEntity>.FromPages(new[] { Page<TableEntity>.FromValues(Rows, null, Mock.Of<Response>()) });
|
||||
});
|
||||
|
||||
Service.Setup(s => s.GetTableClient(Constants.TableNames.CveIndex)).Returns(Table.Object);
|
||||
Sut = new TableStorageService(Service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
private Task<Response> Record(Write write)
|
||||
{
|
||||
Writes.Enqueue(write);
|
||||
return Answer(Fail(write.Call, write.CveId));
|
||||
}
|
||||
|
||||
private static Task<Response> Answer(int? failure) => failure.HasValue
|
||||
? Task.FromException<Response>(new RequestFailedException(failure.Value, "synthetic failure"))
|
||||
: Task.FromResult(Mock.Of<Response>());
|
||||
|
||||
public List<Write> WritesInOrder() => Writes.OrderBy(w => w.CveId, StringComparer.Ordinal).ThenBy(w => w.Call, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Which CVEs of a report get a row
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildCveIndexEntities_TakesTheFirstFiveOfAnEntry_AndEveryKnownExploitedOneBeyond()
|
||||
{
|
||||
var findings = new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("Contoso Widget",
|
||||
Vuln("CVE-01"), Vuln("CVE-02"), Vuln("CVE-03"), Vuln("CVE-04"), Vuln("CVE-05"),
|
||||
Vuln("CVE-06"), Vuln("CVE-07", kev: true), Vuln("CVE-08"), Vuln("CVE-09", kev: true)),
|
||||
// The count runs per entry: this one lists CVE-08 among its first five.
|
||||
Finding("Fabrikam Gadget",
|
||||
Vuln("CVE-11"), Vuln("CVE-08"), Vuln("CVE-13"), Vuln("CVE-14"), Vuln("CVE-15"), Vuln("CVE-16")),
|
||||
};
|
||||
|
||||
var indexed = TableStorageService.BuildCveIndexEntities(Tenant, Session, findings).Select(e => e.GetString("CveId"));
|
||||
var all = TableStorageService.BuildCveIndexEntities(Tenant, Session, findings, mostUrgentOnly: false).Select(e => e.GetString("CveId"));
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "CVE-01", "CVE-02", "CVE-03", "CVE-04", "CVE-05", "CVE-07", "CVE-08", "CVE-09", "CVE-11", "CVE-13", "CVE-14", "CVE-15" },
|
||||
indexed.OrderBy(id => id, StringComparer.Ordinal));
|
||||
Assert.Equal(14, all.Count());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ReplaceCveIndexEntriesAsync
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_KeptRowIsMergedWithoutItsDate_NewRowIsWrittenInFull_DroppedRowIsDeleted()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(
|
||||
Tenant, Session, Findings("CVE-KEPT", "CVE-NEW"), previousFindings: Findings("CVE-KEPT", "CVE-GONE"));
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
// A merge that does not name DetectedAt leaves the stored date alone.
|
||||
new Write("update", "CVE-KEPT", HasDetectedAt: false, ETag.All, TableUpdateMode.Merge),
|
||||
new Write("upsert", "CVE-NEW", HasDetectedAt: true, default, TableUpdateMode.Replace),
|
||||
}, harness.WritesInOrder());
|
||||
Assert.Equal(new[] { ($"{Tenant}_CVE-GONE", Session) }, harness.Deletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_KeptRowThatNeverLanded_IsWrittenInFullWithADate()
|
||||
{
|
||||
var harness = new Harness { Fail = (call, _) => call == "update" ? 404 : null };
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(Tenant, Session, Findings("CVE-KEPT"), previousFindings: Findings("CVE-KEPT"));
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
new Write("update", "CVE-KEPT", HasDetectedAt: false, ETag.All, TableUpdateMode.Merge),
|
||||
new Write("upsert", "CVE-KEPT", HasDetectedAt: true, default, TableUpdateMode.Replace),
|
||||
}, harness.WritesInOrder());
|
||||
Assert.Empty(harness.Deletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_NoPreviousReport_WritesEveryRowInFull()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(Tenant, Session, Findings("CVE-A", "CVE-B"), previousFindings: null);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
new Write("upsert", "CVE-A", HasDetectedAt: true, default, TableUpdateMode.Replace),
|
||||
new Write("upsert", "CVE-B", HasDetectedAt: true, default, TableUpdateMode.Replace),
|
||||
}, harness.WritesInOrder());
|
||||
Assert.Empty(harness.Deletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_ReportRemoved_DeletesEveryRowItHad()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(Tenant, Session, findings: null, previousFindings: Findings("CVE-A", "CVE-B"));
|
||||
|
||||
Assert.Empty(harness.Writes);
|
||||
Assert.Equal(
|
||||
new[] { ($"{Tenant}_CVE-A", Session), ($"{Tenant}_CVE-B", Session) },
|
||||
harness.Deletes.OrderBy(d => d.PartitionKey, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A report stored before the index was limited has a row for every CVE it lists. All of its
|
||||
/// CVEs count as existing rows, so the ones the new selection leaves out are removed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Replace_PreviousReportIsReadInFull_SoRowsBeyondTheSelectionAreRemoved()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var sevenCves = new[] { "CVE-1", "CVE-2", "CVE-3", "CVE-4", "CVE-5", "CVE-6", "CVE-7" };
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(Tenant, Session, Findings(sevenCves), previousFindings: Findings(sevenCves));
|
||||
|
||||
Assert.Equal(new[] { "CVE-1", "CVE-2", "CVE-3", "CVE-4", "CVE-5" }, harness.WritesInOrder().Select(w => w.CveId));
|
||||
Assert.All(harness.Writes, w => Assert.Equal("update", w.Call));
|
||||
Assert.Equal(
|
||||
new[] { $"{Tenant}_CVE-6", $"{Tenant}_CVE-7" },
|
||||
harness.Deletes.Select(d => d.PartitionKey).OrderBy(pk => pk, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_PreviousFindingsAsReadBackFromStorage_StillNameTheRows()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var stored = ReportFixture.AsStored(new Dictionary<string, object> { ["findings"] = Findings("CVE-KEPT", "CVE-GONE") });
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(
|
||||
Tenant, Session, Findings("CVE-KEPT"), VulnerabilityCorrelationService.FindingsOf(stored));
|
||||
|
||||
Assert.Equal("update", Assert.Single(harness.Writes).Call);
|
||||
Assert.Equal(new[] { ($"{Tenant}_CVE-GONE", Session) }, harness.Deletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_NothingToWriteOrRemove_DoesNotTouchTheTable()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(Tenant, Session, null, null);
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(
|
||||
Tenant, Session, new List<Dictionary<string, object>>(), new List<Dictionary<string, object>>());
|
||||
|
||||
harness.Service.Verify(s => s.GetTableClient(It.IsAny<string>()), Times.Never);
|
||||
harness.Table.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("delete", 404)] // already gone
|
||||
[InlineData("delete", 503)]
|
||||
[InlineData("upsert", 503)]
|
||||
[InlineData("update", 503)]
|
||||
public async Task Replace_FailingWrites_NeverThrow_AndTheOtherRowsAreStillWritten(string failingCall, int status)
|
||||
{
|
||||
var harness = new Harness { Fail = (call, _) => call == failingCall ? status : null };
|
||||
|
||||
await harness.Sut.ReplaceCveIndexEntriesAsync(
|
||||
Tenant, Session, Findings("CVE-KEPT", "CVE-NEW"), previousFindings: Findings("CVE-KEPT", "CVE-GONE"));
|
||||
|
||||
Assert.Contains(harness.Writes, w => w.Call == "update" && w.CveId == "CVE-KEPT");
|
||||
Assert.Contains(harness.Writes, w => w.Call == "upsert" && w.CveId == "CVE-NEW");
|
||||
Assert.Equal(new[] { ($"{Tenant}_CVE-GONE", Session) }, harness.Deletes);
|
||||
// Only a 404 means "the row is not there"; any other failed update is not retried as an insert.
|
||||
Assert.DoesNotContain(harness.Writes, w => w.Call == "upsert" && w.CveId == "CVE-KEPT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_TableUnreachable_DoesNotThrow()
|
||||
{
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(It.IsAny<string>())).Throws(new RequestFailedException(503, "busy"));
|
||||
var sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
|
||||
await sut.ReplaceCveIndexEntriesAsync(Tenant, Session, Findings("CVE-A"), previousFindings: null);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ScanCveIndexAsync
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_WithAWindow_FiltersByDetectionDateInTheQuery()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var detectedAt = new DateTimeOffset(2026, 11, 10, 9, 0, 0, TimeSpan.Zero);
|
||||
harness.Rows.Add(new TableEntity($"{Tenant}_CVE-2026-0001", Session)
|
||||
{
|
||||
["TenantId"] = Tenant, ["SessionId"] = Session, ["CveId"] = "CVE-2026-0001", ["DetectedAt"] = detectedAt,
|
||||
});
|
||||
var since = new DateTime(2026, 11, 5, 8, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
var (rows, truncated) = await harness.Sut.ScanCveIndexAsync(Tenant, maxRows: 100, detectedSinceUtc: since);
|
||||
await harness.Sut.ScanCveIndexAsync(null, maxRows: 100, detectedSinceUtc: since);
|
||||
await harness.Sut.ScanCveIndexAsync(Tenant, maxRows: 100);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
$"(PartitionKey ge '{Tenant}_' and PartitionKey lt '{Tenant}_~') and DetectedAt ge datetime'2026-11-05T08:30:00Z'",
|
||||
"DetectedAt ge datetime'2026-11-05T08:30:00Z'",
|
||||
$"PartitionKey ge '{Tenant}_' and PartitionKey lt '{Tenant}_~'",
|
||||
}, harness.Queries.Select(q => q.Filter));
|
||||
Assert.All(harness.Queries, q => Assert.Contains("DetectedAt", q.Select));
|
||||
|
||||
Assert.False(truncated);
|
||||
Assert.Equal(detectedAt.UtcDateTime, Assert.Single(rows).DetectedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CveVersionFilter"/> judges an installed version only by ranges that belong to the
|
||||
/// product itself, fit the software edition its mapping asks for and count versions the way the
|
||||
/// installed one does. Each rule here removes a class of CVEs a product was reported for without
|
||||
/// having them. The platform and plain range rules are in <c>VulnerabilityCorrelationServiceTests</c>.
|
||||
/// </summary>
|
||||
public class CveVersionFilterTests
|
||||
{
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string FirefoxCpe = "cpe:2.3:a:mozilla:firefox";
|
||||
|
||||
private static CachedVersionRange Below(string criteria, string? fixedIn = null) =>
|
||||
new() { Criteria = criteria, VersionEndExcluding = fixedIn };
|
||||
|
||||
private static CachedCve Cve(string id, params CachedVersionRange[] ranges) => new()
|
||||
{
|
||||
CveId = id,
|
||||
CvssScore = 8.8,
|
||||
CvssSeverity = "HIGH",
|
||||
Description = "Synthetic description.",
|
||||
PublishedDate = "2026-06-01T00:00:00.000",
|
||||
AffectedVersions = ranges.ToList(),
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ranges of another product (rows written before the projection dropped them)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RangeOfAnotherProductInTheSameCve_DoesNotJudgeTheInstalledVersion()
|
||||
{
|
||||
var cve = Cve("CVE-2024-3661",
|
||||
Below("cpe:2.3:a:contoso:widget:-:*:*:*:*:*:*:*"),
|
||||
Below("cpe:2.3:a:fabrikam:secure_client:*:*:*:*:*:*:*:*", "24.06.1"));
|
||||
|
||||
var result = CveVersionFilter.Filter(new[] { cve }, WidgetCpe, "3.2.0");
|
||||
|
||||
Assert.Empty(result.Vulnerabilities);
|
||||
Assert.Equal(0, result.IncomparableCves);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:adobe:acrobat_reader_dc:*:*:*:*:*:*:*:*")] // shares the prefix, is another product
|
||||
[InlineData("cpe:2.3:a:fabrikam:acrobat:*:*:*:*:*:*:*:*")] // same product name, another vendor
|
||||
[InlineData("cpe:2.3:o:microsoft:windows:*:*:*:*:*:*:*:*")]
|
||||
public void ProductIsComparedByVendorAndProduct_NotByPrefix(string foreignCriteria)
|
||||
{
|
||||
var cve = Cve("CVE-2025-0101", Below(foreignCriteria, "99.0"));
|
||||
|
||||
Assert.Empty(CveVersionFilter.Filter(new[] { cve }, "cpe:2.3:a:adobe:acrobat", "24.0").Vulnerabilities);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Software editions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Shaped like the public CVE-2023-4863 for Firefox: one fix for the release channel, one for ESR.</summary>
|
||||
private static readonly CachedCve FirefoxAndItsEsr = Cve("CVE-2023-4863",
|
||||
Below("cpe:2.3:a:mozilla:firefox:*:*:*:*:-:*:*:*", "117.0.1"),
|
||||
Below("cpe:2.3:a:mozilla:firefox:*:*:*:*:esr:*:*:*", "115.2.1"));
|
||||
|
||||
[Theory]
|
||||
// Firefox, mapped with "never the ESR ranges"
|
||||
[InlineData("116.0", null, true, true)]
|
||||
[InlineData("117.0.1", null, true, false)]
|
||||
// Firefox ESR, mapped with "only the ESR ranges"
|
||||
[InlineData("115.2.0", "esr", false, true)]
|
||||
[InlineData("115.3.0", "esr", false, false)]
|
||||
// Without an edition on the mapping the fixed ESR build is judged by the release-channel
|
||||
// range: the false positive the edition setting removes.
|
||||
[InlineData("115.3.0", null, false, true)]
|
||||
public void Edition_DecidesWhichRangesCount(string installed, string? edition, bool excludeEsr, bool affected)
|
||||
{
|
||||
var result = CveVersionFilter.Filter(
|
||||
new[] { FirefoxAndItsEsr }, FirefoxCpe, installed, edition,
|
||||
excludeEsr ? new[] { "esr" } : null);
|
||||
|
||||
Assert.Equal(affected, result.Vulnerabilities.Count == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutEditionOnTheMapping_EveryEditionCounts()
|
||||
{
|
||||
const string readerCpe = "cpe:2.3:a:adobe:acrobat_reader_dc";
|
||||
var cve = Cve("CVE-2025-0102", Below("cpe:2.3:a:adobe:acrobat_reader_dc:*:*:*:*:continuous:*:*:*", "24.002.20759"));
|
||||
|
||||
Assert.Single(CveVersionFilter.Filter(new[] { cve }, readerCpe, "23.008.20470").Vulnerabilities);
|
||||
Assert.Empty(CveVersionFilter.Filter(new[] { cve }, readerCpe, "23.008.20470", edition: "classic").Vulnerabilities);
|
||||
Assert.Empty(CveVersionFilter.Filter(new[] { cve }, readerCpe, "23.008.20470", excludeEditions: new[] { "continuous" }).Vulnerabilities);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Version scheme
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "2022.30050")] // a range that counts in years
|
||||
[InlineData("cpe:2.3:a:contoso:widget:2022.30050:*:*:*:*:*:*:*", null)] // one exact version in years
|
||||
public void RangeInAnotherVersionScheme_IsNotJudgedButCounted(string criteria, string? fixedIn)
|
||||
{
|
||||
var cve = Cve("CVE-2025-0103", Below(criteria, fixedIn));
|
||||
|
||||
var result = CveVersionFilter.Filter(new[] { cve }, WidgetCpe, "26.198");
|
||||
|
||||
// 26.198 is "below" every year-based version, so comparing would report each such CVE.
|
||||
Assert.Empty(result.Vulnerabilities);
|
||||
Assert.Equal(1, result.IncomparableCves);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void CveMatchingThroughAComparableRange_IsReportedAndNotCountedAsIncomparable(bool comparableRangeFirst)
|
||||
{
|
||||
var inYears = Below("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "2022.30050");
|
||||
var inReleases = Below("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "27.0");
|
||||
var cve = comparableRangeFirst ? Cve("CVE-2025-0104", inReleases, inYears) : Cve("CVE-2025-0104", inYears, inReleases);
|
||||
|
||||
var result = CveVersionFilter.Filter(new[] { cve }, WidgetCpe, "26.198");
|
||||
|
||||
Assert.Equal("CVE-2025-0104", Assert.Single(result.Vulnerabilities)["cveId"]);
|
||||
Assert.Equal(0, result.IncomparableCves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncomparableCves_CountsCvesNotRanges()
|
||||
{
|
||||
var twoRanges = Cve("CVE-2025-0105",
|
||||
Below("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "2022.30050"),
|
||||
Below("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "2023.10000"));
|
||||
var fixedLongAgo = Cve("CVE-2025-0106", Below("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "20.0"));
|
||||
|
||||
var result = CveVersionFilter.Filter(new[] { twoRanges, fixedLongAgo }, WidgetCpe, "26.198");
|
||||
|
||||
Assert.Empty(result.Vulnerabilities);
|
||||
Assert.Equal(1, result.IncomparableCves);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Platform named in the description
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("The flaw can be triggered in certain scenarios when a crafted file is opened.", false)] // "ios" inside "scenarios"
|
||||
[InlineData("A local attacker can gain elevated permissions in the agent on Linux and macOS.", true)]
|
||||
[InlineData("A local attacker can gain elevated permissions in the agent on Linux and Windows.", false)]
|
||||
[InlineData("FortiClientLinux may allow an attacker to execute code via a crafted website.", false)] // a product name, not the platform word
|
||||
public void IsDescriptionNonWindowsOnly_MatchesWholeWords(string description, bool nonWindowsOnly)
|
||||
{
|
||||
Assert.Equal(nonWindowsOnly, CveVersionFilter.IsDescriptionNonWindowsOnly(description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescriptionThatOnlyContainsAPlatformNameInsideAWord_KeepsTheCve()
|
||||
{
|
||||
var cve = Cve("CVE-2025-0107", Below("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*", "4.0"));
|
||||
cve.Description = "Memory corruption in certain scenarios allows code execution.";
|
||||
|
||||
Assert.Single(CveVersionFilter.Filter(new[] { cve }, WidgetCpe, "3.2.0").Vulnerabilities);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// DELETE vulnerability/cpe-mapping removes an operator's mapping or an automatic one and brings
|
||||
/// the inventory rows in line in the same call. An automatic mapping that is removed also puts
|
||||
/// its program on the ignore list, or the next nightly pass would map it again; the seed and the
|
||||
/// community list are not deleted from here.
|
||||
/// </summary>
|
||||
public class DeleteCustomCpeMappingFunctionTests
|
||||
{
|
||||
private const string Seed = CpeMappingStore.SeedPartition;
|
||||
private const string Custom = CpeMappingStore.CustomPartition;
|
||||
private const string Auto = CpeMappingStore.AutoPartition;
|
||||
private const string Ignored = CpeMappingStore.IgnoredPartition;
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string GadgetCpe = "cpe:2.3:a:fabrikam:gadget";
|
||||
private const string ToolCpe = "cpe:2.3:a:northwind:tool";
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
private Rig(MappingRig maps)
|
||||
{
|
||||
Maps = maps;
|
||||
Function = new DeleteCustomCpeMappingFunction(
|
||||
maps.Scan.Repo.Object, maps.Reconciler, NullLogger<DeleteCustomCpeMappingFunction>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>The gadget is mapped by the seed, the widget by an operator, the tool automatically.</summary>
|
||||
public static async Task<Rig> CreateAsync() => new(await MappingRig.CreateAsync(
|
||||
VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", GadgetCpe, "fabrikam gadget"),
|
||||
VulnerabilityCacheRows.MappingIn(Custom, "manual", "contoso", "contoso widget", WidgetCpe),
|
||||
VulnerabilityCacheRows.MappingIn(Auto, "auto", "northwind", "northwind tool", ToolCpe)));
|
||||
|
||||
public MappingRig Maps { get; }
|
||||
public MappingTables Tables => Maps.Tables;
|
||||
public DeleteCustomCpeMappingFunction Function { get; }
|
||||
|
||||
public Task<HttpResponseData> DeleteAsync(DeleteCustomCpeMappingRequest request) => Function.Run(OperatorCall.Request(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AutomaticMapping_RemovesIt_PutsTheProgramOnTheIgnoreList_AndTakesTheCpeOffItsRows()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var installed = rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", cpeUri: ToolCpe);
|
||||
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "northwind:northwind tool", Source = "auto" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.True(body.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("ignore list", body.GetProperty("message").GetString());
|
||||
|
||||
Assert.Empty(rig.Tables.Rows(Auto));
|
||||
// Filed like an entry the operator adds by hand, so the ignore list shows and removes it the same way.
|
||||
var entry = Assert.Single(rig.Tables.Rows(Ignored));
|
||||
Assert.Equal(CpeIgnoreEntry.Build("northwind", "Northwind Tool 3.1", "manual")!.Value.RowKey, entry["RowKey"]);
|
||||
Assert.Equal("northwind", entry["NormalizedVendor"]);
|
||||
Assert.Equal("northwind tool", entry["NormalizedProduct"]);
|
||||
Assert.Equal("automatic mapping removed", entry["Reason"]);
|
||||
// The ignore entry first: a failure between the two writes must leave the mapping in
|
||||
// place, not a program the next night maps again.
|
||||
Assert.Equal(new[] { $"upsert {Ignored}/northwind:northwind tool", $"delete {Auto}/northwind:northwind tool" }, rig.Tables.Writes);
|
||||
|
||||
Assert.Equal(string.Empty, installed["CpeUri"]);
|
||||
var mappings = await rig.Maps.Store.GetAsync();
|
||||
Assert.Null(mappings.Matcher.Match("northwind", "northwind tool 4.0", "Northwind Tool 4.0", "Northwind Traders"));
|
||||
Assert.True(mappings.IsIgnored("northwind", "Northwind Traders", "Northwind Tool 4.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AutomaticMapping_LeavesAnIgnoreEntryTheIgnoreListItselfCanRemove()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var ignoreList = new IgnoreSoftwareFunction(
|
||||
rig.Maps.Scan.Repo.Object, rig.Maps.Store, NullLogger<IgnoreSoftwareFunction>.Instance);
|
||||
await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "northwind:northwind tool", Source = "auto" });
|
||||
|
||||
var listed = Assert.Single(OperatorCall.BodyOf(await ignoreList.GetIgnored(OperatorCall.Request())).GetProperty("items").EnumerateArray());
|
||||
// The list's delete sends back what the list shows.
|
||||
var removed = await ignoreList.DeleteIgnored(OperatorCall.Request(new IgnoreSoftwareDeleteRequest
|
||||
{
|
||||
SoftwareName = listed.GetProperty("softwareName").GetString()!,
|
||||
Publisher = listed.GetProperty("publisher").GetString(),
|
||||
}));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, removed.StatusCode);
|
||||
Assert.Empty(rig.Tables.Rows(Ignored));
|
||||
Assert.False((await rig.Maps.Store.GetAsync()).IsIgnored("northwind", "Northwind Traders", "Northwind Tool 4.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_OperatorsMapping_ByVendorAndProgramName_RemovesItWithoutIgnoringTheProgram()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var installed = rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2", cpeUri: WidgetCpe);
|
||||
|
||||
// The name as the program shows it; the source defaults to the operator's own mappings.
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest
|
||||
{
|
||||
NormalizedVendor = "Contoso",
|
||||
NormalizedProduct = "Contoso Widget 4.2 (x64)",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("CPE mapping deleted", OperatorCall.BodyOf(response).GetProperty("message").GetString());
|
||||
Assert.Empty(rig.Tables.Rows(Custom));
|
||||
Assert.Empty(rig.Tables.Rows(Ignored));
|
||||
Assert.Equal(new[] { $"delete {Custom}/contoso:contoso widget" }, rig.Tables.Writes);
|
||||
Assert.Equal(string.Empty, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ByRowKey_ReachesARowWhateverItIsKeyedBy()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
// Not the key today's save would give this program.
|
||||
const string rowKey = ":contoso reader 2.0 (x64)";
|
||||
rig.Tables.Put(Custom, rowKey, new Dictionary<string, object>
|
||||
{
|
||||
["NormalizedVendor"] = string.Empty,
|
||||
["NormalizedProduct"] = "Contoso Reader 2.0 (x64)",
|
||||
["CpeUri"] = "cpe:2.3:a:contoso:reader",
|
||||
["Source"] = "manual",
|
||||
});
|
||||
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = rowKey, Source = "Custom" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "contoso:contoso widget" }, rig.Tables.RowKeys(Custom));
|
||||
Assert.Empty(rig.Tables.Rows(Ignored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AutomaticMappingKeptAmongTheOperatorsMappings_IsDeletedByWhatTheListShowsForIt()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
// The retired name search wrote its mappings next to the operator's own; only the Source
|
||||
// column tells them apart, and the list shows them as automatic.
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(Custom, "auto", "adatum", "adatum reader", "cpe:2.3:a:adatum:reader"));
|
||||
var list = new GetCpeMappingsFunction(rig.Maps.Store, NullLogger<GetCpeMappingsFunction>.Instance);
|
||||
var listed = OperatorCall.BodyOf(await list.Run(OperatorCall.Request())).GetProperty("mappings").EnumerateArray()
|
||||
.Single(m => m.GetProperty("normalizedProduct").GetString() == "adatum reader");
|
||||
Assert.Equal("auto", listed.GetProperty("source").GetString());
|
||||
|
||||
// The portal names a mapping by the row key and the source the list gave it.
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest
|
||||
{
|
||||
RowKey = listed.GetProperty("rowKey").GetString(),
|
||||
Source = listed.GetProperty("source").GetString(),
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "contoso:contoso widget" }, rig.Tables.RowKeys(Custom));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("seed")]
|
||||
[InlineData("community")]
|
||||
[InlineData("manual")]
|
||||
public async Task Delete_SourceOtherThanTheOperatorsOrAutomatic_IsABadRequest(string source)
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "fabrikam:fabrikam gadget", Source = source });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.BadRequest, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Single(rig.Tables.Rows(Seed));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("contoso:contoso gadget", null)]
|
||||
// The row exists, but among the operator's mappings, not the automatic ones.
|
||||
[InlineData("contoso:contoso widget", "auto")]
|
||||
[InlineData("northwind:northwind tool", "custom")]
|
||||
public async Task Delete_RowThatIsNotThere_IsNotFound(string rowKey, string? source)
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = rowKey, Source = source });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.NotFound, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{ "normalizedVendor": "contoso" }""")]
|
||||
[InlineData("""{ "rowKey": " ", "normalizedProduct": " ", "source": "custom" }""")]
|
||||
public async Task Delete_WithoutRowKeyOrProgramName_IsABadRequest(string json)
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.RequestWithJson(json));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AutomaticMappingKeptAmongTheOperatorsMappings_LeavesTheIgnoreListAlone()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(Custom, "auto", "adatum", "adatum reader", "cpe:2.3:a:adatum:reader"));
|
||||
var installed = rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0", cpeUri: "cpe:2.3:a:adatum:reader");
|
||||
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "adatum:adatum reader", Source = "auto" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
// The name search that wrote this row is retired, so nothing would write it again.
|
||||
Assert.Equal("CPE mapping deleted", OperatorCall.BodyOf(response).GetProperty("message").GetString());
|
||||
Assert.Empty(rig.Tables.Rows(Ignored));
|
||||
Assert.Equal(new[] { $"delete {Custom}/adatum:adatum reader" }, rig.Tables.Writes);
|
||||
Assert.Equal(string.Empty, installed["CpeUri"]);
|
||||
Assert.False((await rig.Maps.Store.GetAsync()).IsIgnored("adatum", "Adatum Corporation", "Adatum Reader 2.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AutomaticMappingKeptAmongTheOperatorsMappings_IsNotReachedAsOneOfTheOperatorsOwn()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(Custom, "auto", "adatum", "adatum reader", "cpe:2.3:a:adatum:reader"));
|
||||
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "adatum:adatum reader", Source = "custom" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Delete_WithoutASource_MeansTheOperatorsOwnMappings(string? source)
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
|
||||
var own = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "contoso:contoso widget", Source = source });
|
||||
var automatic = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "northwind:northwind tool", Source = source });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, own.StatusCode);
|
||||
Assert.Empty(rig.Tables.Rows(Custom));
|
||||
// An automatic mapping is deleted only by a request that names it as one.
|
||||
Assert.Equal(HttpStatusCode.NotFound, automatic.StatusCode);
|
||||
Assert.Single(rig.Tables.Rows(Auto));
|
||||
Assert.Empty(rig.Tables.Rows(Ignored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AutomaticMapping_WhoseIgnoreEntryCannotBeWritten_StaysInPlace()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var installed = rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", cpeUri: ToolCpe);
|
||||
rig.Tables.WriteFailure = write =>
|
||||
write.StartsWith($"upsert {Ignored}/", StringComparison.Ordinal) ? new InvalidOperationException("storage down") : null;
|
||||
|
||||
var response = await rig.DeleteAsync(new DeleteCustomCpeMappingRequest { RowKey = "northwind:northwind tool", Source = "auto" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
// Deleted without the entry, the mapping would be back after the next nightly pass.
|
||||
Assert.Single(rig.Tables.Rows(Auto));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Equal(ToolCpe, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_AutomaticMapping_ThatCannotBeDeletedAfterItsIgnoreEntry_CanBeDeletedAgain()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Tables.WriteFailure = write =>
|
||||
write.StartsWith("delete ", StringComparison.Ordinal) ? new InvalidOperationException("storage down") : null;
|
||||
var request = new DeleteCustomCpeMappingRequest { RowKey = "northwind:northwind tool", Source = "auto" };
|
||||
|
||||
var first = await rig.DeleteAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, first.StatusCode);
|
||||
Assert.Single(rig.Tables.Rows(Auto));
|
||||
Assert.Single(rig.Tables.Rows(Ignored));
|
||||
|
||||
rig.Tables.WriteFailure = null;
|
||||
var second = await rig.DeleteAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
Assert.Empty(rig.Tables.Rows(Auto));
|
||||
Assert.Single(rig.Tables.Rows(Ignored));
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ public class DerivedWriterLivenessTests
|
||||
Mock.Of<ISessionRepository>(),
|
||||
inventoryLoader.Object,
|
||||
Mock.Of<IAnalyzeOnEnrollmentEndProducer>(),
|
||||
Mock.Of<IVulnerabilityCorrelateProducer>(),
|
||||
reportWriter: null!,
|
||||
signalRNotification: null!,
|
||||
GuardFor(row),
|
||||
NullLogger<VulnerabilityCorrelateHandler>.Instance);
|
||||
|
||||
@@ -154,6 +154,12 @@ public class EpssAndNvdThrottleTests
|
||||
var failed = NvdFetchResult<NvdCveResponse>.Failed();
|
||||
Assert.False(failed.Succeeded);
|
||||
Assert.False(failed.Throttled);
|
||||
Assert.False(failed.Unanswered);
|
||||
|
||||
var unanswered = NvdFetchResult<NvdCveResponse>.UnansweredResult();
|
||||
Assert.False(unanswered.Succeeded);
|
||||
Assert.False(unanswered.Throttled);
|
||||
Assert.True(unanswered.Unanswered);
|
||||
|
||||
var ok = NvdFetchResult<NvdCveResponse>.Ok(new NvdCveResponse { TotalResults = 1 });
|
||||
Assert.True(ok.Succeeded);
|
||||
@@ -209,27 +215,22 @@ public class EpssAndNvdThrottleTests
|
||||
}
|
||||
};
|
||||
|
||||
var cves = VulnerabilityCorrelationService.ProjectNvdResponse(response);
|
||||
var cves = CveCacheFiller.Project(response, "cpe:2.3:a:v:p");
|
||||
|
||||
Assert.Equal(2, cves.Count);
|
||||
var first = cves[0];
|
||||
// The second CVE names no range of the product and is not kept.
|
||||
var first = Assert.Single(cves);
|
||||
Assert.Equal(9.8, first.CvssScore);
|
||||
Assert.Equal("CRITICAL", first.CvssSeverity);
|
||||
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", first.CvssVector);
|
||||
Assert.Single(first.AffectedVersions);
|
||||
Assert.Equal("2.0", first.AffectedVersions[0].VersionEndExcluding);
|
||||
Assert.Null(first.EpssScore); // never defaulted before enrichment
|
||||
|
||||
var second = cves[1];
|
||||
Assert.Equal(0, second.CvssScore);
|
||||
Assert.Equal("UNKNOWN", second.CvssSeverity);
|
||||
Assert.Null(second.CvssVector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectNvdResponse_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(VulnerabilityCorrelationService.ProjectNvdResponse(null));
|
||||
Assert.Empty(CveCacheFiller.Project(null, "cpe:2.3:a:v:p"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -252,7 +253,7 @@ public class EpssAndNvdThrottleTests
|
||||
// CVE-2 absent: FIRST did not score it (or its batch failed) ⇒ stays null
|
||||
};
|
||||
|
||||
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
|
||||
var changed = CveCacheFiller.ApplyEpssScores(cves, scores);
|
||||
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal(0.25, cves[0].EpssScore);
|
||||
@@ -265,7 +266,7 @@ public class EpssAndNvdThrottleTests
|
||||
public void ApplyEpssScores_NeverErasesYesterdaysScore_WhenTodayHasNone()
|
||||
{
|
||||
var cves = new List<CachedCve> { new() { CveId = "CVE-1", EpssScore = 0.3, EpssPercentile = 0.7, EpssDate = "2026-08-27" } };
|
||||
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, new Dictionary<string, EpssScore>());
|
||||
var changed = CveCacheFiller.ApplyEpssScores(cves, new Dictionary<string, EpssScore>());
|
||||
Assert.Equal(0, changed);
|
||||
Assert.Equal(0.3, cves[0].EpssScore);
|
||||
}
|
||||
@@ -274,7 +275,7 @@ public class EpssAndNvdThrottleTests
|
||||
public void BuildCveCacheRow_PassesCachedAtThrough_SoEpssRewritesDoNotLookLikeNvdFetches()
|
||||
{
|
||||
var cachedAt = new DateTime(2026, 8, 20, 3, 0, 0, DateTimeKind.Utc);
|
||||
var row = VulnerabilityCorrelationService.BuildCveCacheRow("cpe:2.3:a:v:p", new List<CachedCve> { new() { CveId = "CVE-1" } }, cachedAt);
|
||||
var row = CveCacheFiller.BuildRow("cpe:2.3:a:v:p", new List<CachedCve> { new() { CveId = "CVE-1" } }, cachedAt, nvdTotal: 7);
|
||||
|
||||
Assert.Equal(cachedAt.ToString("o"), row["CachedAt"]);
|
||||
Assert.Equal("cpe:2.3:a:v:p", row["CpeUri"]);
|
||||
@@ -285,13 +286,13 @@ public class EpssAndNvdThrottleTests
|
||||
[Fact]
|
||||
public void CachedCve_RoundTrips_WithoutWritingNulls_SoLegacyRowsStayReadable()
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(new List<CachedCve> { new() { CveId = "CVE-1", CvssScore = 7 } }, JsonOptions);
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(new List<CachedCve> { new() { CveId = "CVE-1", CvssScore = 7 } }, CveCacheFiller.JsonOptions);
|
||||
Assert.DoesNotContain("EpssScore", json);
|
||||
Assert.DoesNotContain("CvssVector", json);
|
||||
|
||||
// A row written before this change (no EPSS/vector properties at all) still deserialises.
|
||||
var legacy = """[{"CveId":"CVE-9","CvssScore":4.3,"CvssSeverity":"MEDIUM","Description":"","PublishedDate":"","AffectedVersions":[]}]""";
|
||||
var cves = System.Text.Json.JsonSerializer.Deserialize<List<CachedCve>>(legacy, JsonOptions)!;
|
||||
var cves = System.Text.Json.JsonSerializer.Deserialize<List<CachedCve>>(legacy, CveCacheFiller.JsonOptions)!;
|
||||
Assert.Null(cves[0].EpssScore);
|
||||
Assert.Null(cves[0].CvssVector);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// GET vulnerability/cpe-mappings lists every mapping in service with where it comes from, the
|
||||
/// row key a delete names, and whether its product counts as self-updating. It reads fresh, so
|
||||
/// the operator who just changed something sees it.
|
||||
/// </summary>
|
||||
public class GetCpeMappingsFunctionTests
|
||||
{
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
|
||||
private static CpeMapping MappingOf(Dictionary<string, object> row) => CpeMappingStore.MappingFromRow(row)!;
|
||||
|
||||
private static CpeMappingSnapshot Snapshot(CpeMapping mapping, params CpeProductFlags[] flags) =>
|
||||
new(new[] { mapping }, Array.Empty<string>(), flags, DateTime.UtcNow);
|
||||
|
||||
[Theory]
|
||||
[InlineData(CpeMappingStore.SeedPartition, "seed", "seed")]
|
||||
[InlineData(CpeMappingStore.CommunityPartition, "community", "community")]
|
||||
[InlineData(CpeMappingStore.AutoPartition, "auto", "auto")]
|
||||
[InlineData(CpeMappingStore.CustomPartition, "manual", "custom")]
|
||||
// A row the retired name search left among the operator's mappings is shown as what it is.
|
||||
[InlineData(CpeMappingStore.CustomPartition, "auto", "auto")]
|
||||
public void ToItem_NamesWhereAMappingComesFrom(string partition, string sourceColumn, string expected)
|
||||
{
|
||||
var mapping = MappingOf(VulnerabilityCacheRows.MappingIn(partition, sourceColumn, "contoso", "contoso widget", WidgetCpe));
|
||||
|
||||
Assert.Equal(expected, GetCpeMappingsFunction.ToItem(mapping, Snapshot(mapping)).Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToItem_CarriesTheMappingItsRowKeyAndTheProductOfItsCpe()
|
||||
{
|
||||
var row = VulnerabilityCacheRows.MappingIn(
|
||||
CpeMappingStore.CustomPartition, "manual", "Contoso", "Contoso Widget", "cpe:2.3:a:Contoso:Widget:*:*:*:*:*:*:*:*", "contoso widget");
|
||||
row["Category"] = "utility";
|
||||
row["PublisherPatternsJson"] = """["contoso ltd"]""";
|
||||
row["ExcludePatternsJson"] = """["widget viewer"]""";
|
||||
row["CreatedAt"] = "2026-11-12T03:00:00.0000000Z";
|
||||
var mapping = MappingOf(row);
|
||||
|
||||
var item = GetCpeMappingsFunction.ToItem(mapping, Snapshot(mapping));
|
||||
|
||||
Assert.Equal("contoso:contoso widget", item.RowKey);
|
||||
Assert.Equal("Contoso", item.NormalizedVendor);
|
||||
Assert.Equal("Contoso Widget", item.NormalizedProduct);
|
||||
Assert.Equal(WidgetCpe, item.CpeUri);
|
||||
Assert.Equal("contoso", item.CpeVendor);
|
||||
Assert.Equal("widget", item.CpeProduct);
|
||||
Assert.Equal("utility", item.Category);
|
||||
Assert.Equal(new[] { "contoso widget" }, item.DisplayNamePatterns);
|
||||
Assert.Equal(new[] { "contoso ltd" }, item.PublisherPatterns);
|
||||
Assert.Equal(new[] { "widget viewer" }, item.ExcludePatterns);
|
||||
Assert.Equal("2026-11-12T03:00:00.0000000Z", item.CreatedAt);
|
||||
Assert.False(item.SelfUpdating);
|
||||
Assert.False(item.SelfUpdatingDefault);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, null, true, true)] // the default alone
|
||||
[InlineData(true, false, false, true)] // an operator switched the default off
|
||||
[InlineData(null, true, true, false)] // an operator flagged a product the seed does not list
|
||||
public void ToItem_SelfUpdating_IsTheValueInEffect_NextToTheSeedsDefault(
|
||||
bool? seed, bool? admin, bool expectedSelfUpdating, bool expectedDefault)
|
||||
{
|
||||
var mapping = MappingOf(VulnerabilityCacheRows.Mapping("contoso", "contoso widget", WidgetCpe));
|
||||
var flags = new CpeProductFlags { Product = "contoso:widget", SeedSelfUpdating = seed, AdminSelfUpdating = admin };
|
||||
var ofAnotherProduct = new CpeProductFlags { Product = "contoso:gadget", SeedSelfUpdating = !expectedDefault };
|
||||
|
||||
var item = GetCpeMappingsFunction.ToItem(mapping, Snapshot(mapping, ofAnotherProduct, flags));
|
||||
|
||||
Assert.Equal(expectedSelfUpdating, item.SelfUpdating);
|
||||
Assert.Equal(expectedDefault, item.SelfUpdatingDefault);
|
||||
}
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
Function = new GetCpeMappingsFunction(
|
||||
new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance), NullLogger<GetCpeMappingsFunction>.Instance);
|
||||
}
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
public GetCpeMappingsFunction Function { get; }
|
||||
|
||||
public async Task<List<(string Source, string Product)>> ListAsync()
|
||||
{
|
||||
var response = await Function.Run(OperatorCall.Request());
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
var listed = body.GetProperty("mappings").EnumerateArray()
|
||||
.Select(m => (m.GetProperty("source").GetString()!, m.GetProperty("normalizedProduct").GetString()!))
|
||||
.ToList();
|
||||
Assert.Equal(listed.Count, body.GetProperty("total").GetInt32());
|
||||
return listed;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ListsTheOperatorsMappingsFirst_ThenAutomaticOnes_ThenTheLists_EachByProgramName()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.Put(VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", "cpe:2.3:a:fabrikam:gadget"));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.Mapping("adatum", "Adatum Reader", "cpe:2.3:a:adatum:reader"));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(CpeMappingStore.CommunityPartition, "community", "northwind", "northwind tool", "cpe:2.3:a:northwind:tool"));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(CpeMappingStore.AutoPartition, "auto", "tailspin", "tailspin launcher", "cpe:2.3:a:tailspin:launcher"));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(CpeMappingStore.CustomPartition, "manual", "contoso", "Contoso Widget", WidgetCpe));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(CpeMappingStore.CustomPartition, "manual", "contoso", "contoso agent", "cpe:2.3:a:contoso:agent"));
|
||||
rig.Tables.Put(VulnerabilityCacheRows.SelfUpdating("contoso:widget"));
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.Request());
|
||||
|
||||
// Within a source the names are in order whatever their case.
|
||||
var mappings = OperatorCall.BodyOf(response).GetProperty("mappings").EnumerateArray().ToList();
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
("custom", "contoso agent"), ("custom", "Contoso Widget"), ("auto", "tailspin launcher"),
|
||||
("community", "northwind tool"), ("seed", "Adatum Reader"), ("seed", "fabrikam gadget"),
|
||||
},
|
||||
mappings.Select(m => (m.GetProperty("source").GetString()!, m.GetProperty("normalizedProduct").GetString()!)));
|
||||
Assert.Equal(6, OperatorCall.BodyOf(response).GetProperty("total").GetInt32());
|
||||
|
||||
var widget = mappings[1];
|
||||
Assert.Equal("contoso:contoso widget", widget.GetProperty("rowKey").GetString());
|
||||
Assert.True(widget.GetProperty("selfUpdating").GetBoolean());
|
||||
Assert.True(widget.GetProperty("selfUpdatingDefault").GetBoolean());
|
||||
Assert.False(mappings[0].GetProperty("selfUpdating").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ReadsTheMappingsFresh_SoAChangeShowsAtOnce()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.Put(VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", "cpe:2.3:a:fabrikam:gadget"));
|
||||
Assert.Equal(new[] { ("seed", "fabrikam gadget") }, await rig.ListAsync());
|
||||
|
||||
// Stored by another instance: nothing told this one's copy to go.
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(CpeMappingStore.CustomPartition, "manual", "contoso", "contoso widget", WidgetCpe));
|
||||
|
||||
Assert.Equal(new[] { ("custom", "contoso widget"), ("seed", "fabrikam gadget") }, await rig.ListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_MappingsUnreadable_IsServiceUnavailable()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.ReadFailure = new InvalidOperationException("storage down");
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.Request());
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ServiceUnavailable, EndpointHarness.ErrorCode(response));
|
||||
Assert.Equal(new[] { "60" }, response.Headers.GetValues("Retry-After"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Graph;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST tenants/{tenantId}/scripts/display-names: a lookup that ran into a time limit says so in
|
||||
/// its answer, so the portal asks once more, and the request row tells a cut first lookup, a
|
||||
/// follow-up and a follow-up that was cut again apart. A first lookup within its limits is the
|
||||
/// normal case and carries neither the flag nor a marker.
|
||||
/// </summary>
|
||||
public class GetScriptDisplayNamesFunctionTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-4000-8000-000000000002";
|
||||
private const string Ref = "Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001";
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, null)]
|
||||
[InlineData(true, false, "timedOut")]
|
||||
[InlineData(false, true, "retry")]
|
||||
[InlineData(true, true, "retryTimedOut")]
|
||||
public async Task A_cut_lookup_is_flagged_in_the_answer_and_marked_on_the_request_row(
|
||||
bool timedOut, bool isFollowUp, string? expectedMarker)
|
||||
{
|
||||
var resolver = new Mock<IScriptDisplayNameResolver>();
|
||||
resolver
|
||||
.Setup(r => r.ResolveAsync(TenantId, It.IsAny<IReadOnlyCollection<ScriptRef>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string _, IReadOnlyCollection<ScriptRef> refs, CancellationToken _) =>
|
||||
new ScriptDisplayNameLookup(refs.ToDictionary(r => r, _ => (string?)null), timedOut));
|
||||
var function = new GetScriptDisplayNamesFunction(NullLogger<GetScriptDisplayNamesFunction>.Instance, resolver.Object);
|
||||
var body = "{\"refs\":[\"" + Ref + "\"]" + (isFollowUp ? ",\"retry\":true" : "") + "}";
|
||||
var (req, context) = EndpointHarness.Request(TenantId, jsonBody: body);
|
||||
|
||||
var response = await function.Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
Assert.True(doc.RootElement.GetProperty("refs").TryGetProperty(Ref, out _));
|
||||
// The key is left out, not false, when no time limit fired.
|
||||
Assert.Equal(timedOut, doc.RootElement.TryGetProperty("timedOut", out var flag));
|
||||
if (timedOut) Assert.True(flag.GetBoolean());
|
||||
|
||||
if (expectedMarker == null)
|
||||
Assert.False(context.Items.ContainsKey(RequestRowMarkers.ScriptNameLookupKey));
|
||||
else
|
||||
Assert.Equal(expectedMarker, context.Items[RequestRowMarkers.ScriptNameLookupKey]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// GET vulnerability/unmatched-software pages through the programs without a mapping, most used
|
||||
/// first, in an order that stays the same from page to page. Every call reads the mappings anew,
|
||||
/// so a program mapped or ignored a moment ago is gone from the list whichever instance took the
|
||||
/// change.
|
||||
/// </summary>
|
||||
public class GetUnmatchedSoftwareFunctionTests
|
||||
{
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", "cpe:2.3:a:fabrikam:gadget", "fabrikam gadget"));
|
||||
Function = new GetUnmatchedSoftwareFunction(
|
||||
Tables.Repo.Object,
|
||||
new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance),
|
||||
NullLogger<GetUnmatchedSoftwareFunction>.Instance);
|
||||
}
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
public GetUnmatchedSoftwareFunction Function { get; }
|
||||
|
||||
public async Task<JsonElement> GetAsync(string query = "")
|
||||
{
|
||||
var response = await Function.Run(EndpointHarness.Request(VulnerabilityScanRig.TenantId, queryString: query).Req);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return OperatorCall.BodyOf(response);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> NamesOf(JsonElement body) =>
|
||||
body.GetProperty("software").EnumerateArray().Select(s => s.GetProperty("softwareName").GetString()!);
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ListsUnmappedProgramsMostUsedFirst_ThenByName_AndPagesThroughThem()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1", sessions: 4);
|
||||
rig.Tables.AddInventory("contoso", "contoso widget", "4.2", sessions: 4);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader", "2.0", sessions: 9);
|
||||
rig.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0", sessions: 1);
|
||||
rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget", "1.0", sessions: 100);
|
||||
|
||||
var all = await rig.GetAsync();
|
||||
var firstPage = await rig.GetAsync("?skip=0&take=3");
|
||||
var secondPage = await rig.GetAsync("?skip=3&take=3");
|
||||
|
||||
// The mapped gadget is not in the list; names used equally often are in order whatever their case.
|
||||
Assert.Equal(new[] { "Adatum Reader", "contoso widget", "Northwind Tool", "Tailspin Launcher" }, NamesOf(all));
|
||||
Assert.Equal(4, all.GetProperty("total").GetInt32());
|
||||
Assert.Equal(0, all.GetProperty("skip").GetInt32());
|
||||
Assert.Equal(50, all.GetProperty("take").GetInt32());
|
||||
|
||||
Assert.Equal(new[] { "Adatum Reader", "contoso widget", "Northwind Tool" }, NamesOf(firstPage));
|
||||
Assert.Equal(new[] { "Tailspin Launcher" }, NamesOf(secondPage));
|
||||
Assert.Equal(4, secondPage.GetProperty("total").GetInt32());
|
||||
Assert.Equal(3, secondPage.GetProperty("skip").GetInt32());
|
||||
|
||||
var reader = all.GetProperty("software")[0];
|
||||
Assert.Equal(9, reader.GetProperty("frequency").GetInt32());
|
||||
Assert.Equal("adatum", reader.GetProperty("normalizedVendor").GetString());
|
||||
Assert.Equal("adatum reader", reader.GetProperty("canonicalName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_MappingsUnreadable_IsServiceUnavailable()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.ReadFailure = new InvalidOperationException("storage down");
|
||||
|
||||
var response = await rig.Function.Run(EndpointHarness.Request(VulnerabilityScanRig.TenantId).Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ServiceUnavailable, EndpointHarness.ErrorCode(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ProgramMappedOrIgnoredOnAnotherInstance_IsGoneFromTheListOnTheNextCall()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1", sessions: 4);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader", "2.0", sessions: 9);
|
||||
rig.Tables.AddInventory("tailspin", "Tailspin Launcher", "1.0", sessions: 1);
|
||||
Assert.Equal(3, (await rig.GetAsync()).GetProperty("total").GetInt32());
|
||||
|
||||
// Neither write went through this instance, whose copy of the mappings is seconds old.
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(
|
||||
CpeMappingStore.CustomPartition, "manual", "northwind", "northwind tool", "cpe:2.3:a:northwind:tool"));
|
||||
var ignore = CpeIgnoreEntry.Build("adatum", "Adatum Reader", "manual")!.Value;
|
||||
rig.Tables.Put(CpeMappingStore.IgnoredPartition, ignore.RowKey, ignore.Properties);
|
||||
|
||||
var body = await rig.GetAsync();
|
||||
|
||||
Assert.Equal(new[] { "Tailspin Launcher" }, NamesOf(body));
|
||||
Assert.Equal(1, body.GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_MappingsUnreadableOnALaterCall_AnswersFromTheCopyThisInstanceHolds()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1");
|
||||
rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget", "1.0");
|
||||
await rig.GetAsync();
|
||||
|
||||
rig.Tables.ReadFailure = new InvalidOperationException("storage down");
|
||||
var body = await rig.GetAsync();
|
||||
|
||||
Assert.Equal(new[] { "Northwind Tool" }, NamesOf(body));
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// GET vulnerability/sync-status keeps two stores apart that are easily taken for one: the CVE
|
||||
/// cache (the products CVE data is held for) and the NVD product dictionary (the names programs
|
||||
/// are matched against). A dictionary that was never stored is absent from the answer, not zero.
|
||||
/// </summary>
|
||||
public class GetVulnerabilitySyncStatusFunctionTests : IDisposable
|
||||
{
|
||||
public GetVulnerabilitySyncStatusFunctionTests() => NvdTestState.Reset();
|
||||
|
||||
public void Dispose() => NvdTestState.Reset();
|
||||
|
||||
private static async Task<JsonElement> StatusAsync(CpeDictionaryService dictionary, params string[] cachedProducts)
|
||||
{
|
||||
var scan = await VulnerabilityScanRig.CreateAsync();
|
||||
foreach (var product in cachedProducts)
|
||||
scan.Cache(VulnerabilityCacheRows.Filled($"cpe:2.3:a:contoso:{product}", Array.Empty<CachedCve>(), DateTime.UtcNow));
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var refresh = new VulnerabilityCacheRefreshService(
|
||||
scan.Repo.Object,
|
||||
scan.Filler,
|
||||
new EpssApiClient(new HttpClient(new ReferenceDataFixture.FailingHandler()), NullLogger<EpssApiClient>.Instance),
|
||||
scan.MappingStore,
|
||||
adminConfig.Object,
|
||||
NullLogger<VulnerabilityCacheRefreshService>.Instance);
|
||||
var function = new GetVulnerabilitySyncStatusFunction(
|
||||
ReferenceDataFixture.Kev(new ReferenceDataFixture.FailingHandler()),
|
||||
ReferenceDataFixture.Msrc(),
|
||||
refresh,
|
||||
dictionary,
|
||||
adminConfig.Object,
|
||||
NullLogger<GetVulnerabilitySyncStatusFunction>.Instance);
|
||||
|
||||
var response = await function.Run(OperatorCall.Request());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return OperatorCall.BodyOf(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_WithAStoredDictionary_NamesItsSizeAndAge_NextToTheCveCache()
|
||||
{
|
||||
var dictionary = CpeDictionaryFixture.Stored(
|
||||
("contoso:widget", "Contoso Widget"), ("fabrikam:gadget", "Fabrikam Gadget"), ("northwind:tool", "Northwind Tool"));
|
||||
|
||||
var status = await StatusAsync(dictionary, "widget");
|
||||
|
||||
Assert.Equal(3, status.GetProperty("cpeDictionaryProducts").GetInt32());
|
||||
Assert.Equal("2026-11-11T03:00:00.0000000Z", status.GetProperty("cpeDictionaryLastRefreshUtc").GetString());
|
||||
// The cache counts the one product CVE data is held for, whatever the dictionary lists.
|
||||
Assert.Equal(1, status.GetProperty("nvdCacheEntries").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_WithoutADictionary_LeavesBothSlotsOut()
|
||||
{
|
||||
var status = await StatusAsync(CpeDictionaryFixture.Stored(), "widget", "gadget");
|
||||
|
||||
Assert.False(status.TryGetProperty("cpeDictionaryProducts", out _));
|
||||
Assert.False(status.TryGetProperty("cpeDictionaryLastRefreshUtc", out _));
|
||||
Assert.Equal(2, status.GetProperty("nvdCacheEntries").GetInt32());
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -25,10 +25,21 @@ internal sealed class FakeGraphFeatureDetector : IGraphFeatureDetector
|
||||
ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>());
|
||||
}
|
||||
|
||||
public Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
|
||||
/// <summary>Tenants whose token never arrives: the call waits until the caller's budget cancels it.</summary>
|
||||
public HashSet<string> HangingTenants { get; } = new();
|
||||
|
||||
/// <summary>When set, <see cref="TryGetTokenContextAsync"/> throws it.</summary>
|
||||
public Exception? TokenContextThrows { get; set; }
|
||||
|
||||
public async Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (TokenContextThrows != null) throw TokenContextThrows;
|
||||
if (HangingTenants.Contains(tenantId))
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
||||
}
|
||||
ContextByTenant.TryGetValue(tenantId, out var ctx);
|
||||
return Task.FromResult(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// <summary>When set for a tenant, <see cref="GetSnapshotAsync"/> reports it as transient.</summary>
|
||||
|
||||
+165
-17
@@ -1,12 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
@@ -28,9 +31,43 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Null(result[refs[0]]);
|
||||
Assert.Single(result.Names);
|
||||
Assert.Null(result.Names[refs[0]]);
|
||||
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
|
||||
Assert.Empty(fix.IncompleteLines); // "no token or no role" is an answer, not a cut
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Token_that_outlasts_its_budget_is_reported_as_an_incomplete_lookup()
|
||||
{
|
||||
var fix = new Fixture(tokenAcquireBudget: TimeSpan.FromMilliseconds(50));
|
||||
fix.Detector.HangingTenants.Add(TenantId);
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
var line = Assert.Single(fix.IncompleteLines);
|
||||
Assert.Contains($"tenant={TenantId} ", line);
|
||||
Assert.Contains("reasons=TokenTimeout ", line);
|
||||
Assert.Contains("requested=1 ", line);
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failing_token_context_is_reported_as_an_incomplete_lookup()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.Detector.TokenContextThrows = new InvalidOperationException("simulated");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Contains("reasons=TokenError ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut); // a failure is not a time limit: asking again is not suggested
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -58,7 +95,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("MyScript.ps1", result[r]);
|
||||
Assert.Equal("MyScript.ps1", result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -77,11 +114,78 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("Bootstrap", result[r]);
|
||||
Assert.Equal("Bootstrap", result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
// Both list entries cached for future calls.
|
||||
Assert.Equal(2, fix.Repo.Entries.Count);
|
||||
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.Empty(fix.IncompleteLines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_missing_from_the_list_is_still_resolved_by_id()
|
||||
{
|
||||
// The list leaves out some scripts that a single GET returns. Treating "not in the list"
|
||||
// as "not found" would drop their names.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Http.When("deviceHealthScripts?", HttpStatusCode.OK,
|
||||
"{\"value\":[{\"id\":\"listed\",\"displayName\":\"Listed\"}]}");
|
||||
fix.Http.When("deviceHealthScripts/unlisted", HttpStatusCode.OK,
|
||||
"{\"id\":\"unlisted\",\"displayName\":\"Unlisted\"}");
|
||||
var listed = new ScriptRef(ScriptKind.Remediation, "listed");
|
||||
var unlisted = new ScriptRef(ScriptKind.Remediation, "unlisted");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { listed, unlisted });
|
||||
|
||||
Assert.Equal("Listed", result.Names[listed]);
|
||||
Assert.Equal("Unlisted", result.Names[unlisted]);
|
||||
Assert.Equal(2, fix.Http.Requests.Count);
|
||||
Assert.False(fix.Repo.Entries[(TenantId, ScriptKind.Remediation, "unlisted")].IsNotFound);
|
||||
Assert.Empty(fix.IncompleteLines);
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timed_out_list_pull_is_reported_and_skips_the_single_lookups()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Http.WhenTimedOut("deviceManagementScripts?");
|
||||
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
|
||||
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.Contains("reasons=ListTimeout:Platform ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_ending_between_list_pages_stores_nothing_and_leaves_the_kind_unfetched()
|
||||
{
|
||||
// A list cut after its first page is incomplete. Stored together with the meta row it
|
||||
// would count as the tenant's whole list for a week.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
using var caller = new CancellationTokenSource();
|
||||
fix.Http.OnRequest = _ => caller.Cancel();
|
||||
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
|
||||
"{\"value\":[{\"id\":\"id-1\",\"displayName\":\"Bootstrap\"}]," +
|
||||
"\"@odata.nextLink\":\"https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$skiptoken=2\"}");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "id-1");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r }, caller.Token);
|
||||
|
||||
Assert.Equal("Bootstrap", result.Names[r]); // page one was read before the cut
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Empty(fix.Repo.Entries);
|
||||
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -102,7 +206,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("Net-New", result[r]);
|
||||
Assert.Equal("Net-New", result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
|
||||
// Per-ID fetch also writes to cache.
|
||||
@@ -125,9 +229,40 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
|
||||
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
|
||||
Assert.Empty(fix.IncompleteLines); // a 404 is an answer
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timed_out_single_lookup_is_reported_and_ends_the_loop()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Remediation,
|
||||
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
||||
};
|
||||
fix.Http.WhenTimedOut("deviceHealthScripts/first");
|
||||
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
|
||||
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
||||
var refs = new[]
|
||||
{
|
||||
new ScriptRef(ScriptKind.Remediation, "first"),
|
||||
new ScriptRef(ScriptKind.Remediation, "second"),
|
||||
};
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.All(refs, r => Assert.Null(result.Names[r]));
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Empty(fix.Repo.Entries); // a cut is not an answer: nothing is cached as not found
|
||||
Assert.Contains("reasons=LookupTimeout ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -152,8 +287,8 @@ public class ScriptDisplayNameResolverTests
|
||||
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(first[r]);
|
||||
Assert.Null(second[r]);
|
||||
Assert.Null(first.Names[r]);
|
||||
Assert.Null(second.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
|
||||
Assert.False(entry.IsNotFound);
|
||||
@@ -168,7 +303,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty<ScriptRef>());
|
||||
|
||||
Assert.Empty(result);
|
||||
Assert.Empty(result.Names);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -182,7 +317,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -200,9 +335,11 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]); // per-ID never ran
|
||||
Assert.Null(result.Names[r]); // per-ID never ran
|
||||
Assert.Single(fix.Http.Requests); // only the failed full-pull
|
||||
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
|
||||
Assert.Contains("reasons=ListError:Platform ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut); // Graph refused: an immediate second lookup would add to the throttle
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -234,9 +371,11 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.All(refs, r => Assert.Null(result[r]));
|
||||
Assert.All(refs, r => Assert.Null(result.Names[r]));
|
||||
Assert.Single(fix.Http.Requests); // second call never issued
|
||||
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
|
||||
Assert.Contains("reasons=LookupError ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -257,7 +396,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -269,19 +408,28 @@ public class ScriptDisplayNameResolverTests
|
||||
public FakeScriptNameCacheRepository Repo { get; } = new();
|
||||
public StubHttpMessageHandler Http { get; } = new();
|
||||
public StubHttpClientFactory HttpFactory { get; }
|
||||
public Mock<ILogger<ScriptDisplayNameResolver>> Logger { get; } = new();
|
||||
public ScriptDisplayNameResolver Sut { get; }
|
||||
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public Fixture()
|
||||
public Fixture(TimeSpan? tokenAcquireBudget = null)
|
||||
{
|
||||
HttpFactory = new StubHttpClientFactory(Http);
|
||||
Sut = new ScriptDisplayNameResolver(
|
||||
Detector, Repo, HttpFactory,
|
||||
NullLogger<ScriptDisplayNameResolver>.Instance,
|
||||
Logger.Object,
|
||||
new TelemetryClient(new TelemetryConfiguration()),
|
||||
new FakeTimeProvider(FixedNow));
|
||||
new FakeTimeProvider(FixedNow),
|
||||
tokenAcquireBudget);
|
||||
}
|
||||
|
||||
/// <summary>The summary lines written for lookups that ended without every answer.</summary>
|
||||
public List<string> IncompleteLines => Logger.Invocations
|
||||
.Where(i => i.Method.Name == nameof(ILogger.Log) && i.Arguments[0] is LogLevel.Warning)
|
||||
.Select(i => i.Arguments[2]?.ToString() ?? string.Empty)
|
||||
.Where(m => m.StartsWith("Script-name lookup incomplete:", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
public void GivenPermission()
|
||||
{
|
||||
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
|
||||
|
||||
+8
-7
@@ -12,8 +12,7 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
/// <list type="bullet">
|
||||
/// <item>Positive rows older than 7 days are removed.</item>
|
||||
/// <item>Positive rows younger than 7 days are kept.</item>
|
||||
/// <item>NotFound rows older than 1 hour are removed.</item>
|
||||
/// <item>NotFound rows younger than 1 hour are kept.</item>
|
||||
/// <item>NotFound rows follow the same 7 days: older ones are removed, younger ones are kept.</item>
|
||||
/// <item>A failed delete on one row doesn't poison the run — others still get processed.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -55,18 +54,20 @@ public class ScriptNameCacheCleanupFunctionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deletes_notfound_rows_older_than_1_hour()
|
||||
public async Task Deletes_notfound_rows_only_after_the_same_7_days()
|
||||
{
|
||||
// A not-found row that goes after hours instead of days sends one Graph lookup per
|
||||
// unknown ID on the first view after every cleanup run.
|
||||
var fix = new Fixture();
|
||||
var deletedId = "deleted-1h-ago";
|
||||
var recentId = "recent-30min";
|
||||
var deletedId = "not-found-8-days-ago";
|
||||
var recentId = "not-found-2-hours-ago";
|
||||
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, deletedId)] = new ScriptDisplayNameEntry
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Remediation,
|
||||
ScriptId = deletedId,
|
||||
DisplayName = null,
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromDays(8),
|
||||
IsNotFound = true,
|
||||
};
|
||||
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, recentId)] = new ScriptDisplayNameEntry
|
||||
@@ -75,7 +76,7 @@ public class ScriptNameCacheCleanupFunctionTests
|
||||
Kind = ScriptKind.Remediation,
|
||||
ScriptId = recentId,
|
||||
DisplayName = null,
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
|
||||
IsNotFound = true,
|
||||
};
|
||||
|
||||
|
||||
+16
@@ -16,18 +16,34 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly List<(string UrlSubstring, HttpStatusCode Status, string Body)> _scripted = new();
|
||||
private readonly List<string> _timedOut = new();
|
||||
public List<string> Requests { get; } = new();
|
||||
|
||||
/// <summary>Runs when a request arrives, before it is answered (e.g. to cancel the caller mid-flight).</summary>
|
||||
public Action<string>? OnRequest { get; set; }
|
||||
|
||||
public StubHttpMessageHandler When(string urlSubstring, HttpStatusCode status, string body)
|
||||
{
|
||||
_scripted.Add((urlSubstring, status, body));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Scripts a request that ends the way a per-request timeout does, without waiting.</summary>
|
||||
public StubHttpMessageHandler WhenTimedOut(string urlSubstring)
|
||||
{
|
||||
_timedOut.Add(urlSubstring);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
Requests.Add(url);
|
||||
OnRequest?.Invoke(url);
|
||||
if (_timedOut.Any(s => url.Contains(s, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new TaskCanceledException($"StubHttpMessageHandler: scripted timeout for {url}");
|
||||
}
|
||||
var match = _scripted.FirstOrDefault(s => url.Contains(s.UrlSubstring, StringComparison.OrdinalIgnoreCase));
|
||||
if (match.Equals(default((string, HttpStatusCode, string))))
|
||||
{
|
||||
|
||||
@@ -76,6 +76,34 @@ public class GraphWireParityTests
|
||||
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_carries_timedOut_only_when_a_time_limit_fired()
|
||||
{
|
||||
// The portal asks once more when it sees timedOut. Null leaves the key out, so an
|
||||
// answer within the limits keeps the shapes pinned above.
|
||||
var payload = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:6cef8fa7-9163-40cf-8f56-52f3aaaa1006"] = null,
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { refs = payload, timedOut = true },
|
||||
new GetScriptDisplayNamesResponse { Refs = payload, TimedOut = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptDisplayNamesRequest_reads_the_retry_flag_and_tolerates_its_absence()
|
||||
{
|
||||
var options = ApiJsonOptions.Create();
|
||||
const string Ref = "\"Platform:7df090b8-a274-41d0-9067-52f3aaaa1007\"";
|
||||
|
||||
var followUp = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "],\"retry\":true}", options);
|
||||
var first = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "]}", options);
|
||||
|
||||
Assert.True(followUp!.Retry);
|
||||
Assert.Null(first!.Retry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The ignore list (<c>vulnerability/ignored-software</c>): an entry is filed like a mapping,
|
||||
/// under the vendor and the program's name without version, so one entry covers every version,
|
||||
/// and what the list shows for an entry is what removes it again.
|
||||
/// </summary>
|
||||
public class IgnoreSoftwareFunctionTests
|
||||
{
|
||||
private const string Ignored = CpeMappingStore.IgnoredPartition;
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", "cpe:2.3:a:fabrikam:gadget", "fabrikam gadget"));
|
||||
Store = new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance);
|
||||
Function = new IgnoreSoftwareFunction(Tables.Repo.Object, Store, NullLogger<IgnoreSoftwareFunction>.Instance);
|
||||
}
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
public CpeMappingStore Store { get; }
|
||||
public IgnoreSoftwareFunction Function { get; }
|
||||
|
||||
public async Task<JsonElement> AddAsync(params IgnoreSoftwareItem[] items)
|
||||
{
|
||||
var response = await Function.AddIgnored(OperatorCall.Request(new IgnoreSoftwareRequest { Items = items.ToList() }));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return OperatorCall.BodyOf(response);
|
||||
}
|
||||
|
||||
public async Task<List<JsonElement>> ListAsync()
|
||||
{
|
||||
var response = await Function.GetIgnored(OperatorCall.Request());
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return OperatorCall.BodyOf(response).GetProperty("items").EnumerateArray().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Removes an entry the way the portal does: with the name and the publisher the list shows for it.</summary>
|
||||
public async Task RemoveAsListedAsync(JsonElement listed)
|
||||
{
|
||||
var response = await Function.DeleteIgnored(OperatorCall.Request(new IgnoreSoftwareDeleteRequest
|
||||
{
|
||||
SoftwareName = listed.GetProperty("softwareName").GetString()!,
|
||||
Publisher = listed.GetProperty("publisher").GetString(),
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_FilesTheEntryUnderVendorAndNameWithoutVersion_SoItCoversEveryVersion()
|
||||
{
|
||||
var rig = new Rig();
|
||||
Assert.False((await rig.Store.GetAsync()).IsIgnored("contoso", "Contoso Ltd", "Contoso Widget 5.0"));
|
||||
|
||||
var body = await rig.AddAsync(new IgnoreSoftwareItem
|
||||
{
|
||||
SoftwareName = "Contoso Widget 4.2 (x64)", Publisher = "Contoso Ltd", NormalizedVendor = "contoso",
|
||||
});
|
||||
|
||||
Assert.True(body.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(1, body.GetProperty("added").GetInt32());
|
||||
var entry = Assert.Single(rig.Tables.Rows(Ignored));
|
||||
Assert.Equal("contoso:contoso widget", entry["RowKey"]);
|
||||
Assert.Equal("contoso", entry["NormalizedVendor"]);
|
||||
Assert.Equal("contoso widget", entry["NormalizedProduct"]);
|
||||
Assert.Equal("manual", entry["Reason"]);
|
||||
// The next reader on this instance already leaves the program out.
|
||||
Assert.True((await rig.Store.GetAsync()).IsIgnored("contoso", "Contoso Ltd", "Contoso Widget 5.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WithoutAVendor_FilesTheEntryUnderThePublisher_AndSkipsANameThatIsOnlyAVersion()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var body = await rig.AddAsync(
|
||||
new IgnoreSoftwareItem { SoftwareName = "Contoso Widget 4.2", Publisher = "Contoso Ltd", NormalizedVendor = " ", Reason = "not ours" },
|
||||
new IgnoreSoftwareItem { SoftwareName = "4.2 (x64)", Publisher = "Contoso Ltd" });
|
||||
|
||||
Assert.Equal(1, body.GetProperty("added").GetInt32());
|
||||
var entry = Assert.Single(rig.Tables.Rows(Ignored));
|
||||
Assert.Equal("contoso ltd:contoso widget", entry["RowKey"]);
|
||||
Assert.Equal("contoso ltd", entry["NormalizedVendor"]);
|
||||
Assert.Equal("not ours", entry["Reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_WithWhatTheListShowsForAnEntry_RemovesIt()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.AddAsync(
|
||||
new IgnoreSoftwareItem { SoftwareName = "Contoso Widget 4.2 (x64)", Publisher = "Contoso Ltd", NormalizedVendor = "contoso" },
|
||||
new IgnoreSoftwareItem { SoftwareName = "Northwind Tool 3.1", Publisher = "Northwind Traders" });
|
||||
var listed = await rig.ListAsync();
|
||||
Assert.Equal(new[] { "contoso widget", "northwind tool" }, listed.Select(e => e.GetProperty("softwareName").GetString()));
|
||||
|
||||
await rig.RemoveAsListedAsync(listed[0]);
|
||||
|
||||
Assert.Equal(new[] { "northwind traders:northwind tool" }, rig.Tables.RowKeys(Ignored));
|
||||
Assert.False((await rig.Store.GetAsync()).IsIgnored("contoso", "Contoso Ltd", "Contoso Widget 5.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_EntryStoredUnderAnEarlierKey_IsRemovedToo()
|
||||
{
|
||||
var rig = new Rig();
|
||||
// Written before entries were filed under the name without version: the row key and the
|
||||
// columns hold the publisher and the name as the program showed them.
|
||||
rig.Tables.Put(Ignored, "contoso ltd:contoso widget 4.2 (x64)", new Dictionary<string, object>
|
||||
{
|
||||
["NormalizedVendor"] = "Contoso Ltd",
|
||||
["NormalizedProduct"] = "Contoso Widget 4.2 (x64)",
|
||||
["Reason"] = "manual",
|
||||
["CreatedAt"] = "2026-08-01T08:00:00.0000000Z",
|
||||
});
|
||||
// The entry is in effect and listed.
|
||||
Assert.True((await rig.Store.GetAsync()).IsIgnored("contoso", "Contoso Ltd", "Contoso Widget 5.0"));
|
||||
var listed = Assert.Single(await rig.ListAsync());
|
||||
|
||||
await rig.RemoveAsListedAsync(listed);
|
||||
|
||||
// The call answered that the entry was removed.
|
||||
Assert.Empty(rig.Tables.Rows(Ignored));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{ "items": [] }""")]
|
||||
[InlineData("{}")]
|
||||
public async Task Add_WithoutItems_IsABadRequest(string json)
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var response = await rig.Function.AddIgnored(OperatorCall.RequestWithJson(json));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_WithoutAName_IsABadRequest()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var response = await rig.Function.DeleteIgnored(OperatorCall.RequestWithJson("""{ "publisher": "Contoso Ltd" }"""));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
/// <summary>An entry as it was written while the list took the publisher and the name as the program showed them.</summary>
|
||||
private static Dictionary<string, object> EarlierEntry(string publisher, string softwareName) => new()
|
||||
{
|
||||
["NormalizedVendor"] = publisher,
|
||||
["NormalizedProduct"] = softwareName,
|
||||
["Reason"] = "manual",
|
||||
["CreatedAt"] = "2026-08-01T08:00:00.0000000Z",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RemovesEveryEntryThatStandsForTheProgram_AndNoOther()
|
||||
{
|
||||
var rig = new Rig();
|
||||
// Two entries from before, for two versions of the program, and one written since.
|
||||
rig.Tables.Put(Ignored, "contoso ltd:contoso widget 4.2 (x64)", EarlierEntry("Contoso Ltd", "Contoso Widget 4.2 (x64)"));
|
||||
rig.Tables.Put(Ignored, "contoso ltd:contoso widget 5.0", EarlierEntry("Contoso Ltd", "Contoso Widget 5.0"));
|
||||
await rig.AddAsync(
|
||||
new IgnoreSoftwareItem { SoftwareName = "Contoso Widget 6.0", Publisher = "Contoso Ltd" },
|
||||
// Another program of the publisher, and the same name under another publisher.
|
||||
new IgnoreSoftwareItem { SoftwareName = "Contoso Widget Viewer", Publisher = "Contoso Ltd" },
|
||||
new IgnoreSoftwareItem { SoftwareName = "Contoso Widget", Publisher = "Fabrikam Inc" });
|
||||
Assert.Equal(5, rig.Tables.Rows(Ignored).Count);
|
||||
|
||||
var response = await rig.Function.DeleteIgnored(OperatorCall.Request(
|
||||
new IgnoreSoftwareDeleteRequest { SoftwareName = "contoso widget", Publisher = "contoso ltd" }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "contoso ltd:contoso widget viewer", "fabrikam inc:contoso widget" }, rig.Tables.RowKeys(Ignored));
|
||||
Assert.False((await rig.Store.GetAsync()).IsIgnored("contoso ltd", "Contoso Ltd", "Contoso Widget 7.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_EntryThatIsNotOnTheList_ChangesNothing()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.AddAsync(new IgnoreSoftwareItem { SoftwareName = "Contoso Widget 4.2", Publisher = "Contoso Ltd" });
|
||||
var writesBefore = rig.Tables.Writes.ToList();
|
||||
|
||||
var response = await rig.Function.DeleteIgnored(OperatorCall.Request(
|
||||
new IgnoreSoftwareDeleteRequest { SoftwareName = "Northwind Tool", Publisher = "Northwind Traders" }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(writesBefore, rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ListedEntryWhoseNameIsNoProgramName_IsRemovedToo()
|
||||
{
|
||||
var rig = new Rig();
|
||||
// The list once took any name. This one is a patch number, which names no program: the
|
||||
// entry ignores nothing any more, but the list still shows it.
|
||||
rig.Tables.Put(Ignored, "contoso ltd:kb9900001", EarlierEntry("Contoso Ltd", "KB9900001"));
|
||||
var listed = Assert.Single(await rig.ListAsync());
|
||||
|
||||
await rig.RemoveAsListedAsync(listed);
|
||||
|
||||
// The call answered that the entry was removed.
|
||||
Assert.Empty(rig.Tables.Rows(Ignored));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="KevDataService"/> answers lookups from the catalog it holds and never fetches on
|
||||
/// that path: a scan asks once per CVE. The catalog changes only through a refresh, and a refresh
|
||||
/// that fails or comes back empty says so and leaves the loaded catalog in service.
|
||||
/// </summary>
|
||||
public class KevDataServiceTests
|
||||
{
|
||||
/// <summary>Answers each request with what the test queued for it and counts the requests.</summary>
|
||||
private sealed class CisaStub : HttpMessageHandler
|
||||
{
|
||||
public Func<HttpResponseMessage> Answer { get; set; } = () => new HttpResponseMessage(HttpStatusCode.InternalServerError);
|
||||
public int Requests { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests++;
|
||||
return Task.FromResult(Answer());
|
||||
}
|
||||
}
|
||||
|
||||
private static Func<HttpResponseMessage> Catalog(params string[] cveIds) =>
|
||||
() => NvdFixture.Json(ReferenceDataFixture.KevCatalogJson(cveIds));
|
||||
|
||||
[Fact]
|
||||
public void Lookup_BeforeAnythingIsLoaded_FindsNothingAndSendsNoRequest()
|
||||
{
|
||||
var cisa = new CisaStub { Answer = Catalog("CVE-2023-4863") };
|
||||
var kev = ReferenceDataFixture.Kev(cisa);
|
||||
|
||||
Assert.Null(kev.Lookup("CVE-2023-4863"));
|
||||
Assert.Null(kev.Lookup(null));
|
||||
Assert.Null(kev.Lookup(" "));
|
||||
|
||||
Assert.Equal(0, cisa.Requests);
|
||||
Assert.Equal(0, kev.CatalogSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_LoadsTheCatalog_AndLookupsReadItWithoutFetching()
|
||||
{
|
||||
var cisa = new CisaStub { Answer = Catalog("CVE-2023-4863", "CVE-2024-0001") };
|
||||
var blob = new Mock<BlobClient>();
|
||||
var kev = ReferenceDataFixture.Kev(cisa, blob);
|
||||
|
||||
Assert.True(await kev.RefreshAsync());
|
||||
|
||||
var entry = kev.Lookup("cve-2023-4863"); // ids compare without case
|
||||
Assert.Equal("CVE-2023-4863", entry!.CveID);
|
||||
Assert.Equal("2026-10-22", entry.DueDate);
|
||||
Assert.Equal("Apply mitigations per vendor instructions.", entry.RequiredAction);
|
||||
Assert.Equal("Unknown", entry.KnownRansomwareCampaignUse);
|
||||
Assert.Null(kev.Lookup("CVE-2020-9999"));
|
||||
Assert.Equal(2, kev.CatalogSize);
|
||||
Assert.Equal(1, cisa.Requests);
|
||||
// The snapshot is kept for the next cold start.
|
||||
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_ReplacesTheCatalog()
|
||||
{
|
||||
var cisa = new CisaStub { Answer = Catalog("CVE-2023-4863") };
|
||||
var kev = ReferenceDataFixture.Kev(cisa);
|
||||
await kev.RefreshAsync();
|
||||
|
||||
cisa.Answer = Catalog("CVE-2024-0001");
|
||||
Assert.True(await kev.RefreshAsync());
|
||||
|
||||
Assert.Null(kev.Lookup("CVE-2023-4863"));
|
||||
Assert.NotNull(kev.Lookup("CVE-2024-0001"));
|
||||
Assert.Equal(1, kev.CatalogSize);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage NoCatalog(string kind) => kind switch
|
||||
{
|
||||
"server error" => new HttpResponseMessage(HttpStatusCode.BadGateway),
|
||||
"empty list" => NvdFixture.Json("""{"title":"CISA Catalog","count":0,"vulnerabilities":[]}"""),
|
||||
"no list" => NvdFixture.Json("""{"title":"CISA Catalog"}"""),
|
||||
"not json" => NvdFixture.Json("<html>maintenance</html>"),
|
||||
"connection lost" => throw new HttpRequestException("connection reset"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("server error")]
|
||||
[InlineData("empty list")]
|
||||
[InlineData("no list")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("connection lost")]
|
||||
public async Task Refresh_FailedOrEmptyAnswer_ReportsItAndKeepsTheLoadedCatalog(string answer)
|
||||
{
|
||||
var cisa = new CisaStub { Answer = Catalog("CVE-2023-4863") };
|
||||
var blob = new Mock<BlobClient>();
|
||||
var kev = ReferenceDataFixture.Kev(cisa, blob);
|
||||
Assert.True(await kev.RefreshAsync());
|
||||
var loadedAt = kev.LastRefresh;
|
||||
|
||||
cisa.Answer = () => NoCatalog(answer);
|
||||
var refreshed = await kev.RefreshAsync();
|
||||
|
||||
Assert.False(refreshed);
|
||||
Assert.NotNull(kev.Lookup("CVE-2023-4863"));
|
||||
Assert.Equal(1, kev.CatalogSize);
|
||||
Assert.Equal(loadedAt, kev.LastRefresh);
|
||||
// Nothing new to keep: only the successful refresh wrote the snapshot.
|
||||
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureHydrated_LoadsTheSnapshotFromItsBlob_WithoutAskingCisa()
|
||||
{
|
||||
var snapshot = new BlobBackedReferenceCache<KevEntry>.Snapshot
|
||||
{
|
||||
LastRefresh = "2026-11-11T03:00:00.0000000Z",
|
||||
Entries = { ["CVE-2023-4863"] = new KevEntry { CveID = "CVE-2023-4863", DueDate = "2023-10-04" } },
|
||||
};
|
||||
var blob = new Mock<BlobClient>();
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>())).ReturnsAsync(Response.FromValue(true, Mock.Of<Response>()));
|
||||
blob.Setup(b => b.DownloadContentAsync(It.IsAny<CancellationToken>())).ReturnsAsync(Response.FromValue(
|
||||
BlobsModelFactory.BlobDownloadResult(content: BinaryData.FromString(JsonSerializer.Serialize(snapshot))),
|
||||
Mock.Of<Response>()));
|
||||
var cisa = new CisaStub();
|
||||
var kev = ReferenceDataFixture.Kev(cisa, blob);
|
||||
|
||||
await kev.EnsureHydratedAsync();
|
||||
await kev.EnsureHydratedAsync();
|
||||
|
||||
Assert.Equal("2023-10-04", kev.Lookup("CVE-2023-4863")!.DueDate);
|
||||
Assert.Equal(new DateTime(2026, 11, 11, 3, 0, 0, DateTimeKind.Utc), kev.LastRefresh);
|
||||
Assert.Equal(0, cisa.Requests);
|
||||
blob.Verify(b => b.DownloadContentAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="NvdApiClient.SearchCvesByCpeAsync"/> reads a product's CVEs in full pages: one page
|
||||
/// when NVD has no more, the first and the last page when it has, and never half an answer. A
|
||||
/// request that runs into its own time limit fails that request; only the caller's cancellation
|
||||
/// ends the call with an exception.
|
||||
/// </summary>
|
||||
[Collection(NvdTestState.Collection)]
|
||||
public sealed class NvdApiClientPagingTests : IDisposable
|
||||
{
|
||||
private const string Cpe = "cpe:2.3:a:google:chrome";
|
||||
|
||||
public NvdApiClientPagingTests() => NvdTestState.Reset();
|
||||
|
||||
public void Dispose() => NvdTestState.Reset();
|
||||
|
||||
/// <summary>NVD pages by position: the page at <c>startIndex</c> holds the CVEs numbered from there.</summary>
|
||||
private static HttpResponseMessage PageOf(int totalResults, NvdRequest request) => NvdFixture.Page(
|
||||
totalResults,
|
||||
Enumerable.Range(request.StartIndex + 1, Math.Min(request.ResultsPerPage, totalResults - request.StartIndex))
|
||||
.Select(n => new NvdVulnerabilityItem { Cve = new NvdCve { Id = $"CVE-2020-{n:00000}" } }));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(137)]
|
||||
[InlineData(2000)]
|
||||
public async Task UpToOnePage_IsOneRequestForAFullPageFromTheStart(int totalResults)
|
||||
{
|
||||
var nvd = new NvdStubHandler(request => PageOf(totalResults, request));
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe);
|
||||
|
||||
var request = Assert.Single(nvd.Requests);
|
||||
Assert.Equal(Cpe, request.Cpe);
|
||||
Assert.EndsWith("&resultsPerPage=2000&startIndex=0", request.Uri.Query);
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(totalResults, result.Data.TotalResults);
|
||||
Assert.Equal(totalResults, result.Data.Vulnerabilities.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4500, 2500, 4000)] // the middle 500 lie years back and are left out
|
||||
[InlineData(2500, 500, 2500)] // the two pages overlap by 1500
|
||||
[InlineData(2001, 1, 2001)]
|
||||
public async Task MoreThanOnePage_AlsoFetchesTheLastPageAndJoinsBoth(int totalResults, int lastPageStart, int expectedCves)
|
||||
{
|
||||
var nvd = new NvdStubHandler(request => PageOf(totalResults, request));
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe);
|
||||
|
||||
Assert.Equal(new[] { 0, lastPageStart }, nvd.Requests.Select(r => r.StartIndex));
|
||||
Assert.All(nvd.Requests, r => Assert.Equal(2000, r.ResultsPerPage));
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(totalResults, result.Data.TotalResults);
|
||||
|
||||
var ids = result.Data.Vulnerabilities.Select(v => v.Cve!.Id!).ToList();
|
||||
Assert.Equal(expectedCves, ids.Count);
|
||||
Assert.Equal(expectedCves, ids.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
Assert.Contains("CVE-2020-00001", ids);
|
||||
Assert.Contains($"CVE-2020-{totalResults:00000}", ids);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError, false)]
|
||||
[InlineData(HttpStatusCode.TooManyRequests, true)]
|
||||
public async Task LastPageFails_TheWholeCallFails(HttpStatusCode lastPageStatus, bool throttled)
|
||||
{
|
||||
var nvd = new NvdStubHandler(request =>
|
||||
request.StartIndex == 0 ? PageOf(4500, request) : NvdFixture.Status(lastPageStatus));
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe);
|
||||
|
||||
Assert.Equal(2, nvd.Requests.Count);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(throttled, result.Throttled);
|
||||
// No half answer: the first page is not handed out without the newest CVEs.
|
||||
Assert.Empty(result.Data.Vulnerabilities);
|
||||
Assert.Equal(throttled, NvdApiClient.IsInCooldown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstPageFails_NoSecondRequest()
|
||||
{
|
||||
var nvd = new NvdStubHandler(_ => NvdFixture.Status(HttpStatusCode.ServiceUnavailable));
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe);
|
||||
|
||||
Assert.Single(nvd.Requests);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.False(result.Throttled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The 45 s limit itself is not waited for here. What it does to a request is the same as
|
||||
/// this: the send is cancelled although the caller's token is still live.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestCancelledByItsOwnTimeLimit_IsAFailedResultNotAnException()
|
||||
{
|
||||
var nvd = new NvdStubHandler((_, _) => throw new TaskCanceledException("the request ran out of time"));
|
||||
using var caller = new CancellationTokenSource();
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe, caller.Token);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.False(result.Throttled);
|
||||
Assert.Empty(result.Data.Vulnerabilities);
|
||||
}
|
||||
|
||||
/// <summary>The limit also covers the body, which is read after the headers have arrived.</summary>
|
||||
[Fact]
|
||||
public async Task BodyReadCancelledByItsOwnTimeLimit_IsAFailedResultNotAnException()
|
||||
{
|
||||
var nvd = new NvdStubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new CancelledBody() });
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.False(result.Throttled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallerCancelsDuringTheRequest_Propagates()
|
||||
{
|
||||
var sent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var nvd = new NvdStubHandler(async (_, ct) =>
|
||||
{
|
||||
sent.SetResult();
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return NvdFixture.Page();
|
||||
});
|
||||
using var caller = new CancellationTokenSource();
|
||||
|
||||
var call = NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe, caller.Token);
|
||||
await sent.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
caller.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallerCancelledBeforeTheCall_PropagatesWithoutARequest()
|
||||
{
|
||||
var nvd = new NvdStubHandler(_ => NvdFixture.Page());
|
||||
using var caller = new CancellationTokenSource();
|
||||
caller.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe, caller.Token));
|
||||
|
||||
Assert.Empty(nvd.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadsTheDateCisaListedACveAsExploited()
|
||||
{
|
||||
// Field names as NVD sends them (API 2.0), shaped like the public CVE-2023-4863.
|
||||
const string body = """
|
||||
{"resultsPerPage":2,"startIndex":0,"totalResults":2,"format":"NVD_CVE","version":"2.0",
|
||||
"vulnerabilities":[
|
||||
{"cve":{"id":"CVE-2023-4863","published":"2023-09-12T15:15:24.327","lastModified":"2025-07-30T01:00:01.000",
|
||||
"vulnStatus":"Analyzed","cisaExploitAdd":"2023-09-13","cisaActionDue":"2023-10-04",
|
||||
"descriptions":[{"lang":"en","value":"Heap buffer overflow in libwebp."}],
|
||||
"metrics":{"cvssMetricV31":[{"type":"Primary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","baseScore":8.8,"baseSeverity":"HIGH"}}]},
|
||||
"configurations":[{"nodes":[{"operator":"OR","negate":false,"cpeMatch":[
|
||||
{"vulnerable":true,"criteria":"cpe:2.3:a:google:chrome:*:*:*:*:*:*:*:*","versionEndExcluding":"116.0.5845.187"}]}]}]}},
|
||||
{"cve":{"id":"CVE-2024-0001","published":"2024-01-02T10:00:00.000","descriptions":[],"configurations":[]}}
|
||||
]}
|
||||
""";
|
||||
var nvd = new NvdStubHandler(_ => NvdFixture.Json(body));
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(Cpe);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
var listed = result.Data.Vulnerabilities.Single(v => v.Cve!.Id == "CVE-2023-4863").Cve!;
|
||||
Assert.Equal("2023-09-13", listed.CisaExploitAdd);
|
||||
Assert.Equal("116.0.5845.187", listed.Configurations.Single().Nodes.Single().CpeMatch.Single().VersionEndExcluding);
|
||||
Assert.Null(result.Data.Vulnerabilities.Single(v => v.Cve!.Id == "CVE-2024-0001").Cve!.CisaExploitAdd);
|
||||
}
|
||||
|
||||
/// <summary>A body whose read ends the way a cancelled read does.</summary>
|
||||
private sealed class CancelledBody : HttpContent
|
||||
{
|
||||
protected override Task<Stream> CreateContentReadStreamAsync() => Task.FromResult<Stream>(new CancelledStream());
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
throw new OperationCanceledException();
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CancelledStream : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new OperationCanceledException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="NvdApiClient"/> makes of an answer that is no result. NVD refusing the client,
|
||||
/// with 429 from the API or 403 from the gateway in front of it, opens the process-wide cooldown
|
||||
/// in which nothing more is sent. A server error, a timeout or a broken connection is no answer:
|
||||
/// a caller that asks in a loop ends the loop on it. Any other refusal concerns the one request.
|
||||
/// </summary>
|
||||
[Collection(NvdTestState.Collection)]
|
||||
public sealed class NvdApiClientRefusalTests : IDisposable
|
||||
{
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string GadgetCpe = "cpe:2.3:a:fabrikam:gadget";
|
||||
|
||||
public NvdApiClientRefusalTests() => NvdTestState.Reset();
|
||||
|
||||
public void Dispose() => NvdTestState.Reset();
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
public async Task RefusalOfTheClient_IsThrottled_AndNothingIsSentWhileTheCooldownRuns(HttpStatusCode status)
|
||||
{
|
||||
var nvd = new NvdStubHandler(_ => NvdFixture.Status(status));
|
||||
var client = NvdFixture.Client(nvd);
|
||||
|
||||
var refused = await client.SearchCvesByCpeAsync(WidgetCpe);
|
||||
|
||||
Assert.False(refused.Succeeded);
|
||||
Assert.True(refused.Throttled);
|
||||
Assert.False(refused.Unanswered);
|
||||
Assert.Empty(refused.Data.Vulnerabilities);
|
||||
Assert.True(NvdApiClient.IsInCooldown);
|
||||
|
||||
// The next product is not asked about, by this client or by another one of the process.
|
||||
var otherNvd = new NvdStubHandler(_ => NvdFixture.Page());
|
||||
var next = await client.SearchCvesByCpeAsync(GadgetCpe);
|
||||
var fromAnotherClient = await NvdFixture.Client(otherNvd).SearchCvesByCpeAsync(GadgetCpe);
|
||||
|
||||
Assert.All(new[] { next, fromAnotherClient }, result =>
|
||||
{
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.True(result.Throttled);
|
||||
});
|
||||
Assert.Equal(new[] { WidgetCpe }, nvd.Requests.Select(r => r.Cpe));
|
||||
Assert.Empty(otherNvd.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CooldownRemaining_IsHowLongARefusalStillBlocks_AndZeroWithoutOne()
|
||||
{
|
||||
Assert.Equal(TimeSpan.Zero, NvdApiClient.CooldownRemaining);
|
||||
|
||||
await NvdFixture.Client(new NvdStubHandler(_ => NvdFixture.Status(HttpStatusCode.TooManyRequests))).SearchCvesByCpeAsync(WidgetCpe);
|
||||
|
||||
Assert.InRange(
|
||||
NvdApiClient.CooldownRemaining, NvdApiClient.ThrottleCooldown - TimeSpan.FromSeconds(5), NvdApiClient.ThrottleCooldown);
|
||||
|
||||
NvdApiClient.ResetThrottleState();
|
||||
Assert.Equal(TimeSpan.Zero, NvdApiClient.CooldownRemaining);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task ServerError_IsNoAnswer_AndOpensNoCooldown(HttpStatusCode status)
|
||||
{
|
||||
var nvd = new NvdStubHandler(_ => NvdFixture.Status(status));
|
||||
var client = NvdFixture.Client(nvd);
|
||||
|
||||
var result = await client.SearchCvesByCpeAsync(WidgetCpe);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.False(result.Throttled);
|
||||
Assert.True(result.Unanswered);
|
||||
Assert.False(NvdApiClient.IsInCooldown);
|
||||
|
||||
await client.SearchCvesByCpeAsync(GadgetCpe);
|
||||
Assert.Equal(new[] { WidgetCpe, GadgetCpe }, nvd.Requests.Select(r => r.Cpe));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadRequest)]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
public async Task RequestNvdTurnsDown_FailsThatRequestOnly(HttpStatusCode status)
|
||||
{
|
||||
var nvd = new NvdStubHandler(request => request.Cpe == WidgetCpe ? NvdFixture.Status(status) : NvdFixture.Page());
|
||||
var client = NvdFixture.Client(nvd);
|
||||
|
||||
var turnedDown = await client.SearchCvesByCpeAsync(WidgetCpe);
|
||||
|
||||
Assert.False(turnedDown.Succeeded);
|
||||
Assert.False(turnedDown.Throttled);
|
||||
// NVD answered: that says nothing about the next product.
|
||||
Assert.False(turnedDown.Unanswered);
|
||||
Assert.False(NvdApiClient.IsInCooldown);
|
||||
|
||||
var next = await client.SearchCvesByCpeAsync(GadgetCpe);
|
||||
Assert.True(next.Succeeded);
|
||||
Assert.Equal(new[] { WidgetCpe, GadgetCpe }, nvd.Requests.Select(r => r.Cpe));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("the connection breaks")]
|
||||
[InlineData("the request runs out of time")]
|
||||
[InlineData("the body is not what NVD sends")]
|
||||
public async Task RequestWithoutAUsableAnswer_IsUnanswered_AndOpensNoCooldown(string way)
|
||||
{
|
||||
var nvd = new NvdStubHandler((_, _) => way switch
|
||||
{
|
||||
"the connection breaks" => throw new HttpRequestException("connection reset"),
|
||||
"the request runs out of time" => throw new TaskCanceledException("the request ran out of time"),
|
||||
_ => Task.FromResult(NvdFixture.Json("<html>maintenance</html>")),
|
||||
});
|
||||
|
||||
var result = await NvdFixture.Client(nvd).SearchCvesByCpeAsync(WidgetCpe);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.False(result.Throttled);
|
||||
Assert.True(result.Unanswered);
|
||||
Assert.Empty(result.Data.Vulnerabilities);
|
||||
Assert.False(NvdApiClient.IsInCooldown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP policy in front of the NVD client (<see cref="ResiliencePolicies.NvdApi"/>) retries
|
||||
/// once for a server error or a broken connection and for nothing else. A 429 has to reach the
|
||||
/// client, which opens its cooldown instead of asking again, and a page that timed out is not
|
||||
/// fetched a second time inside a run with a time budget.
|
||||
/// </summary>
|
||||
public class NvdResiliencePolicyTests
|
||||
{
|
||||
/// <summary>Runs one request through a fresh policy; <paramref name="attempt"/> gets the attempt number, from 1.</summary>
|
||||
private static async Task<(HttpStatusCode? Status, Exception? Error, int Attempts)> SendAsync(
|
||||
Func<int, HttpResponseMessage> attempt)
|
||||
{
|
||||
var policy = new ResiliencePolicies(NullLoggerFactory.Instance).NvdApi;
|
||||
var attempts = 0;
|
||||
try
|
||||
{
|
||||
var response = await policy.ExecuteAsync(() => Task.FromResult(attempt(++attempts)));
|
||||
return (response.StatusCode, null, attempts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (null, ex, attempts);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refusal_IsNotRetried()
|
||||
{
|
||||
var (status, _, attempts) = await SendAsync(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, status);
|
||||
Assert.Equal(1, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_IsNotRetried()
|
||||
{
|
||||
var (_, error, attempts) = await SendAsync(_ => throw new TaskCanceledException("the request ran out of time"));
|
||||
|
||||
Assert.IsType<TaskCanceledException>(error);
|
||||
Assert.Equal(1, attempts);
|
||||
}
|
||||
|
||||
/// <summary>Both cases wait out the policy's pause before the retry, so they run side by side.</summary>
|
||||
[Fact]
|
||||
public async Task ServerErrorOrBrokenConnection_IsRetriedExactlyOnce()
|
||||
{
|
||||
var serverError = SendAsync(_ => new HttpResponseMessage(HttpStatusCode.BadGateway));
|
||||
var brokenConnection = SendAsync(n =>
|
||||
n == 1 ? throw new HttpRequestException("connection reset") : new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var afterServerError = await serverError;
|
||||
var afterBrokenConnection = await brokenConnection;
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadGateway, afterServerError.Status);
|
||||
Assert.Equal(2, afterServerError.Attempts);
|
||||
Assert.Equal(HttpStatusCode.OK, afterBrokenConnection.Status);
|
||||
Assert.Equal(2, afterBrokenConnection.Attempts);
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>X-Tenant-Config: stored</c> (<c>readStoredConfig</c>): a signed-in caller that has just written a
|
||||
/// tenant configuration reads the stored row instead of this instance's cached copy — for its own tenant
|
||||
/// and for a target it may reach. Without the header nothing changes: no extra read on any request.
|
||||
/// </summary>
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private static string FlagsPath(string tenantId) => $"/api/config/{tenantId}/feature-flags";
|
||||
|
||||
private static void StoreRows(Harness h)
|
||||
=> h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string tenantId) => TenantConfiguration.CreateDefault(tenantId));
|
||||
|
||||
private static int Reads(Harness h, string tenantId)
|
||||
=> h.ConfigRepo.Invocations.Count(i =>
|
||||
i.Method.Name == nameof(AutopilotMonitor.Shared.DataAccess.IConfigRepository.GetTenantConfigurationAsync)
|
||||
&& string.Equals(i.Arguments[0] as string, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
[Fact]
|
||||
public async Task Without_the_header_a_request_reads_the_cached_copy_only()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed); // warms the cache
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed);
|
||||
|
||||
Assert.Equal(0, Reads(h, TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_header_the_callers_tenant_is_read_from_storage_despite_a_cached_copy()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller);
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Equal(1, Reads(h, TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_header_a_Global_Admin_reads_its_own_and_the_target_tenant()
|
||||
{
|
||||
// A Global Admin skips the suspension gate, so without the header nothing here reads the target.
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
var caller = AuthedPrincipal(TenantA, "ga@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller); // warms whatever the decision caches
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller)).Allowed);
|
||||
Assert.Equal(0, Reads(h, TenantA) + Reads(h, TenantB));
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller, readStoredConfig: true)).Allowed);
|
||||
Assert.Equal(1, Reads(h, TenantA));
|
||||
Assert.Equal(1, Reads(h, TenantB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_header_never_makes_an_instance_read_a_tenant_the_caller_may_not_reach()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
|
||||
var r = await h.Middleware.DecideAsync(
|
||||
"GET", FlagsPath(TenantB), null, AuthedPrincipal(TenantA, "admin@contoso.com"), readStoredConfig: true);
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(0, Reads(h, TenantB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_signed_in_caller_the_header_reads_nothing()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, principal: null, readStoredConfig: true);
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Empty(h.ConfigRepo.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_failure_on_that_read_does_not_fail_the_request()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller); // a cached copy exists
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET config/{tenantId}</c> is the baseline of the settings editor and of every read-modify-write
|
||||
/// in the portal, <c>GET config/{tenantId}/feature-flags</c> decides its banners and gates. Both
|
||||
/// answer with the tenant's row or with an error — never with the synthesized default the plain
|
||||
/// cached read hands out when storage fails.
|
||||
/// </summary>
|
||||
public sealed class PortalConfigReadTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private static (TenantConfigurationService Service, StoredTenantConfig Store) Service(TenantConfiguration? row)
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(row);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
return (service, store);
|
||||
}
|
||||
|
||||
private static TenantConfiguration Row(string manufacturers)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.ManufacturerWhitelist = manufacturers;
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string? Manufacturers(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
return doc.RootElement.GetProperty("manufacturerWhitelist").GetString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_answers_with_the_tenants_row()
|
||||
{
|
||||
var (service, _) = Service(Row("Dell*"));
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("Dell*", Manufacturers(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_answers_a_storage_failure_with_an_error_not_with_defaults()
|
||||
{
|
||||
var (service, store) = Service(Row("Dell*"));
|
||||
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_still_creates_the_default_row_for_a_tenant_without_one()
|
||||
{
|
||||
var (service, store) = Service(null);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(store.Row);
|
||||
Assert.Equal(TenantConfiguration.CreateDefault(TenantId).ManufacturerWhitelist, Manufacturers(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flags_answer_a_storage_failure_with_an_error_not_with_defaults()
|
||||
{
|
||||
var (service, store) = Service(Row("Dell*"));
|
||||
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
// The read fails before the homing service or the enrollment counter is asked.
|
||||
var function = new GetTenantFeatureFlagsFunction(
|
||||
NullLogger<GetTenantFeatureFlagsFunction>.Instance, service, appHomingService: null!, Mock.Of<IMetricsRepository>());
|
||||
|
||||
var response = await function.Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using static AutopilotMonitor.Functions.Tests.MappingListFixture;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The CPE part of POST rules/reseed-from-github (<c>?type=cpe</c>) in
|
||||
/// <see cref="ReseedFromGitHubFunction"/>: both mapping lists are imported, the inventory rows
|
||||
/// follow in the same call, and a list that is refused is reported with its reasons, in the
|
||||
/// answer and (by the importer) as an ops event, while the mappings in service stay.
|
||||
/// </summary>
|
||||
public class ReseedFromGitHubFunctionTests
|
||||
{
|
||||
private const string Seed = CpeMappingStore.SeedPartition;
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string GadgetCpe = "cpe:2.3:a:fabrikam:gadget";
|
||||
private const string ReaderCpe = "cpe:2.3:a:adatum:reader";
|
||||
private const string EmptyCommunityList = """{ "version": "1.0.0", "lastUpdated": "2025-03-20", "mappings": [] }""";
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
private Rig(MappingRig maps)
|
||||
{
|
||||
Maps = maps;
|
||||
var rules = Rules.Repository();
|
||||
// A reseed of the CPE lists alone reaches neither the rule services nor the rule repository.
|
||||
Function = new ReseedFromGitHubFunction(
|
||||
NullLogger<ReseedFromGitHubFunction>.Instance,
|
||||
rules,
|
||||
gatherRuleService: null!,
|
||||
analyzeRuleService: null!,
|
||||
new Mock<IRuleRepository>(MockBehavior.Strict).Object,
|
||||
new CpeMappingImporter(
|
||||
maps.Scan.Repo.Object, rules, maps.Flags, maps.Store, OpsEvents.Object, NullLogger<CpeMappingImporter>.Instance),
|
||||
maps.Reconciler);
|
||||
}
|
||||
|
||||
/// <summary>Records nothing; a test verifies what the import reported.</summary>
|
||||
public Mock<OpsEventService> OpsEvents { get; } = new(null!, null!, null!) { CallBase = false };
|
||||
|
||||
public static async Task<Rig> CreateAsync(params Dictionary<string, object>[] mappingsInService) =>
|
||||
new(await MappingRig.CreateAsync(mappingsInService));
|
||||
|
||||
public MappingRig Maps { get; }
|
||||
public MappingTables Tables => Maps.Tables;
|
||||
public RulesRepositoryStub Rules { get; } = new() { CommunityList = EmptyCommunityList };
|
||||
public ReseedFromGitHubFunction Function { get; }
|
||||
|
||||
public Task<HttpResponseData> ReseedCpeAsync() =>
|
||||
Function.Reseed(EndpointHarness.Request(VulnerabilityScanRig.TenantId, queryString: "?type=cpe").Req);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedCpe_ImportsBothLists_AndBringsTheInventoryInLineInTheSameCall()
|
||||
{
|
||||
var rig = await Rig.CreateAsync(
|
||||
VulnerabilityCacheRows.Mapping("contoso", "contoso widget", "cpe:2.3:a:contoso:widget_old", "contoso widget"),
|
||||
VulnerabilityCacheRows.Mapping("northwind", "northwind tool", "cpe:2.3:a:northwind:tool", "northwind tool"));
|
||||
rig.Rules.SeedList = Json(
|
||||
new[] { Entry("contoso", "contoso widget", WidgetCpe), Entry("fabrikam", "fabrikam gadget", GadgetCpe) }, "contoso:widget");
|
||||
rig.Rules.CommunityList = Json(new[] { Entry("adatum", "adatum reader", ReaderCpe) });
|
||||
var corrected = rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2", cpeUri: "cpe:2.3:a:contoso:widget_old");
|
||||
var withdrawn = rig.Tables.AddInventory("northwind", "Northwind Tool 3.1", "3.1", cpeUri: "cpe:2.3:a:northwind:tool");
|
||||
var added = rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0");
|
||||
|
||||
var response = await rig.ReseedCpeAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.True(body.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("Reseed from GitHub complete", body.GetProperty("message").GetString());
|
||||
Assert.False(body.TryGetProperty("cpeProblems", out _));
|
||||
Assert.Equal((1, 2), Counts(body.GetProperty("cpeSeedMappings")));
|
||||
Assert.Equal((0, 1), Counts(body.GetProperty("cpeCommunityMappings")));
|
||||
// The rule catalogs are no part of this reseed.
|
||||
Assert.Equal((0, 0), Counts(body.GetProperty("gather")));
|
||||
Assert.Equal((0, 0), Counts(body.GetProperty("analyze")));
|
||||
Assert.Equal((0, 0), Counts(body.GetProperty("ime")));
|
||||
|
||||
var inventory = body.GetProperty("cpeInventory");
|
||||
Assert.Equal(3, inventory.GetProperty("rows").GetInt32());
|
||||
Assert.Equal(2, inventory.GetProperty("set").GetInt32());
|
||||
Assert.Equal(1, inventory.GetProperty("cleared").GetInt32());
|
||||
Assert.Equal(0, inventory.GetProperty("failed").GetInt32());
|
||||
Assert.Equal(2, inventory.GetProperty("productsNoted").GetInt32());
|
||||
Assert.Equal(WidgetCpe, corrected["CpeUri"]);
|
||||
Assert.Equal(string.Empty, withdrawn["CpeUri"]);
|
||||
Assert.Equal(ReaderCpe, added["CpeUri"]);
|
||||
|
||||
Assert.Equal(new[] { RulesRepositoryStub.CommunityListPath, RulesRepositoryStub.SeedListPath }, rig.Rules.RequestedPaths);
|
||||
Assert.True((await rig.Maps.Store.GetAsync()).IsSelfUpdating("contoso:widget"));
|
||||
Assert.Empty(RefusalsReported(rig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedCpe_ListThatIsRefused_IsReportedWithItsReasons_AndTheMappingsInServiceStay()
|
||||
{
|
||||
var rig = await Rig.CreateAsync(Tools(4)
|
||||
.Select(entry => VulnerabilityCacheRows.Mapping(entry.NormalizedVendor!, entry.NormalizedProduct!, entry.CpeUri!))
|
||||
.ToArray());
|
||||
var inService = rig.Tables.RowKeys(Seed).ToList();
|
||||
rig.Rules.SeedList = Json(Tools(1));
|
||||
|
||||
var response = await rig.ReseedCpeAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.False(body.GetProperty("success").GetBoolean());
|
||||
// The message names the part that fell short; why it did is in the problems.
|
||||
Assert.Contains("except for the CPE mappings", body.GetProperty("message").GetString());
|
||||
var problem = Assert.Single(body.GetProperty("cpeProblems").EnumerateArray()).GetString();
|
||||
Assert.StartsWith("seed list: ", problem);
|
||||
Assert.Contains("shrinks", problem);
|
||||
Assert.Contains("refused", problem);
|
||||
Assert.Equal((0, 0), Counts(body.GetProperty("cpeSeedMappings")));
|
||||
Assert.Equal(inService, rig.Tables.RowKeys(Seed));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
// Nobody may be looking at the answer of a scheduled reseed: the refusal is an ops event too.
|
||||
var reported = Assert.Single(RefusalsReported(rig));
|
||||
Assert.Equal("seed", reported.List);
|
||||
Assert.Equal(problem, "seed list: " + Assert.Single(reported.Problems));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedCpe_BothListsRefused_AreBothInTheAnswer_AndEachIsReported()
|
||||
{
|
||||
var rig = await Rig.CreateAsync(Tools(4)
|
||||
.Select(entry => VulnerabilityCacheRows.Mapping(entry.NormalizedVendor!, entry.NormalizedProduct!, entry.CpeUri!))
|
||||
.ToArray());
|
||||
rig.Rules.CommunityList = "{ \"mappings\": [";
|
||||
rig.Rules.SeedList = Json(Tools(1));
|
||||
|
||||
var response = await rig.ReseedCpeAsync();
|
||||
|
||||
var problems = OperatorCall.BodyOf(response).GetProperty("cpeProblems").EnumerateArray().Select(p => p.GetString()!).ToList();
|
||||
Assert.Equal(2, problems.Count);
|
||||
Assert.StartsWith("community list: not valid JSON", problems[0]);
|
||||
Assert.StartsWith("seed list: ", problems[1]);
|
||||
Assert.Equal(problems, RefusalsReported(rig).Select(reported => $"{reported.List} list: {Assert.Single(reported.Problems)}"));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedCpe_ImportedListsThatCannotBeReadBack_SayThatTheInventoryRowsWait()
|
||||
{
|
||||
var rig = await ImportedButNotReadBackAsync();
|
||||
var installed = rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2");
|
||||
|
||||
var response = await rig.ReseedCpeAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.Equal((0, 1), Counts(body.GetProperty("cpeSeedMappings")));
|
||||
Assert.Equal(new[] { "contoso:contoso widget" }, rig.Tables.RowKeys(Seed));
|
||||
// The answer does not pass the import off as finished.
|
||||
var problem = Assert.Single(body.GetProperty("cpeProblems").EnumerateArray()).GetString();
|
||||
Assert.Contains("could not be read back", problem);
|
||||
Assert.False(body.TryGetProperty("cpeInventory", out _));
|
||||
Assert.Equal(string.Empty, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedCpe_ImportedListsThatCannotBeReadBack_AreNotReportedAsARefusedList()
|
||||
{
|
||||
var rig = await ImportedButNotReadBackAsync();
|
||||
|
||||
await rig.ReseedCpeAsync();
|
||||
|
||||
// No list was refused and the mappings in service were replaced: the event that says
|
||||
// "list refused, the mappings in service were kept" is not this reseed's.
|
||||
Assert.Empty(RefusalsReported(rig));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A first import whose seed list is fine, on an instance that holds no copy of the mappings
|
||||
/// and cannot read the table when the reconcile asks for them.
|
||||
/// </summary>
|
||||
private static async Task<Rig> ImportedButNotReadBackAsync()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Rules.SeedList = Json(new[] { Entry("contoso", "contoso widget", WidgetCpe) });
|
||||
// The import reads and writes single partitions; only the reconcile loads the mappings as a whole.
|
||||
rig.Maps.Scan.Repo.Setup(r => r.GetAllCpeMappingsAsync())
|
||||
.ThrowsAsync(new InvalidOperationException("table storage is not reachable"));
|
||||
return rig;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedCpe_ListThatCannotBeFetched_IsABadGateway()
|
||||
{
|
||||
var rig = await Rig.CreateAsync(VulnerabilityCacheRows.Mapping("contoso", "contoso widget", WidgetCpe, "contoso widget"));
|
||||
rig.Rules.SeedList = null;
|
||||
|
||||
var response = await rig.ReseedCpeAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.UpstreamError, EndpointHarness.ErrorCode(response));
|
||||
Assert.Equal(new[] { "contoso:contoso widget" }, rig.Tables.RowKeys(Seed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReseedCpe_FirstImportThatIsRefused_StillSaysWhy()
|
||||
{
|
||||
// Nothing is in service yet, as on a new installation, and the first seed list has a mistake.
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Rules.SeedList = Json(new[] { Entry("contoso", "contoso widget", "cpe:2.3:a:contoso:widget:4.2") });
|
||||
|
||||
var response = await rig.ReseedCpeAsync();
|
||||
|
||||
// Without a single mapping the inventory cannot be reconciled, but that is no reason to
|
||||
// lose the one thing the operator needs: why the list was turned down.
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.False(body.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("write the CPE as", Assert.Single(body.GetProperty("cpeProblems").EnumerateArray()).GetString());
|
||||
Assert.Contains("except for the CPE mappings", body.GetProperty("message").GetString());
|
||||
// No inventory row was looked at, so the answer carries no counts for them.
|
||||
Assert.False(body.TryGetProperty("cpeInventory", out _));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
Assert.Empty(rig.Tables.InventoryWrites);
|
||||
Assert.Single(RefusalsReported(rig));
|
||||
}
|
||||
|
||||
/// <summary>The lists reported as refused, each with its problems, one entry per report.</summary>
|
||||
private static List<(string List, IReadOnlyList<string> Problems)> RefusalsReported(Rig rig) => rig.OpsEvents.Invocations
|
||||
.Where(invocation => invocation.Method.Name == nameof(OpsEventService.RecordCpeMappingListRefusedAsync))
|
||||
.Select(invocation => ((string)invocation.Arguments[0]!, (IReadOnlyList<string>)invocation.Arguments[1]!))
|
||||
.ToList();
|
||||
|
||||
private static (int Deleted, int Written) Counts(System.Text.Json.JsonElement node) =>
|
||||
(node.GetProperty("deleted").GetInt32(), node.GetProperty("written").GetInt32());
|
||||
}
|
||||
@@ -68,7 +68,7 @@ public class RuleSubmissionServiceTests
|
||||
MarkSessionAsFailed = true,
|
||||
Notify = true,
|
||||
NotifyChannelIds = new List<string> { "chan-1" },
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
|
||||
Explanation = "The PAC file could not be fetched.",
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
@@ -89,7 +89,7 @@ public class RuleSubmissionServiceTests
|
||||
|
||||
Assert.Equal("ANALYZE-CUSTOM-003", node["ruleId"]!.GetValue<string>());
|
||||
Assert.Equal("Alice Admin", node["author"]!.GetValue<string>());
|
||||
Assert.Equal("ANALYZE-SEC-004", node["derivedFromTemplateRuleId"]!.GetValue<string>());
|
||||
Assert.Equal("ANALYZE-SEC-006", node["derivedFromTemplateRuleId"]!.GetValue<string>());
|
||||
Assert.Single(node["conditions"]!.AsArray());
|
||||
}
|
||||
|
||||
@@ -430,8 +430,8 @@ public class RuleSubmissionServiceTests
|
||||
Assert.Null(s.ContactEmail);
|
||||
Assert.Equal("Proxy PAC unreachable", s.Title);
|
||||
Assert.Equal("network", s.Category);
|
||||
Assert.Equal("ANALYZE-SEC-004", s.DerivedFromTemplateRuleId);
|
||||
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-004"));
|
||||
Assert.Equal("ANALYZE-SEC-006", s.DerivedFromTemplateRuleId);
|
||||
Assert.Contains(s.ValidationFindings, f => f.Level == "info" && f.Message.Contains("ANALYZE-SEC-006"));
|
||||
Assert.DoesNotContain(s.ValidationFindings, f => f.Level == "error");
|
||||
Assert.Equal(5, s.SourceFireStats!.FireCount);
|
||||
Assert.Equal(50, s.SourceFireStats.SessionsEvaluated);
|
||||
|
||||
@@ -27,14 +27,14 @@ public class RuleSubmissionsWireParityTests
|
||||
AttributionName = "Community contribution",
|
||||
SubmittedAt = T0,
|
||||
Status = "approved",
|
||||
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" } },
|
||||
ValidationFindings = new List<RuleSubmissionFinding> { new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" } },
|
||||
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
|
||||
ReviewedBy = "ga@example.com",
|
||||
ReviewedAt = T0.AddDays(1),
|
||||
ReviewComment = "Taken.",
|
||||
WillBeAdapted = true,
|
||||
PublishedRuleId = "ANALYZE-NET-002",
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
|
||||
};
|
||||
|
||||
private static object ItemLiteral() => new
|
||||
@@ -54,14 +54,14 @@ public class RuleSubmissionsWireParityTests
|
||||
attributionName = "Community contribution",
|
||||
submittedAt = T0,
|
||||
status = "approved",
|
||||
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-004" } },
|
||||
validationFindings = new List<object> { new { level = "info", message = "Derived from template ANALYZE-SEC-006" } },
|
||||
sourceFireStats = new { days = 30, fireCount = 42, sessionsEvaluated = 310, evaluationCount = 312 },
|
||||
reviewedBy = "ga@example.com",
|
||||
reviewedAt = T0.AddDays(1),
|
||||
reviewComment = "Taken.",
|
||||
willBeAdapted = true,
|
||||
publishedRuleId = "ANALYZE-NET-002",
|
||||
derivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
derivedFromTemplateRuleId = "ANALYZE-SEC-006",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST vulnerability/cpe-mapping saves an operator's mapping for one program, filed under its
|
||||
/// vendor and its name without version, architecture and language, and puts the CPE onto the
|
||||
/// program's inventory rows in the same call.
|
||||
/// </summary>
|
||||
public class SaveCustomCpeMappingFunctionTests
|
||||
{
|
||||
private const string Custom = CpeMappingStore.CustomPartition;
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
private Rig(MappingRig maps)
|
||||
{
|
||||
Maps = maps;
|
||||
Function = new SaveCustomCpeMappingFunction(
|
||||
maps.Scan.Repo.Object, maps.Reconciler, NullLogger<SaveCustomCpeMappingFunction>.Instance);
|
||||
}
|
||||
|
||||
public static async Task<Rig> CreateAsync() => new(await MappingRig.CreateAsync(
|
||||
VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", "cpe:2.3:a:fabrikam:gadget", "fabrikam gadget")));
|
||||
|
||||
public MappingRig Maps { get; }
|
||||
public MappingTables Tables => Maps.Tables;
|
||||
public SaveCustomCpeMappingFunction Function { get; }
|
||||
|
||||
public Task<HttpResponseData> SaveAsync(SaveCustomCpeMappingRequest request) => Function.Run(OperatorCall.Request(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_FilesTheMappingUnderVendorAndNameWithoutVersion_AndPutsTheCpeOnEveryVersionInTheInventory()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var named = rig.Tables.AddInventory("contoso", "Contoso Widget 4.2 (x64)", "4.2");
|
||||
var otherVersion = rig.Tables.AddInventory("contoso", "Contoso Widget 5.0", "5.0");
|
||||
var otherProgram = rig.Tables.AddInventory("contoso", "Contoso Widget Viewer", "1.0");
|
||||
|
||||
var response = await rig.SaveAsync(new SaveCustomCpeMappingRequest
|
||||
{
|
||||
NormalizedVendor = "Contoso",
|
||||
NormalizedProduct = "Contoso Widget 4.2 (x64)",
|
||||
CpeUri = "cpe:2.3:a:Contoso:Widget:4.2:*:*:*:*:*:*:*",
|
||||
PublisherPatterns = new List<string> { "contoso ltd" },
|
||||
// A pattern without a word could never match and would fail the next list check.
|
||||
ExcludePatterns = new List<string> { "widget viewer", "--" },
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.True(body.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("contoso:contoso widget", body.GetProperty("rowKey").GetString());
|
||||
Assert.Equal(2, body.GetProperty("inventoryRowsChanged").GetInt32());
|
||||
|
||||
var row = Assert.Single(rig.Tables.Rows(Custom));
|
||||
Assert.Equal("contoso:contoso widget", row["RowKey"]);
|
||||
Assert.Equal("contoso", row["NormalizedVendor"]);
|
||||
Assert.Equal("contoso widget", row["NormalizedProduct"]);
|
||||
Assert.Equal(WidgetCpe, row["CpeUri"]);
|
||||
Assert.Equal("contoso", row["CpeVendor"]);
|
||||
Assert.Equal("widget", row["CpeProduct"]);
|
||||
Assert.Equal("custom", row["Category"]);
|
||||
Assert.Equal("manual", row["Source"]);
|
||||
Assert.Equal("[]", row["DisplayNamePatternsJson"]);
|
||||
Assert.Equal("""["contoso ltd"]""", row["PublisherPatternsJson"]);
|
||||
Assert.Equal("""["widget viewer"]""", row["ExcludePatternsJson"]);
|
||||
Assert.Equal(OperatorCall.Upn, row["CreatedBy"]);
|
||||
|
||||
Assert.Equal(WidgetCpe, named["CpeUri"]);
|
||||
Assert.Equal(WidgetCpe, otherVersion["CpeUri"]);
|
||||
Assert.Equal(string.Empty, otherProgram["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_AgainForTheSameProgram_ReplacesItsMapping()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
var installed = rig.Tables.AddInventory("contoso", "Contoso Widget 4.2", "4.2");
|
||||
await rig.SaveAsync(new SaveCustomCpeMappingRequest { NormalizedVendor = "contoso", NormalizedProduct = "Contoso Widget 4.2", CpeUri = "cpe:2.3:a:contoso:widget_wrong" });
|
||||
|
||||
var response = await rig.SaveAsync(new SaveCustomCpeMappingRequest { NormalizedVendor = "contoso", NormalizedProduct = "contoso widget", CpeUri = WidgetCpe });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(WidgetCpe, Assert.Single(rig.Tables.Rows(Custom))["CpeUri"]);
|
||||
Assert.Equal(WidgetCpe, installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_CorrectionOfAMappingStoredUnderAnEarlierKey_TakesEffect()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
// Stored before programs were filed under their name without version: the key holds the
|
||||
// name as it was typed, and no vendor.
|
||||
rig.Tables.Put(Custom, ":contoso reader 2.0 (x64)", new Dictionary<string, object>
|
||||
{
|
||||
["NormalizedVendor"] = string.Empty,
|
||||
["NormalizedProduct"] = "Contoso Reader 2.0 (x64)",
|
||||
["CpeUri"] = "cpe:2.3:a:contoso:reader_wrong",
|
||||
["Source"] = "manual",
|
||||
});
|
||||
var installed = rig.Tables.AddInventory("", "Contoso Reader 2.0 (x64)", "2.0", cpeUri: "cpe:2.3:a:contoso:reader_wrong");
|
||||
|
||||
// The portal's edit sends the vendor and the name the list shows for the mapping.
|
||||
var response = await rig.SaveAsync(new SaveCustomCpeMappingRequest
|
||||
{
|
||||
NormalizedVendor = string.Empty,
|
||||
NormalizedProduct = "Contoso Reader 2.0 (x64)",
|
||||
CpeUri = "cpe:2.3:a:contoso:reader",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("cpe:2.3:a:contoso:reader", installed["CpeUri"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", WidgetCpe)]
|
||||
[InlineData("4.2 (x64)", WidgetCpe)] // no name is left once the version is gone
|
||||
[InlineData("Contoso Widget", "")]
|
||||
[InlineData("Contoso Widget", "cpe:2.3:a:contoso")]
|
||||
[InlineData("Contoso Widget", "cpe:2.3:a:contoso:*")]
|
||||
[InlineData("Contoso Widget", "contoso widget")]
|
||||
public async Task Save_WithoutAProgramNameOrACpeThatNamesAProduct_IsABadRequest(string product, string cpeUri)
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
|
||||
var response = await rig.SaveAsync(new SaveCustomCpeMappingRequest { NormalizedVendor = "contoso", NormalizedProduct = product, CpeUri = cpeUri });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.BadRequest, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_MappingsUnreadableAfterTheWrite_StillSavesAndLeavesTheRowCountOut()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Tables.ReadFailure = new InvalidOperationException("storage down");
|
||||
|
||||
var response = await rig.SaveAsync(new SaveCustomCpeMappingRequest { NormalizedVendor = "contoso", NormalizedProduct = "Contoso Widget", CpeUri = WidgetCpe });
|
||||
|
||||
// The mapping is stored; the nightly run brings the inventory rows in line.
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.False(OperatorCall.BodyOf(response).TryGetProperty("inventoryRowsChanged", out _));
|
||||
Assert.Equal(new[] { $"upsert {Custom}/contoso:contoso widget" }, rig.Tables.Writes);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Earlier rows of the same program
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>A mapping as it was stored before programs were filed under their name without version.</summary>
|
||||
private static Dictionary<string, object> Earlier(string vendor, string product, string cpeUri) => new()
|
||||
{
|
||||
["NormalizedVendor"] = vendor,
|
||||
["NormalizedProduct"] = product,
|
||||
["CpeUri"] = cpeUri,
|
||||
["Source"] = "manual",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Save_RemovesEarlierRowsOfTheSameProgram_AndNoOtherRow()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Tables.Put(Custom, "contoso:contoso reader 2.0 (x64)", Earlier("contoso", "Contoso Reader 2.0 (x64)", "cpe:2.3:a:contoso:reader_wrong"));
|
||||
rig.Tables.Put(Custom, "contoso:contoso reader 1.0", Earlier("Contoso", "Contoso Reader 1.0", "cpe:2.3:a:contoso:reader_older"));
|
||||
// Another program of the vendor, and the same name under another vendor.
|
||||
rig.Tables.Put(Custom, "contoso:contoso reader pro", Earlier("contoso", "Contoso Reader Pro", "cpe:2.3:a:contoso:reader_pro"));
|
||||
rig.Tables.Put(Custom, "fabrikam:contoso reader 2.0", Earlier("fabrikam", "Contoso Reader 2.0", "cpe:2.3:a:fabrikam:reader"));
|
||||
// The automatic mapping of the same program is not an operator's row.
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(CpeMappingStore.AutoPartition, "auto", "contoso", "contoso reader", "cpe:2.3:a:contoso:reader_auto"));
|
||||
|
||||
var response = await rig.SaveAsync(new SaveCustomCpeMappingRequest
|
||||
{
|
||||
NormalizedVendor = "contoso", NormalizedProduct = "Contoso Reader 3.0", CpeUri = "cpe:2.3:a:contoso:reader",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(
|
||||
new[] { "contoso:contoso reader", "contoso:contoso reader pro", "fabrikam:contoso reader 2.0" }, rig.Tables.RowKeys(Custom));
|
||||
Assert.Single(rig.Tables.Rows(CpeMappingStore.AutoPartition));
|
||||
// The mapping is stored before anything is taken away.
|
||||
Assert.Equal($"upsert {Custom}/contoso:contoso reader", rig.Tables.Writes[0]);
|
||||
Assert.Equal(
|
||||
new[] { $"delete {Custom}/contoso:contoso reader 1.0", $"delete {Custom}/contoso:contoso reader 2.0 (x64)" },
|
||||
rig.Tables.Writes.Skip(1).OrderBy(write => write, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_EarlierRowThatCannotBeRemoved_StillSavesTheMapping_AndTheNextSaveRemovesIt()
|
||||
{
|
||||
var rig = await Rig.CreateAsync();
|
||||
rig.Tables.Put(Custom, "contoso:contoso reader 2.0 (x64)", Earlier("contoso", "Contoso Reader 2.0 (x64)", "cpe:2.3:a:contoso:reader_wrong"));
|
||||
var request = new SaveCustomCpeMappingRequest
|
||||
{
|
||||
NormalizedVendor = "contoso", NormalizedProduct = "Contoso Reader 3.0", CpeUri = "cpe:2.3:a:contoso:reader",
|
||||
};
|
||||
rig.Tables.WriteFailure = write =>
|
||||
write.StartsWith("delete ", StringComparison.Ordinal) ? new InvalidOperationException("storage down") : null;
|
||||
|
||||
var first = await rig.SaveAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.True(OperatorCall.BodyOf(first).GetProperty("success").GetBoolean());
|
||||
Assert.Equal(new[] { "contoso:contoso reader", "contoso:contoso reader 2.0 (x64)" }, rig.Tables.RowKeys(Custom));
|
||||
Assert.Equal("cpe:2.3:a:contoso:reader", rig.Tables.Row(Custom, "contoso:contoso reader")!["CpeUri"]);
|
||||
|
||||
rig.Tables.WriteFailure = null;
|
||||
var second = await rig.SaveAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
Assert.Equal(new[] { "contoso:contoso reader" }, rig.Tables.RowKeys(Custom));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A write on one session acts in that session's tenant: the portal sends it as ?tenantId=, the
|
||||
/// middleware validates it (TenantScoping.QueryParam), and a Global Admin on a foreign tenant's session
|
||||
/// reaches that tenant. Before, mark-failed/-succeeded and actions took the caller's token tenant and
|
||||
/// answered 404 for a Global Admin on a foreign session.
|
||||
/// </summary>
|
||||
public class SessionWriteTenantScopeTests
|
||||
{
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string SessionId = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
/// <summary>
|
||||
/// Every route on one session takes the tenant from ?tenantId=. The only exception, the report,
|
||||
/// carries the session's tenant in its body and checks it in the function itself.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_session_route_is_query_param_scoped()
|
||||
{
|
||||
var exceptions = new HashSet<string> { "POST sessions/{sessionId}/report" };
|
||||
|
||||
var routes = EndpointAccessPolicyCatalog.Entries
|
||||
.Where(e => e.RouteTemplate.StartsWith("sessions/{sessionId}", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
Assert.NotEmpty(routes);
|
||||
|
||||
var unscoped = routes
|
||||
.Where(e => e.TenantScoping != TenantScoping.QueryParam)
|
||||
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
|
||||
.Where(r => !exceptions.Contains(r))
|
||||
.ToList();
|
||||
Assert.True(unscoped.Count == 0,
|
||||
"Session routes must take the session's tenant from ?tenantId= (TenantScoping.QueryParam):\n"
|
||||
+ string.Join("\n", unscoped.Select(r => $" - {r}")));
|
||||
|
||||
foreach (var exception in exceptions)
|
||||
Assert.Contains(routes, e => $"{e.HttpMethod} {e.RouteTemplate}" == exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("failed")]
|
||||
[InlineData("succeeded")]
|
||||
public async Task Mark_by_a_global_admin_acts_in_the_session_tenant(string outcome)
|
||||
{
|
||||
var repo = SessionRepo();
|
||||
var req = Request(GlobalAdmin());
|
||||
|
||||
await RunMark(outcome, repo, req);
|
||||
|
||||
Assert.Equal(OtherTenant, StatusUpdateTenant(repo));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("failed")]
|
||||
[InlineData("succeeded")]
|
||||
public async Task Mark_by_a_tenant_admin_stays_in_the_validated_tenant(string outcome)
|
||||
{
|
||||
var repo = SessionRepo();
|
||||
var req = Request(TenantAdmin());
|
||||
|
||||
await RunMark(outcome, repo, req);
|
||||
|
||||
Assert.Equal(HomeTenant, StatusUpdateTenant(repo));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Action_by_a_global_admin_is_queued_in_the_session_tenant()
|
||||
{
|
||||
var repo = SessionRepo();
|
||||
var req = Request(GlobalAdmin(), """{"type":"request_diagnostics"}""");
|
||||
|
||||
await ActionFunction(repo).Run(req, SessionId);
|
||||
|
||||
repo.Verify(r => r.QueueServerActionAsync(OtherTenant, SessionId, It.IsAny<ServerAction>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Action_by_an_operator_stays_in_the_validated_tenant()
|
||||
{
|
||||
var repo = SessionRepo();
|
||||
var operatorContext = new RequestContext
|
||||
{
|
||||
TenantId = HomeTenant,
|
||||
TargetTenantId = HomeTenant,
|
||||
UserPrincipalName = "operator@contoso.com",
|
||||
UserRole = Constants.TenantRoles.Operator,
|
||||
};
|
||||
var req = Request(operatorContext, """{"type":"request_diagnostics"}""");
|
||||
|
||||
await ActionFunction(repo).Run(req, SessionId);
|
||||
|
||||
repo.Verify(r => r.QueueServerActionAsync(HomeTenant, SessionId, It.IsAny<ServerAction>()), Times.Once);
|
||||
}
|
||||
|
||||
// The lookup always points at the other tenant, so only the caller's reach decides where the write lands.
|
||||
private static Mock<ISessionRepository> SessionRepo()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
|
||||
repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = OtherTenant });
|
||||
return repo;
|
||||
}
|
||||
|
||||
private static RequestContext GlobalAdmin() => new()
|
||||
{
|
||||
TenantId = HomeTenant,
|
||||
TargetTenantId = HomeTenant,
|
||||
UserPrincipalName = "ga@contoso.com",
|
||||
IsGlobalAdmin = true,
|
||||
UserRole = Constants.GlobalRoles.GlobalAdmin,
|
||||
};
|
||||
|
||||
private static RequestContext TenantAdmin() => new()
|
||||
{
|
||||
TenantId = HomeTenant,
|
||||
TargetTenantId = HomeTenant,
|
||||
UserPrincipalName = "admin@contoso.com",
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
};
|
||||
|
||||
private static HttpRequestData Request(RequestContext requestContext, string? body = null)
|
||||
{
|
||||
var (req, context) = EndpointHarness.Request(HomeTenant, jsonBody: body);
|
||||
context.Items["RequestContext"] = requestContext;
|
||||
return req;
|
||||
}
|
||||
|
||||
private static async Task RunMark(string outcome, Mock<ISessionRepository> repo, HttpRequestData req)
|
||||
{
|
||||
var maintenance = Mock.Of<IMaintenanceRepository>();
|
||||
if (outcome == "failed")
|
||||
await new MarkSessionFailedFunction(NullLogger<MarkSessionFailedFunction>.Instance, repo.Object, maintenance).Run(req, SessionId);
|
||||
else
|
||||
await new MarkSessionSucceededFunction(NullLogger<MarkSessionSucceededFunction>.Instance, repo.Object, maintenance).Run(req, SessionId);
|
||||
}
|
||||
|
||||
private static string? StatusUpdateTenant(Mock<ISessionRepository> repo)
|
||||
{
|
||||
var call = Assert.Single(repo.Invocations, i => i.Method.Name == nameof(ISessionRepository.UpdateSessionStatusAsync));
|
||||
return call.Arguments[0] as string;
|
||||
}
|
||||
|
||||
private static QueueSessionActionFunction ActionFunction(Mock<ISessionRepository> repo)
|
||||
{
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var opsEvents = new OpsEventService(
|
||||
Mock.Of<IOpsEventRepository>(), NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
return new QueueSessionActionFunction(
|
||||
NullLogger<QueueSessionActionFunction>.Instance, repo.Object,
|
||||
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }), opsEvents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Vulnerability;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST vulnerability/cpe-product-flags sets whether a product counts as self-updating and
|
||||
/// answers with the value in effect and the default the seed ships. A product no mapping points
|
||||
/// to is not found, and mappings that cannot be read change nothing.
|
||||
/// </summary>
|
||||
public class SetCpeProductFlagsFunctionTests
|
||||
{
|
||||
private const string Flags = CpeMappingStore.ProductFlagsPartition;
|
||||
private const string Widget = "contoso:widget";
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig()
|
||||
{
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping("contoso", "contoso widget", "cpe:2.3:a:contoso:widget", "contoso widget"));
|
||||
var store = new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance);
|
||||
Service = new CpeProductFlagsService(Tables.Repo.Object, store);
|
||||
Function = new SetCpeProductFlagsFunction(Service, NullLogger<SetCpeProductFlagsFunction>.Instance);
|
||||
}
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
public CpeProductFlagsService Service { get; }
|
||||
public SetCpeProductFlagsFunction Function { get; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Route_IsRegisteredForGlobalAdminsOnly()
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("POST", "vulnerability/cpe-product-flags");
|
||||
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_AwayFromTheDefault_StoresTheOperatorsValueUnderTheirName_AndAnswersWithBoth()
|
||||
{
|
||||
var rig = new Rig();
|
||||
await rig.Service.ApplySeedDefaultsAsync(new[] { Widget });
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.Request(
|
||||
new SetCpeProductFlagsRequest { Product = " Contoso:Widget ", SelfUpdating = false }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.True(body.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(Widget, body.GetProperty("product").GetString());
|
||||
Assert.False(body.GetProperty("selfUpdating").GetBoolean());
|
||||
Assert.True(body.GetProperty("selfUpdatingDefault").GetBoolean());
|
||||
|
||||
var row = rig.Tables.Row(Flags, Widget)!;
|
||||
Assert.Equal(false, row["AdminSelfUpdating"]);
|
||||
Assert.Equal(OperatorCall.Upn, row["UpdatedBy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_ForAProductTheSeedDoesNotList_AnswersWithADefaultOfFalse()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.Request(
|
||||
new SetCpeProductFlagsRequest { Product = Widget, SelfUpdating = true }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = OperatorCall.BodyOf(response);
|
||||
Assert.True(body.GetProperty("selfUpdating").GetBoolean());
|
||||
Assert.False(body.GetProperty("selfUpdatingDefault").GetBoolean());
|
||||
Assert.Equal(true, rig.Tables.Row(Flags, Widget)!["AdminSelfUpdating"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_ProductNoMappingPointsTo_IsNotFound()
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.Request(
|
||||
new SetCpeProductFlagsRequest { Product = "contoso:gadget", SelfUpdating = true }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.NotFound, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{ "selfUpdating": true }""")]
|
||||
[InlineData("""{ "product": " ", "selfUpdating": true }""")]
|
||||
[InlineData("""{ "product": "contoso:widget" }""")]
|
||||
[InlineData("""{ "product": "contoso:widget", "selfUpdating": null }""")]
|
||||
public async Task Post_WithoutProductOrValue_IsABadRequest(string json)
|
||||
{
|
||||
var rig = new Rig();
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.RequestWithJson(json));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.BadRequest, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_MappingsUnreadable_IsServiceUnavailable_AndChangesNothing()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.ReadFailure = new InvalidOperationException("storage down");
|
||||
|
||||
var response = await rig.Function.Run(OperatorCall.Request(
|
||||
new SetCpeProductFlagsRequest { Product = Widget, SelfUpdating = true }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ServiceUnavailable, EndpointHarness.ErrorCode(response));
|
||||
Assert.Equal(new[] { "60" }, response.Headers.GetValues("Retry-After"));
|
||||
Assert.Empty(rig.Tables.Writes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SoftwareInventoryCpeReconciler"/> brings the CPE on every inventory row in line with
|
||||
/// the mappings in service: it writes only the rows that differ, goes on when one write fails,
|
||||
/// notes products that have no CVE data yet, and touches nothing when the mappings cannot be read.
|
||||
/// </summary>
|
||||
public class SoftwareInventoryCpeReconcilerTests
|
||||
{
|
||||
private const string WidgetCpe = "cpe:2.3:a:contoso:widget";
|
||||
private const string GadgetCpe = "cpe:2.3:a:fabrikam:gadget";
|
||||
private const string OtherTenant = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
private static Task<MappingRig> RigAsync() => MappingRig.CreateAsync(
|
||||
VulnerabilityCacheRows.Mapping("contoso", "contoso widget", WidgetCpe, "contoso widget"),
|
||||
VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", GadgetCpe, "fabrikam gadget"));
|
||||
|
||||
private static void HasCveData(MappingRig rig, params string[] cpeUris)
|
||||
{
|
||||
foreach (var cpeUri in cpeUris)
|
||||
rig.Scan.Cache(VulnerabilityCacheRows.Filled(cpeUri, Array.Empty<CachedCve>(), DateTime.UtcNow.AddDays(-1)));
|
||||
}
|
||||
|
||||
private static string RowKeyOf(Dictionary<string, object> row) => (string)row["RowKey"];
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_SetsTheCpeWhereAMappingCovers_TakesItAwayWhereNoneDoes_AndWritesNoRowThatIsRight()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
HasCveData(rig, WidgetCpe, GadgetCpe);
|
||||
var toSet = rig.Tables.AddInventory("contoso", "Contoso Widget", "4.2");
|
||||
var alreadySet = rig.Tables.AddInventory("contoso", "Contoso Widget", "5.0", cpeUri: WidgetCpe);
|
||||
var toCorrect = rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget", "1.0", cpeUri: "cpe:2.3:a:fabrikam:gadget_old");
|
||||
var toClear = rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1", cpeUri: "cpe:2.3:a:northwind:tool");
|
||||
var neverMapped = rig.Tables.AddInventory("northwind", "Northwind Viewer", "1.0");
|
||||
// The same program in a second tenant is a row of its own.
|
||||
var inOtherTenant = rig.Tables.AddInventory("contoso", "Contoso Widget", "4.2", tenantId: OtherTenant);
|
||||
|
||||
var result = await rig.Reconciler.ReconcileAsync("test");
|
||||
|
||||
Assert.Equal(6, result.Rows);
|
||||
Assert.Equal(4, result.Mapped);
|
||||
Assert.Equal(2, result.Unmapped);
|
||||
Assert.Equal(3, result.Set);
|
||||
Assert.Equal(1, result.Cleared);
|
||||
Assert.Equal(0, result.Failed);
|
||||
Assert.Equal(0, result.ProductsNoted);
|
||||
|
||||
Assert.Equal(WidgetCpe, toSet["CpeUri"]);
|
||||
Assert.Equal(WidgetCpe, alreadySet["CpeUri"]);
|
||||
Assert.Equal(GadgetCpe, toCorrect["CpeUri"]);
|
||||
Assert.Equal(string.Empty, toClear["CpeUri"]);
|
||||
Assert.Equal(string.Empty, neverMapped["CpeUri"]);
|
||||
Assert.Equal(WidgetCpe, inOtherTenant["CpeUri"]);
|
||||
|
||||
// The writes run side by side, so their order is not fixed.
|
||||
Assert.Equal(
|
||||
new[] { RowKeyOf(toSet), RowKeyOf(inOtherTenant), RowKeyOf(toCorrect), RowKeyOf(toClear) }.OrderBy(k => k, StringComparer.Ordinal),
|
||||
rig.Tables.InventoryWrites.OrderBy(k => k, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_ReadsTheMappingsFresh_NotFromTheCopyThisInstanceHolds()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
HasCveData(rig, "cpe:2.3:a:northwind:tool");
|
||||
await rig.Store.GetAsync();
|
||||
// Stored after this instance read the mappings, as the save of an operator's mapping is.
|
||||
rig.Tables.Put(VulnerabilityCacheRows.MappingIn(
|
||||
CpeMappingStore.CustomPartition, "manual", "northwind", "northwind tool", "cpe:2.3:a:northwind:tool"));
|
||||
var row = rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1");
|
||||
|
||||
var result = await rig.Reconciler.ReconcileAsync("mapping_saved");
|
||||
|
||||
Assert.Equal(1, result.Set);
|
||||
Assert.Equal("cpe:2.3:a:northwind:tool", row["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_RowDeletedSinceItWasRead_IsNeitherSetNorFailed()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
HasCveData(rig, WidgetCpe, GadgetCpe);
|
||||
var deleted = rig.Tables.AddInventory("contoso", "Contoso Widget", "4.2");
|
||||
var staying = rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget", "1.0");
|
||||
rig.Tables.AfterInventoryRead = () => rig.Tables.RemoveInventory(deleted);
|
||||
|
||||
var result = await rig.Reconciler.ReconcileAsync("test");
|
||||
|
||||
Assert.Equal(2, result.Rows);
|
||||
Assert.Equal(2, result.Mapped);
|
||||
Assert.Equal(1, result.Set);
|
||||
Assert.Equal(0, result.Cleared);
|
||||
Assert.Equal(0, result.Failed);
|
||||
Assert.Equal(new[] { RowKeyOf(staying) }, rig.Tables.InventoryWrites);
|
||||
Assert.Equal(GadgetCpe, staying["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_WriteThatThrows_CountsAsFailed_AndTheOtherRowsAreStillWritten()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
HasCveData(rig, WidgetCpe, GadgetCpe);
|
||||
var failing = rig.Tables.AddInventory("contoso", "Contoso Widget", "4.2");
|
||||
var set = new[] { "1.0", "1.1", "2.0" }.Select(version => rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget", version)).ToList();
|
||||
var cleared = rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1", cpeUri: "cpe:2.3:a:northwind:tool");
|
||||
rig.Tables.InventoryWriteFailure = rowKey => rowKey == RowKeyOf(failing) ? new RequestFailedException(503, "busy") : null;
|
||||
|
||||
var result = await rig.Reconciler.ReconcileAsync("test");
|
||||
|
||||
Assert.Equal(1, result.Failed);
|
||||
Assert.Equal(3, result.Set);
|
||||
Assert.Equal(1, result.Cleared);
|
||||
Assert.Equal(string.Empty, failing["CpeUri"]);
|
||||
Assert.All(set, row => Assert.Equal(GadgetCpe, row["CpeUri"]));
|
||||
Assert.Equal(string.Empty, cleared["CpeUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_ProductInUseWithoutCveData_IsNotedForTheRefresh_OnceAndOnlyWhileItHasNone()
|
||||
{
|
||||
var rig = await MappingRig.CreateAsync(
|
||||
VulnerabilityCacheRows.Mapping("contoso", "contoso widget", WidgetCpe, "contoso widget"),
|
||||
VulnerabilityCacheRows.Mapping("fabrikam", "fabrikam gadget", GadgetCpe, "fabrikam gadget"),
|
||||
VulnerabilityCacheRows.Mapping("adatum", "adatum reader", "cpe:2.3:a:adatum:reader", "adatum reader"));
|
||||
HasCveData(rig, GadgetCpe);
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget", "4.2");
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget", "5.0", cpeUri: WidgetCpe);
|
||||
rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget", "1.0");
|
||||
|
||||
var first = await rig.Reconciler.ReconcileAsync("test");
|
||||
var second = await rig.Reconciler.ReconcileAsync("test");
|
||||
|
||||
// The widget is in use and has no data; the gadget has data; nobody has the reader installed.
|
||||
Assert.Equal(1, first.ProductsNoted);
|
||||
var marker = Assert.Single(rig.Scan.PendingMarkers);
|
||||
Assert.Equal(WidgetCpe, marker["CpeUri"]);
|
||||
Assert.Equal(CveCacheFiller.StatePending, marker["State"]);
|
||||
Assert.Equal(CveCacheFiller.StatePending, rig.Scan.CacheRows[CveCacheFiller.CacheKey(WidgetCpe)]["State"]);
|
||||
Assert.False(rig.Scan.CacheRows.ContainsKey(CveCacheFiller.CacheKey("cpe:2.3:a:adatum:reader")));
|
||||
|
||||
// The marker is a cache row: the product is not noted again.
|
||||
Assert.Equal(0, second.ProductsNoted);
|
||||
Assert.Single(rig.Scan.PendingMarkers);
|
||||
Assert.Empty(rig.Scan.Nvd.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconcile_MappingsUnreadable_IsUnavailable_AndNoRowIsWritten()
|
||||
{
|
||||
var rig = await RigAsync();
|
||||
var mapped = rig.Tables.AddInventory("contoso", "Contoso Widget", "4.2");
|
||||
var withCpe = rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1", cpeUri: "cpe:2.3:a:northwind:tool");
|
||||
rig.Tables.ReadFailure = new InvalidOperationException("storage down");
|
||||
|
||||
await Assert.ThrowsAsync<CpeMappingUnavailableException>(() => rig.Reconciler.ReconcileAsync("test"));
|
||||
|
||||
// Without mappings every row would read as unmapped and lose its CPE.
|
||||
Assert.Empty(rig.Tables.InventoryWrites);
|
||||
Assert.Equal(string.Empty, mapped["CpeUri"]);
|
||||
Assert.Equal("cpe:2.3:a:northwind:tool", withCpe["CpeUri"]);
|
||||
Assert.Empty(rig.Scan.PendingMarkers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical name is the key for manual mappings, the ignore list and the admin's unmatched
|
||||
/// list. Every version, architecture and language of one product must land on one key, and two
|
||||
/// products must not.
|
||||
/// </summary>
|
||||
public class SoftwareNameCanonicalizerTests
|
||||
{
|
||||
public static IEnumerable<object[]> Names() => new[]
|
||||
{
|
||||
// versions, architectures, editions in parentheses
|
||||
new object[] { "7-Zip 24.08 (x64)", "7-zip" },
|
||||
new object[] { "7-Zip 26.03 (x64 edition)", "7-zip" },
|
||||
new object[] { "7-Zip", "7-zip" },
|
||||
new object[] { "Zoom Workplace (64-bit)", "zoom workplace" },
|
||||
new object[] { "Python 3.12.1 (64-bit)", "python" },
|
||||
new object[] { "WinRAR 7.01 (64-bit)", "winrar" },
|
||||
new object[] { "Notepad++ (64-bit x64)", "notepad++" },
|
||||
new object[] { "Foo Tool v2.5", "foo tool" },
|
||||
new object[] { "Foo Tool version 2.5-beta", "foo tool" },
|
||||
|
||||
// language tags
|
||||
new object[] { "Mozilla Firefox (x64 de)", "mozilla firefox" },
|
||||
new object[] { "Mozilla Firefox (x64 en-US)", "mozilla firefox" },
|
||||
new object[] { "Mozilla Firefox ESR (x64 en-US)", "mozilla firefox esr" },
|
||||
new object[] { "Microsoft 365 Apps for enterprise - de-de", "microsoft 365 apps for enterprise" },
|
||||
new object[] { "Microsoft OneNote - fr-fr", "microsoft onenote" },
|
||||
|
||||
// marks and separators
|
||||
new object[] { "Intel(R) Wireless Bluetooth(R)", "intel wireless bluetooth" },
|
||||
new object[] { "Java(TM) SE Development Kit 17.0.9 (64-bit)", "java se development kit" },
|
||||
new object[] { "HP Wolf Security - Console", "hp wolf security console" },
|
||||
new object[] { "Google Chrome", "google chrome" },
|
||||
|
||||
// counters that change with every patch or build
|
||||
new object[] { "Java 8 Update 401 (64-bit)", "java 8" },
|
||||
new object[] { "Update for Windows 10 for x64-based Systems (KB5001716)", "update for windows 10 for x64-based systems" },
|
||||
new object[] { "Citrix Workspace 2402", "citrix workspace" },
|
||||
new object[] { "Microsoft .NET Runtime - 8.0.11 (x64)", "microsoft .net runtime" },
|
||||
new object[] { "Contoso Agent {5A3B9C10-1D2E-4F60-8A7B-0C9D8E7F6A5B}", "contoso agent" },
|
||||
new object[] { "Windows Driver Package - Contoso (ctsdrv) System (06/12/2020 2028.15.0.1688)", "windows driver package contoso ctsdrv system" },
|
||||
|
||||
// a year is part of the name, a release range too
|
||||
new object[] { "Microsoft Visual Studio Professional 2022", "microsoft visual studio professional 2022" },
|
||||
new object[] { "Microsoft Office Professional Plus 2016", "microsoft office professional plus 2016" },
|
||||
new object[] { "Microsoft Visual C++ 2015-2022 Redistributable (x64) - 14.38.33135", "microsoft visual c++ 2015-2022 redistributable" },
|
||||
|
||||
// a small number is part of the name
|
||||
new object[] { "Office 16 Click-to-Run Licensing Component", "office 16 click-to-run licensing component" },
|
||||
new object[] { "Node.js", "node.js" },
|
||||
|
||||
new object[] { "", "" },
|
||||
new object[] { " ", "" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public void Canonicalize_KeepsOnlyWhatNamesTheProduct(string displayName, string expected)
|
||||
=> Assert.Equal(expected, SoftwareNameCanonicalizer.Canonicalize(displayName));
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_Null_IsEmpty()
|
||||
=> Assert.Equal(string.Empty, SoftwareNameCanonicalizer.Canonicalize(null));
|
||||
|
||||
/// <summary>Stored keys are canonicalized again when loaded, so a second pass must not move them.</summary>
|
||||
[Fact]
|
||||
public void Canonicalize_IsStableOnItsOwnOutput()
|
||||
{
|
||||
foreach (var name in Names().Select(row => (string)row[0]))
|
||||
{
|
||||
var once = SoftwareNameCanonicalizer.Canonicalize(name);
|
||||
Assert.Equal(once, SoftwareNameCanonicalizer.Canonicalize(once));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_KeepsDifferentProductsApart()
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
"Microsoft Visual Studio Professional 2019", "Microsoft Visual Studio Professional 2022",
|
||||
"Microsoft Visual Studio Code", "Mozilla Firefox", "Mozilla Firefox ESR",
|
||||
"Microsoft Edge", "Microsoft Edge Update", "Microsoft Edge WebView2 Runtime",
|
||||
}.Select(SoftwareNameCanonicalizer.Canonicalize).ToList();
|
||||
|
||||
Assert.Equal(keys.Count, keys.Distinct().Count());
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public class TableRuleSubmissionRepositoryTests
|
||||
Status = RuleSubmissionStatuses.Approved,
|
||||
ValidationFindings = new List<RuleSubmissionFinding>
|
||||
{
|
||||
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-004" },
|
||||
new() { Level = "info", Message = "Derived from template ANALYZE-SEC-006" },
|
||||
},
|
||||
SourceFireStats = new RuleSubmissionFireStats { Days = 30, FireCount = 42, SessionsEvaluated = 310, EvaluationCount = 312 },
|
||||
ReviewedBy = "ga@example.com",
|
||||
@@ -41,7 +41,7 @@ public class TableRuleSubmissionRepositoryTests
|
||||
ReviewComment = "Taken, generalised for the community.",
|
||||
WillBeAdapted = true,
|
||||
PublishedRuleId = "ANALYZE-NET-002",
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-004",
|
||||
DerivedFromTemplateRuleId = "ANALYZE-SEC-006",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -140,6 +140,48 @@ public class TableStorageInventoryCasTests
|
||||
harness.SoftwareInventory.Verify(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CPE on an existing row follows the matching: a computed value replaces it, the empty
|
||||
/// "no mapping" clears it, and null (matching did not run) leaves it alone.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("cpe:2.3:a:contoso:widget_pro", "cpe:2.3:a:contoso:widget_pro")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "cpe:2.3:a:contoso:widget")]
|
||||
public async Task Increment_writes_the_computed_cpe_onto_an_existing_row(string? computedCpe, string expectedOnRow)
|
||||
{
|
||||
var existing = new TableEntity(TenantId, "contoso:widget:1.0")
|
||||
{
|
||||
["NormalizedName"] = "widget",
|
||||
["SessionCount"] = 3,
|
||||
["CpeUri"] = "cpe:2.3:a:contoso:widget",
|
||||
};
|
||||
existing.ETag = new ETag("0xABC");
|
||||
|
||||
var harness = new Harness();
|
||||
harness.SoftwareInventory.Setup(t =>
|
||||
t.GetEntityAsync<TableEntity>(TenantId, It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(existing, new Mock<Response>().Object));
|
||||
|
||||
TableEntity? written = null;
|
||||
harness.SoftwareInventory.Setup(t => t.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
{
|
||||
written = e;
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var item = new SoftwareInventoryItem
|
||||
{
|
||||
NormalizedVendor = "contoso", NormalizedName = "widget", NormalizedVersion = "1.0", CpeUri = computedCpe,
|
||||
};
|
||||
await harness.Sut.IncrementSoftwareInventoryEntryAsync(TenantId, item, SessionId);
|
||||
|
||||
Assert.Equal(expectedOnRow, written!.GetString("CpeUri"));
|
||||
Assert.Equal(4, written.GetInt32("SessionCount"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Increment_retries_on_412_etag_conflict_and_succeeds_on_second_attempt()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A tenant configuration is cached per backend instance, and a write clears only the cache of the
|
||||
/// instance that served it. These tests model two instances (two services, each with its own cache)
|
||||
/// over one stored row and pin the two ways the stored row reaches the other instance without
|
||||
/// shortening the cache: <see cref="TenantConfigurationService.RefreshFromStoreAsync"/> for a request
|
||||
/// that follows its caller's own write, and <see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>
|
||||
/// before a refusal.
|
||||
/// </summary>
|
||||
public sealed class TenantConfigReadAfterWriteTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTimeOffset Start = new(2026, 11, 11, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed record Instances(
|
||||
TenantConfigurationService A, TenantConfigurationService B, StoredTenantConfig Store, Mock<IConfigRepository> Repo, TestTimeProvider Clock);
|
||||
|
||||
private static Instances TwoInstances(Action<TenantConfiguration>? seed = null)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
seed?.Invoke(row);
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(row);
|
||||
var clock = new TestTimeProvider(Start);
|
||||
TenantConfigurationService Instance() => new(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()),
|
||||
ManagedTenantProIndex.None, clock);
|
||||
return new Instances(Instance(), Instance(), store, repo, clock);
|
||||
}
|
||||
|
||||
private static Task<TenantConfigUpdate> AllowOnly(TenantConfigurationService instance, string manufacturers)
|
||||
=> instance.UpdateAsync(TenantId, row =>
|
||||
{
|
||||
row.ManufacturerWhitelist = manufacturers;
|
||||
return true;
|
||||
}, "test", "hardware");
|
||||
|
||||
// ---------------------------------------------------------------- the hazard itself
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_through_one_instance_leaves_the_other_instances_cached_copy_untouched()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId); // B holds a copy from before the write
|
||||
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
|
||||
Assert.Equal("Dell*", (await s.A.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
// This is the behaviour the two reads below exist for; it changes only with the cache itself.
|
||||
Assert.NotEqual("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RefreshFromStoreAsync
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_puts_the_stored_row_into_the_instance_cache_for_every_later_read()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
|
||||
await s.B.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Equal("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
Assert.Equal("Dell*", (await s.B.GetConfigurationIfExistsAsync(TenantId))!.ManufacturerWhitelist);
|
||||
var (viaGate, exists) = await s.B.TryGetConfigurationAsync(TenantId);
|
||||
Assert.True(exists);
|
||||
Assert.Equal("Dell*", viaGate.ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_refresh_keeps_the_cached_copy_and_does_not_fail_the_request()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
var before = (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist;
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
|
||||
await s.B.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Equal(before, (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_of_a_tenant_without_a_row_creates_nothing()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await service.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Null(store.Row);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ConfirmBeforeRefusalAsync
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_returns_the_stored_row_and_refreshes_the_cache()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
await AllowOnly(s.A, "Dell*,Contoso");
|
||||
|
||||
var stored = await s.B.ConfirmBeforeRefusalAsync(TenantId);
|
||||
|
||||
Assert.Equal("Dell*,Contoso", stored!.ManufacturerWhitelist);
|
||||
Assert.Equal("Dell*,Contoso", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_reads_storage_once_per_interval_per_tenant()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
s.Repo.Invocations.Clear();
|
||||
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval - TimeSpan.FromSeconds(1));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
|
||||
|
||||
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval);
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_counts_each_tenant_on_its_own_and_ignores_the_case_of_the_id()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId.ToUpperInvariant()));
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync("22222222-2222-2222-2222-222222222222"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_answers_null_when_the_read_fails_or_no_row_exists_so_the_refusal_stands()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
Assert.Null(await service.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await service.ConfirmBeforeRefusalAsync(string.Empty));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the bootstrap feature switch
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_sees_a_plan_change_another_instance_wrote()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId); // B: still the free plan
|
||||
Assert.False(TenantEntitlementService.IsBootstrapEnabled((await s.B.GetConfigurationAsync(TenantId)), DateTime.UtcNow));
|
||||
|
||||
await s.A.UpdateAsync(TenantId, row =>
|
||||
{
|
||||
row.BootstrapTokenEnabled = true;
|
||||
return true;
|
||||
}, "test", "bootstrap");
|
||||
|
||||
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_reads_nothing_extra_while_the_cached_copy_says_yes()
|
||||
{
|
||||
var s = TwoInstances(row => row.BootstrapTokenEnabled = true);
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
s.Repo.Invocations.Clear();
|
||||
|
||||
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_refuses_a_disabled_feature_and_an_unknown_tenant()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(service, TenantId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet for the portal's "I have just written the configuration" hint. After a successful write
|
||||
/// under <c>/api/config/</c> (or one of the listed routes) the portal asks for the stored tenant
|
||||
/// configuration for a few minutes, because a write clears only the cache of the instance that
|
||||
/// served it (web <c>lib/storedConfigHint.ts</c>, <c>writesTenantConfig</c>). A function that writes
|
||||
/// the tenant row from a route the portal does not recognise brings the outdated reads back for
|
||||
/// that route — so every such function lives under <c>Functions/Config</c> with routes below
|
||||
/// <c>config/</c>, or is listed here with the reason it needs no hint.
|
||||
/// </summary>
|
||||
public class TenantConfigWriteRouteGuardTests
|
||||
{
|
||||
private static readonly Regex TenantConfigWrite = new(
|
||||
@"_(?:tenantConfigService|configService|tenantConfigurationService)\s*\.\s*(?:UpdateAsync|CreateOrUpdateAsync)\("
|
||||
+ @"|_patchService\s*\.\s*(?:ApplyFieldPatchAsync|RevertAsync)\(",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex WriteRoute = new(
|
||||
@"HttpTrigger\([^)]*?""(?:post|put|patch|delete)""[^)]*?Route\s*=\s*""(?<route>[^""]+)""",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
/// <summary>Writers outside <c>Functions/Config</c>: file → (why, the portal route the web must list, if any).</summary>
|
||||
private static readonly IReadOnlyDictionary<string, (string Why, string? PortalRoute)> Baseline =
|
||||
new Dictionary<string, (string, string?)>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Infrastructure/AuthFunction.cs"] =
|
||||
("the terms confirmation is on the portal's list; the sign-in writes of auth/me answer with what they wrote", "/api/auth/dpa-acceptance"),
|
||||
["Functions/Admin/TenantOffboardFunction.cs"] =
|
||||
("writes the offboarding tombstone; the tenant's members are locked out from then on", null),
|
||||
};
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_function_that_writes_the_tenant_row_uses_a_route_the_portal_recognises()
|
||||
{
|
||||
var functionsRoot = Path.Combine(RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var portalList = File.ReadAllText(Path.Combine(
|
||||
RepoRoot(), "src", "Web", "autopilot-monitor-web", "lib", "storedConfigHint.ts"));
|
||||
Assert.Contains("\"/api/config/\"", portalList);
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var offenders = new List<string>();
|
||||
foreach (var path in Directory.EnumerateFiles(Path.Combine(functionsRoot, "Functions"), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var source = File.ReadAllText(path);
|
||||
if (!TenantConfigWrite.IsMatch(source))
|
||||
continue;
|
||||
var rel = Path.GetRelativePath(functionsRoot, path).Replace('\\', '/');
|
||||
seen.Add(rel);
|
||||
|
||||
if (Baseline.TryGetValue(rel, out var entry))
|
||||
{
|
||||
if (entry.PortalRoute != null && !portalList.Contains($"\"{entry.PortalRoute}\"", StringComparison.Ordinal))
|
||||
offenders.Add($"{rel}: the portal no longer lists {entry.PortalRoute}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rel.StartsWith("Functions/Config/", StringComparison.Ordinal))
|
||||
{
|
||||
offenders.Add($"{rel}: writes the tenant row outside Functions/Config");
|
||||
continue;
|
||||
}
|
||||
foreach (Match route in WriteRoute.Matches(source))
|
||||
{
|
||||
var value = route.Groups["route"].Value;
|
||||
if (!value.StartsWith("config/", StringComparison.Ordinal) && !value.StartsWith("global/config", StringComparison.Ordinal))
|
||||
offenders.Add($"{rel}: write route '{value}' is not under config/");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"A tenant-configuration write the portal's hint does not cover — move the route under config/, or add the file to the "
|
||||
+ "baseline with its reason and, for a portal route, extend writesTenantConfig in lib/storedConfigHint.ts:\n "
|
||||
+ string.Join("\n ", offenders));
|
||||
|
||||
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries that no longer write the tenant row — remove them:\n " + string.Join("\n ", stale));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
[
|
||||
{"name": "Microsoft Teams Meeting Add-in for Microsoft Office", "publisher": "Microsoft"},
|
||||
{"name": "Chrome Remote Desktop Host", "publisher": "Google LLC"},
|
||||
{"name": "Google Chrome", "publisher": "Google LLC", "product": "google:chrome"},
|
||||
{"name": "Microsoft Edge Update", "publisher": "", "product": "microsoft:edge_update"},
|
||||
{"name": "Microsoft Edge WebView2 Runtime", "publisher": "", "product": "microsoft:edge_chromium"},
|
||||
{"name": "Mozilla Firefox ESR (x64 en-US)", "publisher": "Mozilla", "product": "mozilla:firefox", "edition": "esr"},
|
||||
{"name": "Mozilla Firefox (x64 de)", "publisher": "Mozilla", "product": "mozilla:firefox", "edition": ""},
|
||||
{"name": "Mozilla Maintenance Service", "publisher": "Mozilla"},
|
||||
{"name": "Adobe Acrobat Reader DC - Deutsch", "publisher": "Adobe Systems Incorporated", "product": "adobe:acrobat_reader_dc"},
|
||||
{"name": "Extended Asian Language font pack for Adobe Acrobat Reader DC", "publisher": "Adobe Systems Incorporated"},
|
||||
{"name": "Adobe Acrobat (64-bit)", "publisher": "Adobe", "product": "adobe:acrobat_dc"},
|
||||
{"name": "Microsoft 365 Copilot", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft Dynamics 365 Business Central", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft Office 365 ProPlus - en-us", "publisher": "Microsoft Corporation", "product": "microsoft:365_apps"},
|
||||
{"name": "Microsoft 365 Apps for enterprise - en-us", "publisher": "Microsoft Corporation", "product": "microsoft:365_apps"},
|
||||
{"name": "Kurumlar için Microsoft 365 Uygulamaları - tr-tr", "publisher": "", "product": "microsoft:365_apps"},
|
||||
{"name": "Microsoft 365-apps voor ondernemingen - nl-nl", "publisher": "", "product": "microsoft:365_apps"},
|
||||
{"name": "Microsoft Office Professional Plus 2019 - en-us", "publisher": "Microsoft Corporation", "product": "microsoft:office"},
|
||||
{"name": "Microsoft Office LTSC Professional Plus 2024 - de-de", "publisher": "Microsoft Corporation", "product": "microsoft:office_2024"},
|
||||
{"name": "Microsoft Office Hub", "publisher": "Microsoft"},
|
||||
{"name": "Microsoft Visio Viewer 2016", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft Project - en-us", "publisher": "Microsoft Corporation", "product": "microsoft:project"},
|
||||
{"name": "Microsoft OneNote - fr-fr", "publisher": "", "product": "microsoft:onenote"},
|
||||
{"name": "Teams Machine-Wide Installer", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft Teams", "publisher": "MicrosoftTeams", "product": "microsoft:teams"},
|
||||
{"name": "MSTeams", "publisher": "MSTeams", "product": "microsoft:teams"},
|
||||
{"name": "Microsoft Teams Rooms", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Zoom Rooms", "publisher": "Zoom Video Communications, Inc."},
|
||||
{"name": "Zoom", "publisher": "Zoom Video Communications, Inc.", "product": "zoom:zoom"},
|
||||
{"name": "Zoom Workplace (64-bit)", "publisher": "Zoom", "product": "zoom:workplace_desktop"},
|
||||
{"name": "Zoom Outlook Plugin", "publisher": "Zoom"},
|
||||
{"name": "TeamViewer Patch Management", "publisher": "TeamViewer"},
|
||||
{"name": "TeamViewer Host", "publisher": "TeamViewer", "product": "teamviewer:teamviewer"},
|
||||
{"name": "Git Extensions 4.2.1", "publisher": "Git Extensions Team"},
|
||||
{"name": "Git", "publisher": "The Git Development Community", "product": "git-scm:git"},
|
||||
{"name": "Git LFS", "publisher": "GitHub, Inc.", "product": "git_large_file_storage_project:git_large_file_storage"},
|
||||
{"name": "PowerShell 7-x64", "publisher": "Microsoft Corporation", "product": "microsoft:powershell"},
|
||||
{"name": "Microsoft Azure PowerShell - April 2018", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Python 3.12.4 (64-bit)", "publisher": "Python Software Foundation", "product": "python:python"},
|
||||
{"name": "Python Launcher", "publisher": "Python Software Foundation"},
|
||||
{"name": "Anaconda3 2024.10-1 (Python 3.12.7 64-bit)", "publisher": "Anaconda, Inc.", "product": "anaconda:anaconda3"},
|
||||
{"name": "Miniconda3 py312_24.5.0-0 (Python 3.12.4 64-bit)", "publisher": "Anaconda, Inc."},
|
||||
{"name": "Microsoft SQL Server 2019 (64-bit)", "publisher": "Microsoft Corporation", "product": "microsoft:sql_server"},
|
||||
{"name": "Microsoft SQL Server Management Studio - 20.2", "publisher": "Microsoft Corporation", "product": "microsoft:sql_server_management_studio"},
|
||||
{"name": "Microsoft SQL Server 2012 Native Client", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft ODBC Driver 18 for SQL Server", "publisher": "Microsoft Corporation", "product": "microsoft:odbc_driver_for_sql_server"},
|
||||
{"name": "Microsoft System CLR Types for SQL Server 2019", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Remote Desktop", "publisher": "Microsoft Corporation", "product": "microsoft:remote_desktop"},
|
||||
{"name": "Remote Desktop Services Infrastructure Agent", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Remote Desktop Manager", "publisher": "Devolutions inc.", "product": "devolutions:remote_desktop_manager"},
|
||||
{"name": "Remote Desktop Connection Manager", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Remote Help", "publisher": "Microsoft Corporation", "product": "microsoft:remote_help"},
|
||||
{"name": "Rustup: the Rust toolchain installer", "publisher": "The Rust Project Developers"},
|
||||
{"name": "Rust 1.80 (MSVC 64-bit)", "publisher": "The Rust Project Developers", "product": "rust-lang:rust"},
|
||||
{"name": "Lenovo Vantage Service", "publisher": "Lenovo Group Ltd."},
|
||||
{"name": "NVIDIA GeForce Experience 3.28.0.417", "publisher": "NVIDIA Corporation", "product": "nvidia:geforce_experience"},
|
||||
{"name": "NVIDIA GeForce NOW", "publisher": "NVIDIA Corporation"},
|
||||
{"name": "NVIDIA App 11.0.5.420", "publisher": "NVIDIA Corporation"},
|
||||
{"name": "NVIDIA Grafiktreiber 573.44", "publisher": "NVIDIA Corporation", "product": "nvidia:gpu_display_driver"},
|
||||
{"name": "Dell SupportAssist for Business PCs", "publisher": "Dell Inc.", "product": "dell:supportassist_for_business_pcs"},
|
||||
{"name": "Dell SupportAssist", "publisher": "Dell Inc.", "product": "dell:supportassist_for_home_pcs"},
|
||||
{"name": "Dell SupportAssist Remediation", "publisher": "Dell Inc."},
|
||||
{"name": "Cisco Webex Meetings", "publisher": "Cisco Webex LLC", "product": "cisco:webex_meetings"},
|
||||
{"name": "Webex", "publisher": "Cisco Systems, Inc", "product": "cisco:webex_teams"},
|
||||
{"name": "OpenVPN Connect", "publisher": "OpenVPN Inc.", "product": "openvpn:connect"},
|
||||
{"name": "OpenVPN 2.6.12-I001 amd64", "publisher": "OpenVPN, Inc.", "product": "openvpn:openvpn"},
|
||||
{"name": "Windows Media Encoder 9 Series", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Intel(R) Management Engine Components", "publisher": "Intel Corporation"},
|
||||
{"name": "Intel® Graphics Software", "publisher": "Intel Corporation"},
|
||||
{"name": "Visual Studio Community 2022", "publisher": "Microsoft Corporation", "product": "microsoft:visual_studio_2022"},
|
||||
{"name": "Microsoft Visual Studio Installer", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft Visual Studio Code (User)", "publisher": "Microsoft Corporation", "product": "microsoft:visual_studio_code"},
|
||||
{"name": "Microsoft Visual Studio 2010 Tools for Office Runtime (x64)", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft Access database engine 2016 (English)", "publisher": "Microsoft Corporation", "product": "microsoft:access"},
|
||||
{"name": "Microsoft Access Runtime 2016", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft Access 2016 - de-de", "publisher": "Microsoft Corporation", "product": "microsoft:access"},
|
||||
{"name": "Java 8 Update 431 (64-bit)", "publisher": "Oracle Corporation", "product": "oracle:jre"},
|
||||
{"name": "Java(TM) SE Development Kit 21.0.5 (64-bit)", "publisher": "Oracle Corporation", "product": "oracle:jdk"},
|
||||
{"name": "Java Auto Updater", "publisher": "Oracle Corporation"},
|
||||
{"name": "Eclipse Temurin JDK with Hotspot 17.0.13+11 (x64)", "publisher": "Eclipse Adoptium", "product": "oracle:openjdk"},
|
||||
{"name": "Microsoft Build of OpenJDK 21.0.5+11 (x64)", "publisher": "Microsoft"},
|
||||
{"name": "Azul Zulu JDK 17.54.21 (17.0.13), 64-bit", "publisher": "Azul Systems, Inc.", "product": "azul:zulu"},
|
||||
{"name": "KeePassXC", "publisher": "KeePassXC Team", "product": "keepassxc:keepassxc"},
|
||||
{"name": "KeePass Password Safe 2.57", "publisher": "Dominik Reichl", "product": "keepass:keepass"},
|
||||
{"name": "FortiClient EMS", "publisher": "Fortinet Technologies Inc"},
|
||||
{"name": "Citrix Workspace Environment Management Agent", "publisher": "Citrix Systems, Inc."},
|
||||
{"name": "Splashtop Streamer", "publisher": "Splashtop Inc.", "product": "splashtop:streamer"},
|
||||
{"name": "Splashtop Business", "publisher": "Splashtop Inc.", "product": "splashtop:splashtop"},
|
||||
{"name": "Logi Options+", "publisher": "Logitech"},
|
||||
{"name": "Logitech Options", "publisher": "Logitech", "product": "logitech:options"},
|
||||
{"name": "Tableau Reader 2024.1", "publisher": "Tableau Software"},
|
||||
{"name": "Tableau 2024.1 (20241.24.0312.0911)", "publisher": "Tableau Software", "product": "tableau:tableau_desktop"},
|
||||
{"name": "AMD Chipset Software", "publisher": "Advanced Micro Devices, Inc."},
|
||||
{"name": "Bonjour Print Services", "publisher": "Apple Inc."},
|
||||
{"name": "Microsoft .NET Runtime - 8.0.11 (x64)", "publisher": "Microsoft Corporation", "product": "microsoft:.net"},
|
||||
{"name": "Microsoft Windows Desktop Runtime - 8.0.11 (x64)", "publisher": "Microsoft Corporation", "product": "microsoft:.net"},
|
||||
{"name": "Microsoft .NET SDK 8.0.404 (x64)", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft .NET Framework 4.8.1", "publisher": "Microsoft Corporation", "product": "microsoft:.net_framework"},
|
||||
{"name": "SAP Crystal Reports runtime engine for .NET Framework (64-bit)", "publisher": "SAP"},
|
||||
{"name": "VNC Viewer 7.12.0", "publisher": "RealVNC", "product": "realvnc:vnc_viewer"},
|
||||
{"name": "VNC Server 7.12.0", "publisher": "RealVNC", "product": "realvnc:vnc_server"},
|
||||
{"name": "Malwarebytes AdwCleaner", "publisher": "Malwarebytes"},
|
||||
{"name": "MySQL Server 8.0", "publisher": "Oracle Corporation", "product": "oracle:mysql_server"},
|
||||
{"name": "Zabbix Agent 2 (64-bit)", "publisher": "Zabbix SIA", "product": "zabbix:zabbix-agent2"},
|
||||
{"name": "Zabbix Agent (64-bit)", "publisher": "Zabbix SIA", "product": "zabbix:zabbix-agent"},
|
||||
{"name": "Nessus Agent (x64)", "publisher": "Tenable, Inc.", "product": "tenable:nessus_agent"},
|
||||
{"name": "Tenable Nessus (x64)", "publisher": "Tenable, Inc.", "product": "tenable:nessus"},
|
||||
{"name": "Autodesk AutoCAD LT 2025 - English", "publisher": "Autodesk, Inc."},
|
||||
{"name": "AutoCAD Open in Desktop", "publisher": "Autodesk"},
|
||||
{"name": "Citrix Secure Access Endpoint Analysis", "publisher": "Citrix Systems, Inc."},
|
||||
{"name": "HP Security Update Service", "publisher": "HP Inc."},
|
||||
{"name": "Microsoft Primary Interoperability Assemblies 2005", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Outlook For Windows", "publisher": "Microsoft"},
|
||||
{"name": "Copilot", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Photos", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft PowerBI Desktop (x64)", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Mesh Agent", "publisher": ""},
|
||||
{"name": "SketchUp 2026", "publisher": "Trimble, Inc."},
|
||||
{"name": "Global Secure Access Client Installer (x64)", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft OLE DB Driver 19 for SQL Server", "publisher": "Microsoft Corporation", "product": "microsoft:ole_db_driver_for_sql_server"},
|
||||
{"name": "Opera GX Stable 115.0.5322.124", "publisher": "Opera Software"},
|
||||
{"name": "Opera Stable 115.0.5322.77", "publisher": "Opera Software", "product": "opera:opera_browser"},
|
||||
{"name": "Microsoft OneDrive", "publisher": "Microsoft Corporation", "product": "microsoft:onedrive"},
|
||||
{"name": "Dell Command | Update for Windows Universal", "publisher": "Dell Inc.", "product": "dell:command_update"},
|
||||
{"name": "Dell Optimizer Service", "publisher": "Dell Inc."},
|
||||
{"name": "Intel(R) PROSet/Wireless Software", "publisher": "Intel Corporation", "product": "intel:proset\\/wireless_wifi"},
|
||||
{"name": "ADSelfService Plus Client Software", "publisher": "ZOHO Corp"},
|
||||
{"name": "uniFLOW SmartClient", "publisher": "NT-ware Systemprogrammierung GmbH"},
|
||||
{"name": "baramundi Management Agent", "publisher": "baramundi software GmbH"},
|
||||
{"name": "YSoft SafeQ Client", "publisher": "Y Soft Corporation"},
|
||||
{"name": "Brave", "publisher": "Brave Software Inc", "product": "brave:brave"},
|
||||
{"name": "Microsoft Edge", "publisher": "Microsoft Corporation", "product": "microsoft:edge_chromium"},
|
||||
{"name": "Visual Studio Code", "publisher": "Microsoft"},
|
||||
{"name": "Microsoft Visual Studio Code", "publisher": "Microsoft Corporation", "product": "microsoft:visual_studio_code"},
|
||||
{"name": "Citrix Workspace 2507", "publisher": "Citrix Systems, Inc.", "product": "citrix:workspace"},
|
||||
{"name": "LOGO!Soft Comfort V8.4.0", "publisher": "Siemens AG"},
|
||||
{"name": "7-Zip 24.08 (x64)", "publisher": "Igor Pavlov", "product": "7-zip:7-zip"},
|
||||
{"name": "7-Zip 26.03 (x64 edition)", "publisher": "Igor Pavlov", "product": "7-zip:7-zip"},
|
||||
{"name": "Notepad++ (64-bit x64)", "publisher": "Notepad++ Team", "product": "notepad-plus-plus:notepad\\+\\+"},
|
||||
{"name": "PuTTY release 0.81 (64-bit)", "publisher": "Simon Tatham", "product": "putty:putty"},
|
||||
{"name": "WinSCP 6.3.5", "publisher": "Martin Prikryl", "product": "winscp:winscp"},
|
||||
{"name": "VLC media player", "publisher": "VideoLAN", "product": "videolan:vlc_media_player"},
|
||||
{"name": "Node.js", "publisher": "Node.js Foundation", "product": "nodejs:node.js"},
|
||||
{"name": "Wireshark 4.2.6 x64", "publisher": "The Wireshark developer community, https://www.wireshark.org", "product": "wireshark:wireshark"},
|
||||
{"name": "FileZilla 3.67.0", "publisher": "Tim Kosse", "product": "filezilla-project:filezilla_client"},
|
||||
{"name": "TeamViewer", "publisher": "TeamViewer", "product": "teamviewer:teamviewer"},
|
||||
{"name": "AnyDesk", "publisher": "AnyDesk Software GmbH", "product": "anydesk:anydesk"},
|
||||
{"name": "Foxit PDF Reader", "publisher": "Foxit Software Inc.", "product": "foxit:pdf_reader"},
|
||||
{"name": "Docker Desktop", "publisher": "Docker Inc.", "product": "docker:desktop"},
|
||||
{"name": "Java 8 Update 421 (64-bit)", "publisher": "Oracle Corporation", "product": "oracle:jre"},
|
||||
{"name": "WireGuard", "publisher": "WireGuard LLC", "product": "wireguard:wireguard"},
|
||||
{"name": "Microsoft Teams", "publisher": "Microsoft", "product": "microsoft:teams"},
|
||||
{"name": "Opera Stable 112.0.5197.53", "publisher": "Opera Software", "product": "opera:opera_browser"},
|
||||
{"name": "Mozilla Thunderbird (x64 en-US)", "publisher": "Mozilla", "product": "mozilla:thunderbird"},
|
||||
{"name": "LibreOffice 24.2.5.2", "publisher": "The Document Foundation", "product": "libreoffice:libreoffice"},
|
||||
{"name": "GIMP 2.10.38", "publisher": "The GIMP Team", "product": "gimp:gimp"},
|
||||
{"name": "Inkscape", "publisher": "Inkscape", "product": "inkscape:inkscape"},
|
||||
{"name": "Audacity 3.6.1", "publisher": "Audacity Team", "product": "audacityteam:audacity"},
|
||||
{"name": "WinRAR 7.01 (64-bit)", "publisher": "win.rar GmbH", "product": "rarlab:winrar"},
|
||||
{"name": "PDF24 Creator 11.18.0", "publisher": "geek software GmbH", "product": "pdf24:pdf24_creator"},
|
||||
{"name": "Greenshot 1.2.10.6", "publisher": "Greenshot", "product": "getgreenshot:greenshot"},
|
||||
{"name": "Paint.NET", "publisher": "dotPDN LLC", "product": "dotpdn:paint.net"},
|
||||
{"name": "IrfanView 4.67 (64-bit)", "publisher": "Irfan Skiljan", "product": "irfanview:irfanview"},
|
||||
{"name": "Oracle VM VirtualBox 7.0.20", "publisher": "Oracle and/or its affiliates", "product": "oracle:vm_virtualbox"},
|
||||
{"name": "VMware Workstation", "publisher": "VMware, Inc.", "product": "vmware:workstation"},
|
||||
{"name": "VMware Tools", "publisher": "VMware, Inc.", "product": "vmware:tools"},
|
||||
{"name": "Cisco Secure Client - AnyConnect VPN", "publisher": "Cisco Systems, Inc.", "product": "cisco:secure_client"},
|
||||
{"name": "FortiClient VPN", "publisher": "Fortinet Technologies Inc", "product": "fortinet:forticlient"},
|
||||
{"name": "GlobalProtect", "publisher": "Palo Alto Networks", "product": "paloaltonetworks:globalprotect"},
|
||||
{"name": "Zscaler", "publisher": "Zscaler Inc.", "product": "zscaler:client_connector"},
|
||||
{"name": "Sophos Endpoint Agent", "publisher": "Sophos Limited", "product": "sophos:endpoint_protection"},
|
||||
{"name": "CrowdStrike Windows Sensor", "publisher": "CrowdStrike, Inc.", "product": "crowdstrike:falcon"},
|
||||
{"name": "1Password", "publisher": "AgileBits Inc.", "product": "1password:1password"},
|
||||
{"name": "Bitwarden", "publisher": "Bitwarden Inc.", "product": "bitwarden:bitwarden"},
|
||||
{"name": "Dropbox", "publisher": "Dropbox, Inc.", "product": "dropbox:dropbox"},
|
||||
{"name": "Microsoft SQL Server Management Studio - 19.3", "publisher": "Microsoft Corporation", "product": "microsoft:sql_server_management_studio"},
|
||||
{"name": "Microsoft .NET Runtime - 8.0.8 (x64)", "publisher": "Microsoft Corporation", "product": "microsoft:.net"},
|
||||
{"name": "Microsoft Windows Desktop Runtime - 8.0.8 (x64)", "publisher": "Microsoft Corporation", "product": "microsoft:.net"},
|
||||
{"name": "Microsoft ASP.NET Core 8.0.8 - Shared Framework (x64)", "publisher": "Microsoft Corporation", "product": "microsoft:asp.net_core"},
|
||||
{"name": "Microsoft Visual C++ 2015-2022 Redistributable (x64) - 14.40.33810", "publisher": "Microsoft Corporation", "product": "microsoft:visual_c\\+\\+"},
|
||||
{"name": "Windows Terminal", "publisher": "Microsoft Corporation", "product": "microsoft:terminal"},
|
||||
{"name": "Zoom Workplace (64-bit)", "publisher": "Zoom Communications, Inc.", "product": "zoom:workplace_desktop"},
|
||||
{"name": "Webex", "publisher": "Cisco Webex LLC", "product": "cisco:webex_teams"},
|
||||
{"name": "Slack", "publisher": "Slack Technologies Inc."},
|
||||
{"name": "Postman x86_64 11.6.2", "publisher": "Postman", "product": "postman:postman"},
|
||||
{"name": "Tableau Desktop 2024.2", "publisher": "Tableau Software", "product": "tableau:tableau_desktop"},
|
||||
{"name": "Snagit 2024", "publisher": "TechSmith Corporation", "product": "techsmith:snagit"},
|
||||
{"name": "Adobe Creative Cloud", "publisher": "Adobe Inc.", "product": "adobe:creative_cloud"},
|
||||
{"name": "Adobe Photoshop 2024", "publisher": "Adobe Inc.", "product": "adobe:photoshop"},
|
||||
{"name": "Autodesk AutoCAD 2025 - English", "publisher": "Autodesk", "product": "autodesk:autocad"},
|
||||
{"name": "Dell Command | Update", "publisher": "Dell Inc.", "product": "dell:command_update"},
|
||||
{"name": "HP Support Assistant", "publisher": "HP Inc.", "product": "hp:support_assistant"},
|
||||
{"name": "Lenovo System Update", "publisher": "Lenovo", "product": "lenovo:system_update"},
|
||||
{"name": "Intel(R) Graphics Driver", "publisher": "Intel Corporation", "product": "intel:graphics_driver"},
|
||||
{"name": "NVIDIA Graphics Driver 552.44", "publisher": "NVIDIA Corporation", "product": "nvidia:gpu_display_driver"},
|
||||
{"name": "TortoiseSVN 1.14.7.29687 (64 bit)", "publisher": "TortoiseSVN", "product": "tortoisesvn:tortoisesvn"},
|
||||
{"name": "PowerToys (Preview) x64", "publisher": "Microsoft Corporation", "product": "microsoft:powertoys"},
|
||||
{"name": "Sysinternals Suite", "publisher": "Microsoft Corporation"},
|
||||
{"name": "BeyondTrust Privilege Management", "publisher": "BeyondTrust", "product": "beyondtrust:privilege_management_for_windows"},
|
||||
{"name": "R for Windows 4.4.1", "publisher": "R Core Team"},
|
||||
{"name": "Go Programming Language amd64 go1.22.5", "publisher": "https://go.dev", "product": "golang:go"},
|
||||
{"name": "Strawberry Perl (64-bit)", "publisher": "strawberryperl.com project", "product": "perl:perl"},
|
||||
{"name": "Microsoft Power BI Desktop (x64)", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Mozilla Thunderbird (x86 de)", "publisher": "Mozilla", "product": "mozilla:thunderbird"},
|
||||
{"name": "Mozilla Thunderbird 102.15.1 (x64 en-GB)", "publisher": "Mozilla", "product": "mozilla:thunderbird"},
|
||||
{"name": "Mozilla Firefox (x64 en-US)", "publisher": "Mozilla", "product": "mozilla:firefox", "edition": ""},
|
||||
{"name": "eM Client", "publisher": "eM Client Inc.", "product": "emclient:em_client"},
|
||||
{"name": "eM Client", "publisher": "eM Client s.r.o.", "product": "emclient:em_client"},
|
||||
{"name": "WPS Office (12.2.0.13431)", "publisher": "Kingsoft Corp.", "product": "kingsoft:wps_office"},
|
||||
{"name": "WPS Office (11.2.0.11537)", "publisher": "Kingsoft Corp.", "product": "kingsoft:wps_office"},
|
||||
{"name": "SumatraPDF", "publisher": "Krzysztof Kowalczyk", "product": "sumatrapdfreader:sumatrapdf"},
|
||||
{"name": "SumatraPDF 3.4.6", "publisher": "Krzysztof Kowalczyk", "product": "sumatrapdfreader:sumatrapdf"},
|
||||
{"name": "GPL Ghostscript", "publisher": "Artifex Software Inc.", "product": "artifex:ghostscript"},
|
||||
{"name": "GPL Ghostscript 9.56.1", "publisher": "Artifex Software Inc.", "product": "artifex:ghostscript"},
|
||||
{"name": "GPL Ghostscript Fonts", "publisher": "Artifex Software Inc."},
|
||||
{"name": "calibre 64bit", "publisher": "Kovid Goyal", "product": "calibre-ebook:calibre"},
|
||||
{"name": "calibre", "publisher": "Kovid Goyal", "product": "calibre-ebook:calibre"},
|
||||
{"name": "Bandizip", "publisher": "Bandisoft International Inc.", "product": "bandisoft:bandizip"},
|
||||
{"name": "Bandizip 7.37", "publisher": "Bandisoft.com", "product": "bandisoft:bandizip"},
|
||||
{"name": "PeaZip 9.9.0 (WIN64)", "publisher": "Giorgio Tani", "product": "peazip:peazip"},
|
||||
{"name": "PeaZip 10.4.0", "publisher": "Giorgio Tani", "product": "peazip:peazip"},
|
||||
{"name": "XnView 2.51.6", "publisher": "Gougelet Pierre-e", "product": "xnview:xnview"},
|
||||
{"name": "XnView 2.49.1", "publisher": "Gougelet Pierre-e", "product": "xnview:xnview"},
|
||||
{"name": "XnViewMP 1.7.2", "publisher": "Gougelet Pierre-e", "product": "xnview:xnview_mp"},
|
||||
{"name": "XnView MP 1.4.5", "publisher": "XnSoft", "product": "xnview:xnview_mp"},
|
||||
{"name": "XnView Shell Extension 4.1.11", "publisher": "Gougelet Pierre-e"},
|
||||
{"name": "ImageMagick 7.1.1-38 Q16-HDRI (64-bit) (2024-09-01)", "publisher": "ImageMagick Studio LLC", "product": "imagemagick:imagemagick"},
|
||||
{"name": "ImageMagick 6.9.13-12 Q16 (32-bit) (2024-06-15)", "publisher": "ImageMagick Studio LLC", "product": "imagemagick:imagemagick"},
|
||||
{"name": "MPC-HC 2.3.3 (64-bit)", "publisher": "MPC-HC Team", "product": "mpc-hc:mpc-hc"},
|
||||
{"name": "MPC-HC 1.7.13 (64-bit)", "publisher": "MPC-HC Team", "product": "mpc-hc:mpc-hc"},
|
||||
{"name": "MPC-BE x64 1.6.11", "publisher": "MPC-BE Team"},
|
||||
{"name": "K-Lite Codec Pack 18.4.5 Full", "publisher": "KLCP", "product": "codecguide:k-lite_codec_pack"},
|
||||
{"name": "Kodi", "publisher": "XBMC Foundation", "product": "kodi:kodi"},
|
||||
{"name": "Kodi 20.2", "publisher": "XBMC Foundation", "product": "kodi:kodi"},
|
||||
{"name": "Adobe Lightroom Classic", "publisher": "Adobe Inc.", "product": "adobe:lightroom", "edition": "classic"},
|
||||
{"name": "Adobe Lightroom Classic CC", "publisher": "Adobe Systems Incorporated", "product": "adobe:lightroom", "edition": "classic"},
|
||||
{"name": "Adobe Photoshop Lightroom Classic CC", "publisher": "Adobe Systems Incorporated", "product": "adobe:lightroom", "edition": "classic"},
|
||||
{"name": "Adobe Lightroom", "publisher": "Adobe Inc.", "product": "adobe:lightroom", "edition": ""},
|
||||
{"name": "Adobe Lightroom CC", "publisher": "Adobe Systems Incorporated", "product": "adobe:lightroom", "edition": ""},
|
||||
{"name": "Adobe Dimension", "publisher": "Adobe Inc.", "product": "adobe:dimension"},
|
||||
{"name": "Adobe Dimension CC", "publisher": "Adobe Systems Incorporated", "product": "adobe:dimension"},
|
||||
{"name": "Adobe Audition 2025", "publisher": "Adobe Inc.", "product": "adobe:audition"},
|
||||
{"name": "Adobe Audition CC 2019", "publisher": "Adobe Systems Incorporated", "product": "adobe:audition"},
|
||||
{"name": "Adobe InCopy 2025", "publisher": "Adobe Inc.", "product": "adobe:incopy"},
|
||||
{"name": "Adobe InCopy CC 2019", "publisher": "Adobe Systems Incorporated", "product": "adobe:incopy"},
|
||||
{"name": "Adobe InDesign 2025", "publisher": "Adobe Inc.", "product": "adobe:indesign"},
|
||||
{"name": "Microsoft ASP.NET Core 6.0.33 - Shared Framework (x86)", "publisher": "Microsoft Corporation", "product": "microsoft:asp.net_core"},
|
||||
{"name": "Microsoft ASP.NET Core 3.1.32 - Shared Framework (x64)", "publisher": "Microsoft Corporation", "product": "microsoft:asp.net_core"},
|
||||
{"name": "Microsoft ASP.NET Core 8.0.8 Targeting Pack (x64)", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft ASP.NET Core Module V2", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft ASP.NET Core Module", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Microsoft ASP.NET MVC 4 Runtime", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Vim 9.1 (x64)", "publisher": "Bram Moolenaar et al.", "product": "vim:vim"},
|
||||
{"name": "Vim 8.2 (x86)", "publisher": "Bram Moolenaar et al.", "product": "vim:vim"},
|
||||
{"name": "Neovim", "publisher": "neovim.io"},
|
||||
{"name": "AWS Command Line Interface v2", "publisher": "Amazon Web Services", "product": "amazon:aws_command_line_interface"},
|
||||
{"name": "AWS Command Line Interface", "publisher": "Amazon Web Services", "product": "amazon:aws_command_line_interface"},
|
||||
{"name": "AWS SAM Command Line Interface", "publisher": "Amazon Web Services"},
|
||||
{"name": "AWS Tools for Windows", "publisher": "Amazon Web Services Developer Relations"},
|
||||
{"name": "Session Manager Plugin", "publisher": "Amazon Web Services"},
|
||||
{"name": "Microsoft Azure CLI (64-bit)", "publisher": "Microsoft Corporation", "product": "microsoft:azure_command-line_interface"},
|
||||
{"name": "Vagrant", "publisher": "HashiCorp, Inc.", "product": "hashicorp:vagrant"},
|
||||
{"name": "Vagrant 2.4.1", "publisher": "HashiCorp", "product": "hashicorp:vagrant"},
|
||||
{"name": "Vagrant VMware Utility", "publisher": "HashiCorp, Inc."},
|
||||
{"name": "Terraform", "publisher": "HashiCorp", "product": "hashicorp:terraform"},
|
||||
{"name": "SoapUI 5.7.2", "publisher": "SmartBear Software", "product": "smartbear:soapui"},
|
||||
{"name": "SoapUI 5.5.0", "publisher": "SmartBear Software", "product": "smartbear:soapui"},
|
||||
{"name": "ReadyAPI 3.52.0", "publisher": "SmartBear Software"},
|
||||
{"name": "SoapUI Pro 1.4.0", "publisher": "SmartBear Software"},
|
||||
{"name": "GitKraken", "publisher": "Axosoft, LLC", "product": "axosoft:gitkraken_desktop"},
|
||||
{"name": "GitKraken", "publisher": "GitKraken", "product": "axosoft:gitkraken_desktop"},
|
||||
{"name": "MySQL Connector/ODBC 8.0", "publisher": "Oracle Corporation", "product": "oracle:mysql_connector\\/odbc"},
|
||||
{"name": "MySQL Connector/ODBC 9.0", "publisher": "Oracle Corporation", "product": "oracle:mysql_connector\\/odbc"},
|
||||
{"name": "MySQL Connector/NET 9.5.0", "publisher": "Oracle Corporation", "product": "oracle:mysql_connector\\/net"},
|
||||
{"name": "MySQL Workbench 8.0 CE", "publisher": "Oracle Corporation", "product": "oracle:mysql_workbench"},
|
||||
{"name": "VMware Player", "publisher": "VMware, Inc.", "product": "vmware:workstation_player"},
|
||||
{"name": "VMware Player 17.5.2", "publisher": "VMware, Inc.", "product": "vmware:workstation_player"},
|
||||
{"name": "VMware Workstation 17 Player", "publisher": "VMware, Inc.", "product": "vmware:workstation"},
|
||||
{"name": "TightVNC", "publisher": "GlavSoft LLC.", "product": "tightvnc:tightvnc"},
|
||||
{"name": "TightVNC 2.8.81", "publisher": "GlavSoft LLC.", "product": "tightvnc:tightvnc"},
|
||||
{"name": "UltraVnc", "publisher": "uvnc bvba", "product": "uvnc:ultravnc"},
|
||||
{"name": "UltraVNC 1.4.3.6", "publisher": "uvnc bvba", "product": "uvnc:ultravnc"},
|
||||
{"name": "DameWare Mini Remote Control", "publisher": "SolarWinds", "product": "solarwinds:dameware_mini_remote_control"},
|
||||
{"name": "SolarWinds Dameware Mini Remote Control x64", "publisher": "SolarWinds Worldwide, LLC", "product": "solarwinds:dameware_mini_remote_control"},
|
||||
{"name": "Dameware Remote Everywhere", "publisher": "SolarWinds"},
|
||||
{"name": "MobaXterm", "publisher": "Mobatek", "product": "mobatek:mobaxterm"},
|
||||
{"name": "MobaXterm 24.2", "publisher": "Mobatek", "product": "mobatek:mobaxterm"},
|
||||
{"name": "NordVPN", "publisher": "Nord Security", "product": "nordvpn:nordvpn"},
|
||||
{"name": "NordVPN 7.31.2.0", "publisher": "Nord Security", "product": "nordvpn:nordvpn"},
|
||||
{"name": "NordVPN network TAP", "publisher": "NordVPN"},
|
||||
{"name": "NordVPN network TUN", "publisher": "NordVPN"},
|
||||
{"name": "NordPass", "publisher": "Nord Security"},
|
||||
{"name": "Proton VPN", "publisher": "Proton AG", "product": "proton:protonvpn"},
|
||||
{"name": "Citrix Secure Access", "publisher": "Citrix Systems, Inc.", "product": "citrix:secure_access_client"},
|
||||
{"name": "Citrix Secure Access 24.8.1.19", "publisher": "Citrix Systems, Inc.", "product": "citrix:secure_access_client"},
|
||||
{"name": "Citrix Gateway Plug-in", "publisher": "Citrix Systems, Inc.", "product": "citrix:gateway_plug-in"},
|
||||
{"name": "Citrix Gateway Plug-in 13.0.84.11", "publisher": "Citrix Systems, Inc.", "product": "citrix:gateway_plug-in"},
|
||||
{"name": "Citrix Workspace 2409", "publisher": "Citrix Systems, Inc.", "product": "citrix:workspace"},
|
||||
{"name": "Global VPN Client", "publisher": "SonicWall", "product": "sonicwall:global_vpn_client"},
|
||||
{"name": "SonicWall Global VPN Client", "publisher": "SonicWall Inc.", "product": "sonicwall:global_vpn_client"},
|
||||
{"name": "SonicWall NetExtender", "publisher": "SonicWall Inc.", "product": "sonicwall:netextender"},
|
||||
{"name": "WatchGuard Mobile VPN with SSL client 12.10", "publisher": "WatchGuard", "product": "watchguard:mobile_vpn_with_ssl"},
|
||||
{"name": "WatchGuard Mobile VPN with SSL client 12.7.2", "publisher": "WatchGuard", "product": "watchguard:mobile_vpn_with_ssl"},
|
||||
{"name": "WatchGuard Authentication Client 12.7", "publisher": "WatchGuard"},
|
||||
{"name": "WatchGuard System Manager 12.10", "publisher": "WatchGuard"},
|
||||
{"name": "Avast Free Antivirus", "publisher": "Avast Software", "product": "avast:free_antivirus"},
|
||||
{"name": "Avast Free Antivirus", "publisher": "AVAST Software", "product": "avast:free_antivirus"},
|
||||
{"name": "Avast Business Security", "publisher": "Avast Software", "product": "avast:antivirus"},
|
||||
{"name": "Avast Premium Security", "publisher": "Avast Software"},
|
||||
{"name": "Avast Secure Browser", "publisher": "Avast Software"},
|
||||
{"name": "AVG AntiVirus FREE", "publisher": "AVG Technologies"},
|
||||
{"name": "Webroot SecureAnywhere", "publisher": "Webroot", "product": "webroot:secureanywhere"},
|
||||
{"name": "Webroot SecureAnywhere Endpoint Protection", "publisher": "Webroot", "product": "webroot:secureanywhere"},
|
||||
{"name": "McAfee DLP Endpoint", "publisher": "McAfee, LLC.", "product": "mcafee:data_loss_prevention_endpoint"},
|
||||
{"name": "McAfee Data Loss Prevention - Endpoint", "publisher": "McAfee, Inc.", "product": "mcafee:data_loss_prevention_endpoint"},
|
||||
{"name": "McAfee Agent", "publisher": "McAfee, LLC.", "product": "mcafee:agent"},
|
||||
{"name": "McAfee Endpoint Security Platform", "publisher": "McAfee, LLC.", "product": "mcafee:endpoint_security"},
|
||||
{"name": "McAfee", "publisher": "McAfee, LLC"},
|
||||
{"name": "VeraCrypt", "publisher": "IDRIX", "product": "idrix:veracrypt"},
|
||||
{"name": "VeraCrypt 1.26.14", "publisher": "IDRIX", "product": "idrix:veracrypt"},
|
||||
{"name": "GNU Privacy Guard", "publisher": "The GnuPG Project", "product": "gnupg:gnupg"},
|
||||
{"name": "GnuPG 2.4.5", "publisher": "The GnuPG Project", "product": "gnupg:gnupg"},
|
||||
{"name": "GnuPG VS-Desktop", "publisher": "g10 Code GmbH"},
|
||||
{"name": "Gpg4win (4.3.1)", "publisher": "The Gpg4win Project", "product": "gpg4win:gpg4win"},
|
||||
{"name": "OpenSC smartcard framework (64bit)", "publisher": "OpenSC Project", "product": "opensc_project:opensc"},
|
||||
{"name": "OpenSC smartcard framework (32bit)", "publisher": "OpenSC Project", "product": "opensc_project:opensc"},
|
||||
{"name": "Entrust Entelligence Security Provider", "publisher": "Entrust", "product": "entrustdatacard:entelligence_security_provider"},
|
||||
{"name": "Entrust Entelligence Security Provider 10.0", "publisher": "Entrust Datacard", "product": "entrustdatacard:entelligence_security_provider"},
|
||||
{"name": "Dell Encryption 64-bit", "publisher": "Dell Inc.", "product": "dell:encryption"},
|
||||
{"name": "Dell Encryption Management Agent", "publisher": "Dell Inc.", "product": "dell:encryption"},
|
||||
{"name": "Dell Data Protection | Encryption", "publisher": "Dell Inc."},
|
||||
{"name": "Telegram Desktop", "publisher": "Telegram FZ-LLC", "product": "telegram:telegram_desktop"},
|
||||
{"name": "Telegram Desktop version 5.5.5", "publisher": "Telegram FZ-LLC", "product": "telegram:telegram_desktop"},
|
||||
{"name": "WhatsApp", "publisher": "WhatsApp"},
|
||||
{"name": "WhatsApp 2.2245.9", "publisher": "WhatsApp"},
|
||||
{"name": "Nextcloud", "publisher": "Nextcloud GmbH", "product": "nextcloud:desktop"},
|
||||
{"name": "Nextcloud 3.13.2", "publisher": "Nextcloud GmbH", "product": "nextcloud:desktop"},
|
||||
{"name": "Nextcloud Talk", "publisher": "Nextcloud GmbH"},
|
||||
{"name": "ownCloud", "publisher": "ownCloud GmbH", "product": "owncloud:owncloud_desktop_client"},
|
||||
{"name": "ownCloud 5.3.1", "publisher": "ownCloud GmbH", "product": "owncloud:owncloud_desktop_client"},
|
||||
{"name": "Synology Drive Client", "publisher": "Synology, Inc.", "product": "synology:drive_client"},
|
||||
{"name": "Synology Drive Client 3.5.1", "publisher": "Synology Inc.", "product": "synology:drive_client"},
|
||||
{"name": "Synology Assistant", "publisher": "Synology"},
|
||||
{"name": "Synology Active Backup for Business Agent", "publisher": "Synology Inc."},
|
||||
{"name": "IBM Aspera Connect", "publisher": "IBM", "product": "ibm:aspera_connect"},
|
||||
{"name": "Aspera Connect", "publisher": "Aspera, Inc.", "product": "ibm:aspera_connect"},
|
||||
{"name": "Macrium Reflect Workstation", "publisher": "Paramount Software (UK) Ltd.", "product": "macrium:reflect"},
|
||||
{"name": "Macrium Reflect Free Edition", "publisher": "Paramount Software (UK) Ltd.", "product": "macrium:reflect"},
|
||||
{"name": "Druva inSync 7.5.0", "publisher": "Druva Technologies Pte. Ltd.", "product": "druva:insync_client"},
|
||||
{"name": "Druva inSync", "publisher": "Druva Inc.", "product": "druva:insync_client"},
|
||||
{"name": "AutoHotkey 1.1.37.02", "publisher": "Lexikos", "product": "autohotkey:autohotkey"},
|
||||
{"name": "AutoHotkey", "publisher": "AutoHotkey Foundation LLC", "product": "autohotkey:autohotkey"},
|
||||
{"name": "Rufus", "publisher": "Akeo Consulting", "product": "akeo:rufus"},
|
||||
{"name": "Rufus 4.5", "publisher": "Akeo Consulting", "product": "akeo:rufus"},
|
||||
{"name": "CPUID CPU-Z 2.10", "publisher": "CPUID, Inc.", "product": "cpuid:cpu-z"},
|
||||
{"name": "CPUID CPU-Z 1.81", "publisher": "CPUID, Inc.", "product": "cpuid:cpu-z"},
|
||||
{"name": "CPUID HWMonitor 1.53", "publisher": "CPUID, Inc."},
|
||||
{"name": "TeraCopy", "publisher": "Code Sector", "product": "codesector:teracopy"},
|
||||
{"name": "TeraCopy 3.9.7", "publisher": "Code Sector", "product": "codesector:teracopy"},
|
||||
{"name": "Samsung Magician", "publisher": "Samsung Electronics", "product": "samsung:magician"},
|
||||
{"name": "Samsung Magician 8.1.0", "publisher": "Samsung Electronics Co., Ltd.", "product": "samsung:magician"},
|
||||
{"name": "Samsung Data Migration", "publisher": "Samsung"},
|
||||
{"name": "Western Digital SSD Dashboard", "publisher": "Western Digital Corporation", "product": "westerndigital:ssd_dashboard"},
|
||||
{"name": "WD SSD Dashboard", "publisher": "Western Digital Corporation", "product": "westerndigital:ssd_dashboard"},
|
||||
{"name": "Western Digital Dashboard", "publisher": "Western Digital Technologies, Inc.", "product": "westerndigital:dashboard"},
|
||||
{"name": "Dashboard", "publisher": "Western Digital Technologies, Inc.", "product": "westerndigital:dashboard"},
|
||||
{"name": "Dashboard", "publisher": ""},
|
||||
{"name": "Dell Command | Monitor", "publisher": "Dell Inc.", "product": "dell:command_\\|_monitor"},
|
||||
{"name": "Dell Command | Monitor 10.11", "publisher": "Dell Inc.", "product": "dell:command_\\|_monitor"},
|
||||
{"name": "Dell Command | Configure", "publisher": "Dell Inc.", "product": "dell:command\\|configure"},
|
||||
{"name": "Dell Update", "publisher": "Dell Inc.", "product": "dell:update"},
|
||||
{"name": "Dell Update for Windows Universal", "publisher": "Dell Inc.", "product": "dell:update"},
|
||||
{"name": "Dell Update - SupportAssist Update Plugin", "publisher": "Dell Inc."},
|
||||
{"name": "HP System Event Utility", "publisher": "HP Inc.", "product": "hp:system_event_utility"},
|
||||
{"name": "HP System Event Utility", "publisher": "HP", "product": "hp:system_event_utility"},
|
||||
{"name": "HP PC Hardware Diagnostics Windows", "publisher": "HP Inc.", "product": "hp:pc_hardware_diagnostics"},
|
||||
{"name": "HP PC Hardware Diagnostics Windows", "publisher": "HP", "product": "hp:pc_hardware_diagnostics"},
|
||||
{"name": "HP PC Hardware Diagnostics UEFI", "publisher": "HP Inc."},
|
||||
{"name": "Lenovo Commercial Vantage", "publisher": "Lenovo Group Ltd.", "product": "lenovo:commercial_vantage"},
|
||||
{"name": "Lenovo Commercial Vantage", "publisher": "Lenovo", "product": "lenovo:commercial_vantage"},
|
||||
{"name": "Lenovo Vantage", "publisher": "Lenovo Group Ltd.", "product": "lenovo:vantage"},
|
||||
{"name": "Lenovo Service Bridge", "publisher": "Lenovo", "product": "lenovo:service_bridge"},
|
||||
{"name": "Lenovo Service Bridge", "publisher": "Lenovo Group Ltd.", "product": "lenovo:service_bridge"},
|
||||
{"name": "ASUS Live Update", "publisher": "ASUSTeK COMPUTER INC.", "product": "asus:live_update"},
|
||||
{"name": "ASUS Live Update", "publisher": "ASUS", "product": "asus:live_update"},
|
||||
{"name": "Power Automate for desktop", "publisher": "Microsoft Corporation", "product": "microsoft:power_automate_for_desktop"},
|
||||
{"name": "Power Automate for desktop (x64)", "publisher": "Microsoft Corporation", "product": "microsoft:power_automate_for_desktop"},
|
||||
{"name": "Power Automate Desktop", "publisher": "Microsoft Corporation", "product": "microsoft:power_automate_for_desktop"},
|
||||
{"name": "Power Automate machine runtime", "publisher": "Microsoft Corporation"},
|
||||
{"name": "Ivanti Workspace Control 2023.2", "publisher": "Ivanti", "product": "ivanti:workspace_control"},
|
||||
{"name": "Ivanti Workspace Control Agent", "publisher": "Ivanti", "product": "ivanti:workspace_control"},
|
||||
{"name": "Ivanti Secure Access Client", "publisher": "Ivanti, Inc.", "product": "ivanti:secure_access_client"},
|
||||
{"name": "MicroStation CONNECT Edition", "publisher": "Bentley Systems, Incorporated", "product": "bentley:microstation"},
|
||||
{"name": "MicroStation 2023", "publisher": "Bentley Systems, Incorporated", "product": "bentley:microstation"},
|
||||
{"name": "MicroStation PowerDraft CONNECT Edition", "publisher": "Bentley Systems, Incorporated"},
|
||||
{"name": "ProjectWise Integration for MicroStation", "publisher": "Bentley Systems, Incorporated"},
|
||||
{"name": "IBM SPSS Statistics", "publisher": "IBM Corp.", "product": "ibm:spss_statistics"},
|
||||
{"name": "IBM SPSS Statistics 27", "publisher": "IBM Corp.", "product": "ibm:spss_statistics"},
|
||||
{"name": "IBM SPSS Statistics - Essentials for Python", "publisher": "IBM Corp."},
|
||||
{"name": "IBM SPSS Modeler 18.4", "publisher": "IBM Corp."},
|
||||
{"name": "QlikView x64", "publisher": "QlikTech International AB", "product": "qlik:qlikview"},
|
||||
{"name": "QlikView", "publisher": "QlikTech International AB", "product": "qlik:qlikview"},
|
||||
{"name": "QlikView Server x64", "publisher": "QlikTech International AB"},
|
||||
{"name": "QlikView IE Plugin", "publisher": "QlikTech International AB"},
|
||||
{"name": "Blue Prism", "publisher": "Blue Prism Limited", "product": "blueprism:robotic_process_automation"},
|
||||
{"name": "Blue Prism 6.10.1", "publisher": "Blue Prism Limited", "product": "blueprism:robotic_process_automation"},
|
||||
{"name": "Blue Prism Capture", "publisher": "Blue Prism Limited"}
|
||||
]
|
||||
@@ -50,7 +50,6 @@ public class TypedRequestGuardTests
|
||||
// Not request bodies at all: telemetry item payloads (agent wire, D-200) and stored table columns.
|
||||
["Functions/Ingest/TelemetryPayloadParser.cs"] = "telemetry item PayloadJson (agent wire, D-200)",
|
||||
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = "stored backup diff column",
|
||||
["Functions/Vulnerability/GetCpeMappingsFunction.cs"] = "stored pattern-list columns",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="UnmappedSoftware"/> is the one list of programs without a mapping that the
|
||||
/// operator's view and the nightly automatic mapper share: one entry per vendor and canonical
|
||||
/// name, decided by the matcher and the ignore list, read from the columns it names. It also
|
||||
/// says which programs occur only as Store packages, which the automatic mapper leaves alone.
|
||||
/// </summary>
|
||||
public class UnmappedSoftwareTests
|
||||
{
|
||||
private const string OtherTenant = "33333333-3333-3333-3333-333333333333";
|
||||
private const string OlderSession = "44444444-4444-4444-4444-444444444444";
|
||||
private const string NewestSession = "55555555-5555-5555-5555-555555555555";
|
||||
private static readonly DateTime Now = new(2026, 11, 12, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public Rig() =>
|
||||
Tables.Put(VulnerabilityCacheRows.Mapping("contoso", "contoso widget", "cpe:2.3:a:contoso:widget", "contoso widget"));
|
||||
|
||||
public MappingTables Tables { get; } = new();
|
||||
|
||||
public void Ignore(string vendor, string product) => Tables.Put(
|
||||
CpeMappingStore.IgnoredPartition, CpeSeedFile.RowKeyOf(vendor, product),
|
||||
new Dictionary<string, object> { ["NormalizedVendor"] = vendor, ["NormalizedProduct"] = product });
|
||||
|
||||
/// <summary>Aggregates what a reader of the listed columns gets, over the mappings in service.</summary>
|
||||
public async Task<List<UnmatchedSoftwareItem>> AggregateAsync()
|
||||
{
|
||||
var mappings = await new CpeMappingStore(Tables.Repo.Object, NullLogger<CpeMappingStore>.Instance).GetAsync();
|
||||
var rows = await Tables.Repo.Object.GetAllSoftwareInventoryAsync(UnmappedSoftware.Columns);
|
||||
return UnmappedSoftware.Aggregate(rows, mappings);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Aggregate_VersionsAndArchitecturesOfOneProgram_AreOneEntry_ShownAsItsMostRecentlySeenRow(bool newestRowFirst)
|
||||
{
|
||||
var rig = new Rig();
|
||||
void Older() => rig.Tables.AddInventory(
|
||||
"northwind", "Northwind Tool 1.0 (x64)", "1.0", sessions: 3, publisher: "Northwind Traders Ltd",
|
||||
lastSeenUtc: Now.AddDays(-3), lastSessionId: OlderSession);
|
||||
void Newest() => rig.Tables.AddInventory(
|
||||
"northwind", "Northwind Tool 2.0 (x86)", "2.0", sessions: 4, publisher: "Northwind Traders",
|
||||
lastSeenUtc: Now.AddDays(-1), lastSessionId: NewestSession);
|
||||
if (newestRowFirst) { Newest(); Older(); } else { Older(); Newest(); }
|
||||
rig.Tables.AddInventory(
|
||||
"northwind", "Northwind Tool 1.0 (x64)", "1.0", sessions: 2, publisher: "Northwind Traders Ltd",
|
||||
lastSeenUtc: Now.AddDays(-2), lastSessionId: OlderSession, tenantId: OtherTenant);
|
||||
|
||||
var entry = Assert.Single(await rig.AggregateAsync());
|
||||
|
||||
Assert.Equal(9, entry.Frequency);
|
||||
Assert.Equal("Northwind Tool 2.0 (x86)", entry.SoftwareName);
|
||||
Assert.Equal("2.0", entry.NormalizedVersion);
|
||||
Assert.Equal(NewestSession, entry.ExampleSessionId);
|
||||
Assert.Equal(Now.AddDays(-1).ToString("o"), entry.LastSeenAt);
|
||||
Assert.Equal("Northwind Traders", entry.Publisher);
|
||||
Assert.Equal("northwind", entry.NormalizedVendor);
|
||||
Assert.Equal("northwind tool", entry.CanonicalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aggregate_GoesByTheMappingsInService_NotByTheCpeColumnOfTheRow()
|
||||
{
|
||||
var rig = new Rig();
|
||||
// The column trails a mapping change until the rows are reconciled.
|
||||
rig.Tables.AddInventory("contoso", "Contoso Widget 4.2 (x64)", "4.2", cpeUri: "");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1", cpeUri: "cpe:2.3:a:northwind:tool");
|
||||
|
||||
var entry = Assert.Single(await rig.AggregateAsync());
|
||||
|
||||
Assert.Equal("Northwind Tool", entry.SoftwareName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aggregate_IgnoredProgram_IsLeftOutInEveryVersion()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Ignore("northwind", "northwind tool");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 1.0 (x64)", "1.0");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 2.0", "2.0");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Viewer", "1.0");
|
||||
|
||||
var entry = Assert.Single(await rig.AggregateAsync());
|
||||
|
||||
Assert.Equal("Northwind Viewer", entry.SoftwareName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aggregate_IgnoreEntryFiledUnderTheRawPublisher_StillHidesTheProgram()
|
||||
{
|
||||
var rig = new Rig();
|
||||
// Entries written before the vendor was normalized hold the publisher and the full name.
|
||||
rig.Ignore("Northwind Traders Ltd", "Northwind Tool 1.0 (x64)");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 2.0", "2.0", publisher: "Northwind Traders Ltd");
|
||||
rig.Tables.AddInventory("fabrikam", "Northwind Tool 2.0", "2.0", publisher: "Fabrikam Inc");
|
||||
|
||||
var entry = Assert.Single(await rig.AggregateAsync());
|
||||
|
||||
// Another vendor's program of the same name was never ignored.
|
||||
Assert.Equal("fabrikam", entry.NormalizedVendor);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Aggregate_RowWithoutADisplayName_FallsBackToTheNormalizedName(bool emptyInsteadOfMissing)
|
||||
{
|
||||
var rig = new Rig();
|
||||
var row = rig.Tables.AddInventory("northwind", "Northwind Tool", "3.1", sessions: 2, lastSeenUtc: Now);
|
||||
if (emptyInsteadOfMissing) row["DisplayName"] = string.Empty;
|
||||
else row.Remove("DisplayName");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 4.0", "4.0", sessions: 1, lastSeenUtc: Now.AddDays(-1));
|
||||
|
||||
var entry = Assert.Single(await rig.AggregateAsync());
|
||||
|
||||
Assert.Equal("northwind tool", entry.SoftwareName);
|
||||
Assert.Equal("northwind tool", entry.CanonicalName);
|
||||
Assert.Equal(3, entry.Frequency);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.3")]
|
||||
[InlineData("(x64)")]
|
||||
[InlineData("{11111111-2222-3333-4444-555555555555}")]
|
||||
[InlineData("")]
|
||||
public async Task Aggregate_RowWhoseNameCanonicalizesToNothing_IsSkipped(string displayName)
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", displayName, "1.0");
|
||||
|
||||
Assert.Empty(await rig.AggregateAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aggregate_SameNameUnderTwoVendors_IsTwoEntries_NoVendorAndThePlaceholderAreOne()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Agent 2.1", "2.1", sessions: 5);
|
||||
rig.Tables.AddInventory("fabrikam", "Agent 7.0", "7.0", sessions: 3);
|
||||
rig.Tables.AddInventory("", "Agent 1.0", "1.0", sessions: 1);
|
||||
rig.Tables.AddInventory("unknown", "Agent 1.1", "1.1", sessions: 1);
|
||||
|
||||
var entries = await rig.AggregateAsync();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { ("", 2), ("fabrikam", 3), ("northwind", 5) },
|
||||
entries.Select(e => (e.NormalizedVendor!, e.Frequency)).OrderBy(e => e.Item1, StringComparer.Ordinal));
|
||||
Assert.All(entries, e => Assert.Equal("agent", e.CanonicalName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aggregate_RowWithoutASessionCount_CountsOnce()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 1.0", "1.0").Remove("SessionCount");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 2.0", "2.0", sessions: 4);
|
||||
|
||||
Assert.Equal(5, Assert.Single(await rig.AggregateAsync()).Frequency);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Programs that occur only as Store packages
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static async Task<HashSet<string>> StoreOnlyKeysAsync(Rig rig) =>
|
||||
UnmappedSoftware.StoreOnlyKeys(await rig.Tables.Repo.Object.GetAllSoftwareInventoryAsync(UnmappedSoftware.Columns));
|
||||
|
||||
[Fact]
|
||||
public async Task StoreOnlyKeys_ProgramEveryRowReportsAsAStorePackage_IsNamedByItsSoftwareKey()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "1.0.3", registrySource: "AppX");
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "1.0.4", registrySource: "appx", tenantId: OtherTenant);
|
||||
rig.Tables.AddInventory("adatum", "Adatum Reader 2.0", "2.0", registrySource: "HKLM_64");
|
||||
rig.Tables.AddInventory("fabrikam", "Fabrikam Gadget (x86)", "1.0", registrySource: "HKLM_32");
|
||||
|
||||
var keys = await StoreOnlyKeysAsync(rig);
|
||||
|
||||
Assert.Equal(new[] { CpeMappingMatcher.SoftwareKey("northwind", "Northwind Tool") }, keys);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("HKLM_64")]
|
||||
[InlineData("HKU_1001")]
|
||||
// A row that does not say where the program was read from is no Store package.
|
||||
[InlineData("")]
|
||||
public async Task StoreOnlyKeys_ProgramAlsoInstalledAnotherWay_IsNotStoreOnly_WhereverThatRowComesFrom(string otherSource)
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "1.0.3", registrySource: "AppX");
|
||||
// The same program by vendor and name without version, on a device of another tenant.
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 3.1 (x64)", "3.1", registrySource: otherSource, tenantId: OtherTenant);
|
||||
|
||||
Assert.Empty(await StoreOnlyKeysAsync(rig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreOnlyKeys_RowWithoutTheSourceColumn_IsNoStorePackage()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "1.0.3", registrySource: "AppX").Remove("RegistrySource");
|
||||
|
||||
Assert.Empty(await StoreOnlyKeysAsync(rig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreOnlyKeys_AreDecidedPerVendorAndName()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Agent", "1.0.3", registrySource: "AppX");
|
||||
// Another vendor's program of the same name says nothing about this one.
|
||||
rig.Tables.AddInventory("fabrikam", "Agent 7.0", "7.0");
|
||||
rig.Tables.AddInventory("northwind", "Agent Console", "2.0");
|
||||
|
||||
var keys = await StoreOnlyKeysAsync(rig);
|
||||
|
||||
Assert.Equal(new[] { CpeMappingMatcher.SoftwareKey("northwind", "Agent") }, keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreOnlyKeys_RowWithoutADisplayName_GoesByItsNormalizedName_AndANameThatIsNoneIsSkipped()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool", "1.0.3", registrySource: "AppX")["DisplayName"] = string.Empty;
|
||||
rig.Tables.AddInventory("northwind", "1.0.4 (x64)", "1.0.4", registrySource: "AppX");
|
||||
|
||||
var keys = await StoreOnlyKeysAsync(rig);
|
||||
|
||||
Assert.Equal(new[] { CpeMappingMatcher.SoftwareKey("northwind", "northwind tool") }, keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreOnlyKeys_UseTheKeyTheUnmappedListFilesAProgramUnder()
|
||||
{
|
||||
var rig = new Rig();
|
||||
rig.Tables.AddInventory("northwind", "Northwind Tool 1.0 (x64)", "1.0.3", registrySource: "AppX", publisher: "Northwind Traders Ltd");
|
||||
|
||||
var keys = await StoreOnlyKeysAsync(rig);
|
||||
var listed = Assert.Single(await rig.AggregateAsync());
|
||||
|
||||
// What the operator's list shows for the program is what a caller looks it up with.
|
||||
Assert.Contains(CpeMappingMatcher.SoftwareKey(listed.NormalizedVendor, listed.SoftwareName), keys);
|
||||
}
|
||||
}
|
||||
@@ -110,4 +110,22 @@ public class VersionComparerTests
|
||||
// No constraints = all versions affected
|
||||
Assert.True(VersionComparer.IsVersionAffected("99.99.99", null, null, null, null));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// AreComparable
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("26.198", "2022.30050", false)] // releases against years: the order says nothing
|
||||
[InlineData("2022.30050", "26.198", false)]
|
||||
[InlineData("2024.1", "2023.3", true)]
|
||||
[InlineData("24.08", "25.0", true)]
|
||||
[InlineData("", "2022.30050", true)]
|
||||
[InlineData("26.198", "", true)]
|
||||
[InlineData(null, "2022.30050", true)]
|
||||
[InlineData("26.198", null, true)]
|
||||
public void AreComparable_IsFalseOnlyForAYearSchemeAgainstAReleaseScheme(string? installed, string? rangeBoundary, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, VersionComparer.AreComparable(installed, rangeBoundary));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user