@oneuptime/common 12.0.22 → 12.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/Models/DatabaseModels/Index.ts +2 -0
  2. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  3. package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +564 -0
  4. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +38 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.ts +60 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.ts +26 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.ts +26 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +917 -0
  11. package/Server/Services/NetworkDeviceAutoImportRuleService.ts +181 -0
  12. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +39 -1
  13. package/Tests/Server/Services/AutoImportScanCredentialSelect.test.ts +205 -0
  14. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +933 -0
  15. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +97 -1
  16. package/Tests/Utils/NetworkDiscovery/AutoImportRuleMatcher.test.ts +516 -0
  17. package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +272 -0
  18. package/Tests/Utils/NetworkDiscovery/ScanTargetUtil.test.ts +164 -0
  19. package/Types/NetworkAutomation/RuleRunResult.ts +84 -0
  20. package/Types/Permission.ts +47 -0
  21. package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +257 -0
  22. package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +183 -0
  23. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +95 -0
  24. package/Utils/NetworkDiscovery/DiscoveryImportEligibility.ts +66 -0
  25. package/Utils/NetworkDiscovery/ScanTargetUtil.ts +82 -0
  26. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  27. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  28. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  29. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  30. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +589 -0
  31. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -0
  32. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +28 -0
  33. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  34. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.js +35 -0
  35. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.js.map +1 -0
  36. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.js +19 -0
  37. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.js.map +1 -0
  38. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.js +19 -0
  39. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.js.map +1 -0
  40. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  41. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  42. package/build/dist/Server/Services/Index.js +2 -0
  43. package/build/dist/Server/Services/Index.js.map +1 -1
  44. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +711 -0
  45. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -0
  46. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +155 -0
  47. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -0
  48. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +26 -0
  49. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  50. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +24 -0
  51. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  52. package/build/dist/Types/Permission.js +41 -0
  53. package/build/dist/Types/Permission.js.map +1 -1
  54. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js +149 -0
  55. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -0
  56. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +121 -0
  57. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -0
  58. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +75 -0
  59. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -0
  60. package/build/dist/Utils/NetworkDiscovery/DiscoveryImportEligibility.js +59 -0
  61. package/build/dist/Utils/NetworkDiscovery/DiscoveryImportEligibility.js.map +1 -0
  62. package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js +60 -0
  63. package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js.map +1 -1
  64. package/package.json +1 -1
@@ -0,0 +1,272 @@
1
+ import {
2
+ MAX_DEVICE_DESCRIPTION_LENGTH,
3
+ MAX_DEVICE_NAME_LENGTH,
4
+ buildDeviceName,
5
+ buildFallbackDeviceName,
6
+ buildNetworkDeviceFromDiscoveredHost,
7
+ DiscoveredDeviceScanSource,
8
+ } from "../../../Utils/NetworkDiscovery/DiscoveredDeviceBuilder";
9
+ import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
10
+ import { DiscoveredNetworkDevice } from "../../../Models/DatabaseModels/NetworkDeviceDiscoveryScan";
11
+ import NetworkDeviceMonitoringMethod from "../../../Types/NetworkDevice/NetworkDeviceMonitoringMethod";
12
+ import ObjectID from "../../../Types/ObjectID";
13
+ import { describe, expect, it } from "@jest/globals";
14
+
15
+ /*
16
+ * Contract under test — one discovered host maps to one NetworkDevice the
17
+ * SAME way everywhere. The builder is the shared recipe behind the
18
+ * dashboard's Review-dialog import and the server-side auto-import rule
19
+ * engine, so what these tests pin is the device both paths must agree on:
20
+ * which host field lands in which device column, which scan credentials ride
21
+ * along (and when they must NOT), and the length ceilings that keep a
22
+ * real-world 255-octet sysName from failing the create on the slug.
23
+ */
24
+
25
+ const PROJECT_ID: ObjectID = new ObjectID(
26
+ "22222222-2222-4222-8222-222222222222",
27
+ );
28
+ const PROBE_ID: ObjectID = new ObjectID("11111111-1111-4111-8111-111111111111");
29
+
30
+ function snmpHost(
31
+ overrides: Partial<DiscoveredNetworkDevice> = {},
32
+ ): DiscoveredNetworkDevice {
33
+ return {
34
+ ipAddress: "10.0.0.5",
35
+ sysName: "core-switch-01",
36
+ sysDescr: "Cisco IOS Software, C2960X",
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ // A scan carrying every credential column the builder knows how to copy.
42
+ function fullScanSource(): DiscoveredDeviceScanSource {
43
+ return {
44
+ probeId: PROBE_ID,
45
+ snmpVersion: "V3",
46
+ snmpCommunityString: "public",
47
+ snmpPort: 1161,
48
+ snmpV3SecurityLevel: "authPriv",
49
+ snmpV3Username: "observer",
50
+ snmpV3AuthProtocol: "SHA",
51
+ snmpV3AuthKey: "auth-key-value",
52
+ snmpV3PrivProtocol: "AES",
53
+ snmpV3PrivKey: "priv-key-value",
54
+ };
55
+ }
56
+
57
+ function build(data: {
58
+ host?: DiscoveredNetworkDevice | undefined;
59
+ scan?: DiscoveredDeviceScanSource | undefined;
60
+ name?: string | undefined;
61
+ }): NetworkDevice {
62
+ return buildNetworkDeviceFromDiscoveredHost({
63
+ projectId: PROJECT_ID,
64
+ host: data.host || snmpHost(),
65
+ scan: data.scan || fullScanSource(),
66
+ name: data.name,
67
+ });
68
+ }
69
+
70
+ describe("buildNetworkDeviceFromDiscoveredHost - vendor template auto-apply flag", () => {
71
+ it("is off unless the caller asks for it", () => {
72
+ expect(build({}).autoApplyVendorHealthTemplate).toBeUndefined();
73
+ });
74
+
75
+ it("is set on an SNMP host when the caller asks (the rule engine does)", () => {
76
+ const device: NetworkDevice = buildNetworkDeviceFromDiscoveredHost({
77
+ projectId: PROJECT_ID,
78
+ host: snmpHost(),
79
+ scan: fullScanSource(),
80
+ autoApplyVendorHealthTemplate: true,
81
+ });
82
+
83
+ expect(device.autoApplyVendorHealthTemplate).toBe(true);
84
+ });
85
+
86
+ /*
87
+ * A ping-only host is never SNMP-polled, so no poll can ever fingerprint
88
+ * its vendor — the flag would be a dead toggle that reads as a promise.
89
+ */
90
+ it("stays off on a ping-only host even when requested", () => {
91
+ const device: NetworkDevice = buildNetworkDeviceFromDiscoveredHost({
92
+ projectId: PROJECT_ID,
93
+ host: snmpHost({ snmpReachable: false }),
94
+ scan: fullScanSource(),
95
+ autoApplyVendorHealthTemplate: true,
96
+ });
97
+
98
+ expect(device.autoApplyVendorHealthTemplate).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ describe("buildNetworkDeviceFromDiscoveredHost - SNMP host", () => {
103
+ it("maps identity fields: sysName to name, address to hostname, sysDescr to description", () => {
104
+ const device: NetworkDevice = build({});
105
+
106
+ expect(device.projectId?.toString()).toBe(PROJECT_ID.toString());
107
+ expect(device.name).toBe("core-switch-01");
108
+ // The address is both the hostname and the registered-host dedup key.
109
+ expect(device.hostname).toBe("10.0.0.5");
110
+ expect(device.description).toBe("Cisco IOS Software, C2960X");
111
+ expect(device.monitoringMethod).toBe(NetworkDeviceMonitoringMethod.Snmp);
112
+ });
113
+
114
+ /*
115
+ * Every credential the scan swept with must land on the device, or the
116
+ * imported device can never poll. The v3 block matters most: a v3 scan
117
+ * that imported as a credential-less device would sit unreachable with no
118
+ * error anywhere.
119
+ */
120
+ it("copies the probe and every SNMP credential from the scan source", () => {
121
+ const device: NetworkDevice = build({});
122
+
123
+ expect(device.probeId?.toString()).toBe(PROBE_ID.toString());
124
+ expect(device.snmpVersion).toBe("V3");
125
+ expect(device.snmpCommunityString).toBe("public");
126
+ expect(device.snmpPort).toBe(1161);
127
+ expect(device.snmpV3SecurityLevel).toBe("authPriv");
128
+ expect(device.snmpV3Username).toBe("observer");
129
+ expect(device.snmpV3AuthProtocol).toBe("SHA");
130
+ expect(device.snmpV3AuthKey).toBe("auth-key-value");
131
+ expect(device.snmpV3PrivProtocol).toBe("AES");
132
+ expect(device.snmpV3PrivKey).toBe("priv-key-value");
133
+ });
134
+
135
+ /*
136
+ * The scan row may hold a serialized id rather than an ObjectID instance
137
+ * (the rule engine selects its scan itself); the builder re-wraps rather
138
+ * than trusting the shape.
139
+ */
140
+ it("re-wraps the probe id so a serialized id still becomes an ObjectID", () => {
141
+ const device: NetworkDevice = build({
142
+ scan: {
143
+ ...fullScanSource(),
144
+ probeId: PROBE_ID.toString() as unknown as ObjectID,
145
+ },
146
+ });
147
+
148
+ expect(device.probeId).toBeInstanceOf(ObjectID);
149
+ expect(device.probeId?.toString()).toBe(PROBE_ID.toString());
150
+ });
151
+ });
152
+
153
+ describe("buildNetworkDeviceFromDiscoveredHost - ping-only host", () => {
154
+ /*
155
+ * A ping-only host becomes a monitor-backed device: recorded so it can
156
+ * belong to a site and appear on the topology map, with binding a monitor
157
+ * to it a separate deliberate step. Credentials it never answered to must
158
+ * not ride along.
159
+ */
160
+ it("builds a monitor-backed device with polling off and no credentials", () => {
161
+ const device: NetworkDevice = build({
162
+ host: snmpHost({ snmpReachable: false, sysName: undefined }),
163
+ });
164
+
165
+ expect(device.monitoringMethod).toBe(NetworkDeviceMonitoringMethod.Monitor);
166
+ expect(device.isPollingEnabled).toBe(false);
167
+ expect(device.probeId).toBeUndefined();
168
+ expect(device.snmpVersion).toBeUndefined();
169
+ expect(device.snmpCommunityString).toBeUndefined();
170
+ expect(device.snmpPort).toBeUndefined();
171
+ expect(device.snmpV3SecurityLevel).toBeUndefined();
172
+ expect(device.snmpV3Username).toBeUndefined();
173
+ expect(device.snmpV3AuthProtocol).toBeUndefined();
174
+ expect(device.snmpV3AuthKey).toBeUndefined();
175
+ expect(device.snmpV3PrivProtocol).toBeUndefined();
176
+ expect(device.snmpV3PrivKey).toBeUndefined();
177
+ });
178
+ });
179
+
180
+ describe("buildDeviceName", () => {
181
+ it("prefers the sysName", () => {
182
+ expect(buildDeviceName(snmpHost())).toBe("core-switch-01");
183
+ });
184
+
185
+ // A ping-only host has no SNMP identity; the address is all there is.
186
+ it("falls back to the address when sysName is missing", () => {
187
+ expect(buildDeviceName(snmpHost({ sysName: undefined }))).toBe("10.0.0.5");
188
+ });
189
+
190
+ it("falls back to the address when sysName is whitespace", () => {
191
+ expect(buildDeviceName(snmpHost({ sysName: " " }))).toBe("10.0.0.5");
192
+ });
193
+
194
+ /*
195
+ * SNMP sysName is a DisplayString of up to 255 octets, so over-long names
196
+ * are routine on real gear — and the create path THROWS on an over-long
197
+ * name (via the slug ceiling) rather than truncating, so the clamp has to
198
+ * happen here.
199
+ */
200
+ it("clamps a 255-character sysName to MAX_DEVICE_NAME_LENGTH", () => {
201
+ const longSysName: string = "x".repeat(255);
202
+ const name: string = buildDeviceName(snmpHost({ sysName: longSysName }));
203
+
204
+ expect(name.length).toBe(MAX_DEVICE_NAME_LENGTH);
205
+ expect(name).toBe(longSysName.substring(0, MAX_DEVICE_NAME_LENGTH));
206
+ });
207
+ });
208
+
209
+ describe("buildFallbackDeviceName", () => {
210
+ it("appends the address that tells name-twins apart", () => {
211
+ expect(buildFallbackDeviceName(snmpHost())).toBe(
212
+ "core-switch-01 (10.0.0.5)",
213
+ );
214
+ });
215
+
216
+ /*
217
+ * The collision fallback must fit under the SAME ceiling as the first
218
+ * attempt: the widest possible suffix is " (255.255.255.255)", so the base
219
+ * name is cut down first rather than the composed string overflowing into
220
+ * the very slug-length failure the ceiling exists to avoid.
221
+ */
222
+ it("keeps the composed name within MAX_DEVICE_NAME_LENGTH at the widest address", () => {
223
+ const name: string = buildFallbackDeviceName(
224
+ snmpHost({
225
+ sysName: "y".repeat(255),
226
+ ipAddress: "255.255.255.255",
227
+ }),
228
+ );
229
+
230
+ expect(name.length).toBeLessThanOrEqual(MAX_DEVICE_NAME_LENGTH);
231
+ expect(name.endsWith(" (255.255.255.255)")).toBe(true);
232
+ });
233
+ });
234
+
235
+ describe("description clamping", () => {
236
+ it("clamps an over-long sysDescr to MAX_DEVICE_DESCRIPTION_LENGTH", () => {
237
+ const longSysDescr: string = "d".repeat(700);
238
+ const device: NetworkDevice = build({
239
+ host: snmpHost({ sysDescr: longSysDescr }),
240
+ });
241
+
242
+ expect(device.description?.length).toBe(MAX_DEVICE_DESCRIPTION_LENGTH);
243
+ expect(device.description).toBe(
244
+ longSysDescr.substring(0, MAX_DEVICE_DESCRIPTION_LENGTH),
245
+ );
246
+ });
247
+
248
+ it("sets no description when the host has no sysDescr", () => {
249
+ const device: NetworkDevice = build({
250
+ host: snmpHost({ sysDescr: undefined }),
251
+ });
252
+
253
+ expect(device.description).toBeUndefined();
254
+ });
255
+ });
256
+
257
+ describe("the name override", () => {
258
+ /*
259
+ * The caller supplies the name so the collision retry can rebuild the SAME
260
+ * device under the fallback name without re-deciding anything else — the
261
+ * override must therefore be taken verbatim.
262
+ */
263
+ it("uses a supplied name verbatim instead of deriving one", () => {
264
+ const device: NetworkDevice = build({
265
+ name: "core-switch-01 (10.0.0.5)",
266
+ });
267
+
268
+ expect(device.name).toBe("core-switch-01 (10.0.0.5)");
269
+ // Everything else is still derived from the host as usual.
270
+ expect(device.hostname).toBe("10.0.0.5");
271
+ });
272
+ });
@@ -581,3 +581,167 @@ describe("ScanTargetUtil.isValid", () => {
581
581
  expect(ScanTargetUtil.isValid("")).toBe(false);
582
582
  });
583
583
  });
584
+
585
+ /*
586
+ * contains() is the matcher behind the auto-import rules' "Host IP is in"
587
+ * condition. It is CONTAINMENT, not membership of the sweep list — the two
588
+ * disagree at a CIDR block's network and broadcast addresses (in the block,
589
+ * never swept) — and an octet range is per-octet interval membership, not a
590
+ * numeric interval between the range's corners. Both distinctions are pinned
591
+ * here because either one, drifting, silently changes which hosts a rule
592
+ * imports.
593
+ */
594
+ describe("ScanTargetUtil.contains", () => {
595
+ describe("CIDR containment", () => {
596
+ it("contains addresses inside the block and not those outside", () => {
597
+ expect(ScanTargetUtil.contains("192.168.1.0/24", "192.168.1.1")).toBe(
598
+ true,
599
+ );
600
+ expect(ScanTargetUtil.contains("192.168.1.0/24", "192.168.1.254")).toBe(
601
+ true,
602
+ );
603
+ expect(ScanTargetUtil.contains("192.168.1.0/24", "192.168.2.1")).toBe(
604
+ false,
605
+ );
606
+ expect(ScanTargetUtil.contains("192.168.1.0/24", "192.167.1.1")).toBe(
607
+ false,
608
+ );
609
+ });
610
+
611
+ /*
612
+ * The deliberate divergence from expand(): a rule asking "is this address
613
+ * in 10.0.0.0/24" means the subnet, so the network and broadcast
614
+ * addresses are IN even though no sweep would ever probe them.
615
+ */
616
+ it("contains the network and broadcast addresses expand() excludes", () => {
617
+ expect(ScanTargetUtil.contains("192.168.1.0/24", "192.168.1.0")).toBe(
618
+ true,
619
+ );
620
+ expect(ScanTargetUtil.contains("192.168.1.0/24", "192.168.1.255")).toBe(
621
+ true,
622
+ );
623
+ expect(ScanTargetUtil.expand("192.168.1.0/24")).not.toContain(
624
+ "192.168.1.0",
625
+ );
626
+ expect(ScanTargetUtil.expand("192.168.1.0/24")).not.toContain(
627
+ "192.168.1.255",
628
+ );
629
+ });
630
+
631
+ it("treats a /32 as exactly one address", () => {
632
+ expect(ScanTargetUtil.contains("10.0.0.5/32", "10.0.0.5")).toBe(true);
633
+ expect(ScanTargetUtil.contains("10.0.0.5/32", "10.0.0.4")).toBe(false);
634
+ expect(ScanTargetUtil.contains("10.0.0.5/32", "10.0.0.6")).toBe(false);
635
+ });
636
+
637
+ it("treats a /31 as both of its addresses", () => {
638
+ expect(ScanTargetUtil.contains("10.0.0.0/31", "10.0.0.0")).toBe(true);
639
+ expect(ScanTargetUtil.contains("10.0.0.0/31", "10.0.0.1")).toBe(true);
640
+ expect(ScanTargetUtil.contains("10.0.0.0/31", "10.0.0.2")).toBe(false);
641
+ });
642
+
643
+ // Same masking rule as expand(): the block, not the address as typed.
644
+ it("masks a non-network address down to its subnet before matching", () => {
645
+ expect(ScanTargetUtil.contains("192.168.1.37/29", "192.168.1.32")).toBe(
646
+ true,
647
+ );
648
+ expect(ScanTargetUtil.contains("192.168.1.37/29", "192.168.1.39")).toBe(
649
+ true,
650
+ );
651
+ expect(ScanTargetUtil.contains("192.168.1.37/29", "192.168.1.40")).toBe(
652
+ false,
653
+ );
654
+ });
655
+
656
+ it("matches every address under /0", () => {
657
+ expect(ScanTargetUtil.contains("0.0.0.0/0", "0.0.0.0")).toBe(true);
658
+ expect(ScanTargetUtil.contains("0.0.0.0/0", "10.1.2.3")).toBe(true);
659
+ expect(ScanTargetUtil.contains("0.0.0.0/0", "255.255.255.255")).toBe(
660
+ true,
661
+ );
662
+ });
663
+
664
+ /*
665
+ * The high end of the address space, where every octet has the sign bit
666
+ * territory in play: signed arithmetic anywhere in the comparison would
667
+ * surface here first.
668
+ */
669
+ it("compares correctly at the top of the address space", () => {
670
+ expect(ScanTargetUtil.contains("255.255.255.0/24", "255.255.255.7")).toBe(
671
+ true,
672
+ );
673
+ expect(ScanTargetUtil.contains("255.255.255.0/24", "255.255.254.7")).toBe(
674
+ false,
675
+ );
676
+ });
677
+ });
678
+
679
+ describe("octet-range containment", () => {
680
+ /*
681
+ * A bare address parses as an octet range of four single values, so as a
682
+ * condition it means "exactly this host" — not a prefix, not a wildcard.
683
+ */
684
+ it("matches a bare-IP target only exactly", () => {
685
+ expect(ScanTargetUtil.contains("10.0.0.5", "10.0.0.5")).toBe(true);
686
+ expect(ScanTargetUtil.contains("10.0.0.5", "10.0.0.6")).toBe(false);
687
+ expect(ScanTargetUtil.contains("10.0.0.5", "10.0.0.50")).toBe(false);
688
+ expect(ScanTargetUtil.contains("10.0.0.5", "11.0.0.5")).toBe(false);
689
+ });
690
+
691
+ /*
692
+ * The distinction that makes octet ranges worth having: membership is
693
+ * per-octet, NOT "between the lowest and highest address". 10.17.5.10
694
+ * sits numerically between 10.16.0.51 and 10.22.255.66, but its fourth
695
+ * octet is outside 51-66, so it is not in the target — exactly as the
696
+ * sweep would never probe it.
697
+ */
698
+ it("matches per octet, not as a numeric interval", () => {
699
+ const target: string = "10.16-22.0-255.51-66";
700
+
701
+ expect(ScanTargetUtil.contains(target, "10.17.5.60")).toBe(true);
702
+ // Inside the interval's corners, outside the fourth octet's range.
703
+ expect(ScanTargetUtil.contains(target, "10.17.5.10")).toBe(false);
704
+ expect(ScanTargetUtil.contains(target, "10.17.5.67")).toBe(false);
705
+ });
706
+
707
+ it("respects the corners of every octet's range", () => {
708
+ const target: string = "10.16-22.0-255.51-66";
709
+
710
+ expect(ScanTargetUtil.contains(target, "10.16.0.51")).toBe(true);
711
+ expect(ScanTargetUtil.contains(target, "10.22.255.66")).toBe(true);
712
+ // One below / one above the second octet's bounds.
713
+ expect(ScanTargetUtil.contains(target, "10.15.0.60")).toBe(false);
714
+ expect(ScanTargetUtil.contains(target, "10.23.0.60")).toBe(false);
715
+ });
716
+
717
+ it("holds single-value octets exact while others range", () => {
718
+ const target: string = "10.16-22.0-255.51-66";
719
+
720
+ expect(ScanTargetUtil.contains(target, "11.17.5.60")).toBe(false);
721
+ expect(ScanTargetUtil.contains(target, "9.17.5.60")).toBe(false);
722
+ });
723
+ });
724
+
725
+ describe("malformed input", () => {
726
+ /*
727
+ * A condition that cannot parse matches nothing, same as the matcher's
728
+ * other conditions — never "matches everything".
729
+ */
730
+ it("returns false for a malformed target", () => {
731
+ expect(ScanTargetUtil.contains("", "10.0.0.5")).toBe(false);
732
+ expect(ScanTargetUtil.contains(" ", "10.0.0.5")).toBe(false);
733
+ expect(ScanTargetUtil.contains("abc", "10.0.0.5")).toBe(false);
734
+ expect(ScanTargetUtil.contains("10.0.0.0/99", "10.0.0.5")).toBe(false);
735
+ expect(ScanTargetUtil.contains("10.22-16.0.1", "10.20.0.1")).toBe(false);
736
+ });
737
+
738
+ it("returns false for a malformed address", () => {
739
+ expect(ScanTargetUtil.contains("10.0.0.0/24", "")).toBe(false);
740
+ expect(ScanTargetUtil.contains("10.0.0.0/24", "abc")).toBe(false);
741
+ expect(ScanTargetUtil.contains("10.0.0.0/24", "10.0.0")).toBe(false);
742
+ expect(ScanTargetUtil.contains("10.0.0.0/24", "10.0.0.1.5")).toBe(false);
743
+ expect(ScanTargetUtil.contains("10.0.0.0/24", "10.0.0.256")).toBe(false);
744
+ expect(ScanTargetUtil.contains("0.0.0.0/0", "not-an-ip")).toBe(false);
745
+ });
746
+ });
747
+ });
@@ -65,6 +65,58 @@ export interface LabelRuleRunResult {
65
65
  isTruncated: boolean;
66
66
  }
67
67
 
68
+ /*
69
+ * One run of a network device auto-import rule — automatic (the worker
70
+ * processing a completed scan) or manual ("Run Now" against the project's
71
+ * completed scans, optionally as a dry run that writes nothing).
72
+ *
73
+ * Every discovered host the run looked at lands in a bucket for the same
74
+ * reason as above: "nothing matched", "everything that matched is already in
75
+ * the inventory", and "an exclusion rule vetoed it" are very different
76
+ * answers to "why did this import zero devices".
77
+ */
78
+ export interface AutoImportRuleRunResult {
79
+ // Discovered hosts the run evaluated, across every scan it read.
80
+ hostsEvaluated: number;
81
+ // Of those, the ones an import rule matched (exclusions already applied).
82
+ hostsMatched: number;
83
+ // Hosts an exclusion rule vetoed.
84
+ hostsExcluded: number;
85
+ /*
86
+ * Matched hosts skipped because a device with that address already exists
87
+ * — including one created earlier in this same run from a duplicate row or
88
+ * an overlapping scan. Re-running a rule is idempotent, not additive.
89
+ */
90
+ hostsSkippedAlreadyRegistered: number;
91
+ // Devices actually created. Always zero on a dry run.
92
+ devicesCreated: number;
93
+ // Matched hosts whose create threw. Logged server-side, never fatal.
94
+ devicesFailed: number;
95
+ /*
96
+ * True when the run stopped at the DEVICE cap with matched hosts left
97
+ * over. Running again continues: already-imported hosts are skipped.
98
+ */
99
+ isTruncated: boolean;
100
+ /*
101
+ * True when the project has more completed scans than one manual run
102
+ * reads (the newest MAX_SCANS_PER_AUTO_IMPORT_RULE_RUN). Distinct from
103
+ * isTruncated because the advice differs: re-running re-reads the same
104
+ * newest scans, so hosts that appear ONLY in older scans stay unread.
105
+ */
106
+ hasMoreScans: boolean;
107
+ // True when this run was a dry run: full evaluation, no writes.
108
+ isDryRun: boolean;
109
+ /*
110
+ * Up to MAX_MATCHED_IP_SAMPLE addresses the run imported (or, on a dry
111
+ * run, would import) — the operator's "which hosts is this rule actually
112
+ * claiming" answer, without shipping a 30k-element array.
113
+ */
114
+ matchedIpAddressSample: Array<string>;
115
+ }
116
+
117
+ // How many addresses matchedIpAddressSample carries at most.
118
+ export const MAX_MATCHED_IP_SAMPLE: number = 50;
119
+
68
120
  /*
69
121
  * A count off the wire. An absent or non-numeric field reads as zero rather
70
122
  * than as NaN: a summary line that says "NaN devices" is worse than one that
@@ -118,4 +170,36 @@ export class RuleRunResultUtil {
118
170
  isTruncated: source["isTruncated"] === true,
119
171
  };
120
172
  }
173
+
174
+ public static parseAutoImportRuleRunResult(
175
+ json: JSONObject | undefined | null,
176
+ ): AutoImportRuleRunResult {
177
+ const source: JSONObject = json || {};
178
+
179
+ const sample: Array<string> = Array.isArray(
180
+ source["matchedIpAddressSample"],
181
+ )
182
+ ? (source["matchedIpAddressSample"] as Array<unknown>)
183
+ .filter((value: unknown) => {
184
+ return typeof value === "string";
185
+ })
186
+ .slice(0, MAX_MATCHED_IP_SAMPLE)
187
+ : [];
188
+
189
+ return {
190
+ hostsEvaluated: readCount(source, "hostsEvaluated"),
191
+ hostsMatched: readCount(source, "hostsMatched"),
192
+ hostsExcluded: readCount(source, "hostsExcluded"),
193
+ hostsSkippedAlreadyRegistered: readCount(
194
+ source,
195
+ "hostsSkippedAlreadyRegistered",
196
+ ),
197
+ devicesCreated: readCount(source, "devicesCreated"),
198
+ devicesFailed: readCount(source, "devicesFailed"),
199
+ isTruncated: source["isTruncated"] === true,
200
+ hasMoreScans: source["hasMoreScans"] === true,
201
+ isDryRun: source["isDryRun"] === true,
202
+ matchedIpAddressSample: sample as Array<string>,
203
+ };
204
+ }
121
205
  }
@@ -600,6 +600,11 @@ enum Permission {
600
600
  EditNetworkDeviceLabelRule = "EditNetworkDeviceLabelRule",
601
601
  ReadNetworkDeviceLabelRule = "ReadNetworkDeviceLabelRule",
602
602
 
603
+ CreateNetworkDeviceAutoImportRule = "CreateNetworkDeviceAutoImportRule",
604
+ DeleteNetworkDeviceAutoImportRule = "DeleteNetworkDeviceAutoImportRule",
605
+ EditNetworkDeviceAutoImportRule = "EditNetworkDeviceAutoImportRule",
606
+ ReadNetworkDeviceAutoImportRule = "ReadNetworkDeviceAutoImportRule",
607
+
603
608
  // Podman Host Label Rule Permissions
604
609
  CreatePodmanHostLabelRule = "CreatePodmanHostLabelRule",
605
610
  DeletePodmanHostLabelRule = "DeletePodmanHostLabelRule",
@@ -11883,6 +11888,48 @@ export class PermissionHelper {
11883
11888
  group: PermissionGroup.Telemetry,
11884
11889
  },
11885
11890
 
11891
+ // Network Device Auto Import Rule Permissions
11892
+ {
11893
+ permission: Permission.CreateNetworkDeviceAutoImportRule,
11894
+ title: "Create Network Device Auto Import Rule",
11895
+ description:
11896
+ "This permission can create Network Device Auto Import Rules in this project.",
11897
+ isAssignableToTenant: true,
11898
+ isAccessControlPermission: false,
11899
+ isRolePermission: false,
11900
+ group: PermissionGroup.Telemetry,
11901
+ },
11902
+ {
11903
+ permission: Permission.DeleteNetworkDeviceAutoImportRule,
11904
+ title: "Delete Network Device Auto Import Rule",
11905
+ description:
11906
+ "This permission can delete Network Device Auto Import Rules of this project.",
11907
+ isAssignableToTenant: true,
11908
+ isAccessControlPermission: false,
11909
+ isRolePermission: false,
11910
+ group: PermissionGroup.Telemetry,
11911
+ },
11912
+ {
11913
+ permission: Permission.EditNetworkDeviceAutoImportRule,
11914
+ title: "Edit Network Device Auto Import Rule",
11915
+ description:
11916
+ "This permission can edit Network Device Auto Import Rules of this project.",
11917
+ isAssignableToTenant: true,
11918
+ isAccessControlPermission: false,
11919
+ isRolePermission: false,
11920
+ group: PermissionGroup.Telemetry,
11921
+ },
11922
+ {
11923
+ permission: Permission.ReadNetworkDeviceAutoImportRule,
11924
+ title: "Read Network Device Auto Import Rule",
11925
+ description:
11926
+ "This permission can read Network Device Auto Import Rules of this project.",
11927
+ isAssignableToTenant: true,
11928
+ isAccessControlPermission: false,
11929
+ isRolePermission: false,
11930
+ group: PermissionGroup.Telemetry,
11931
+ },
11932
+
11886
11933
  // Podman Host Label Rule Permissions
11887
11934
  {
11888
11935
  permission: Permission.CreatePodmanHostLabelRule,