From 4ba6f7619fc8f2bb66d5b7fbe3ff9464748de1f1 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:26:22 +0200 Subject: [PATCH] fix: detect port changes on one address of a DNS target A DNS target is compared as one logical unit whose ports are merged across its resolved addresses, so a port that opened on one address while another already exposed it, or closed on one address while another kept it open, produced no change, incident or notification, also in the default address-sensitive mode. Address-sensitive mode now compares, for a port that is positive in both the baseline and the scan, which addresses in both DNS answers expose it. Each difference is a port-address change with the address in the new address field, reported as for example "edge.example tcp/22 on 2001:db8::10: not-open -> open". Ports without address evidence are unknown and take their addresses from the next complete scan, closures on a down host are left to its host change, incomplete scans report openings on complete addresses and defer closures, and findings a complete scan cannot compare are retired rather than recovered. Aggregate mode ignores per-address ports. Baseline samples converge only on the same per-address ports. Accepting a port-address incident updates the baseline evidence and host view; accepting a closure recomputes the service from the remaining addresses and resolves a service incident reporting it. The console labels the new kind with its address. Existing baselines can report per-address drift that built up before the upgrade. Fixes #1222 --- .../docs/reference/api-compatibility.md | 11 + .../user-guide/jobs-baselines-incidents.md | 47 +- internal/engine/dns_comparison_test.go | 7 +- internal/engine/dns_port_address_test.go | 657 ++++++++++++++++++ internal/engine/engine.go | 275 +++++++- internal/model/model.go | 9 +- internal/store/cycle.go | 7 +- internal/store/incident_actions.go | 145 ++++ internal/store/incident_port_address_test.go | 183 +++++ src/main.tsx | 6 +- src/pages/Activity.test.tsx | 8 + src/pages/Activity.tsx | 4 +- src/pages/JobDetail.tsx | 6 +- src/pages/JobEditor.behavior.test.tsx | 3 +- src/pages/JobEditor.tsx | 4 +- src/status.test.ts | 11 +- src/status.ts | 12 + src/types.ts | 6 +- 18 files changed, 1370 insertions(+), 31 deletions(-) create mode 100644 internal/engine/dns_port_address_test.go create mode 100644 internal/store/incident_port_address_test.go diff --git a/docs/src/content/docs/reference/api-compatibility.md b/docs/src/content/docs/reference/api-compatibility.md index 4412141b..2ba5fb7c 100644 --- a/docs/src/content/docs/reference/api-compatibility.md +++ b/docs/src/content/docs/reference/api-compatibility.md @@ -27,6 +27,17 @@ that only need scan metadata should use `/summary`, then request paginated results or host evidence separately when needed. This avoids loading large snapshots just to show scan status and timestamps. +## Per-address port changes + +A change in scan `changes`, an incident, a pending change, or an event can +have the kind `port-address`: a port of a DNS target opened or closed on one of +the target's resolved addresses while another address exposed it. `target` is +the DNS name and the new `address` key is the resolved address. `old` and `new` +are a positive port state or `not-open`, and `key` has the form +`port-address||||
`. Other change kinds have no +`address`. Clients that handle change kinds individually should treat an +unknown kind as a generic change. + ## Business units v0.20.0 adds business units to every installation. The routes and response diff --git a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md index 1c18ecc3..e8d40563 100644 --- a/docs/src/content/docs/user-guide/jobs-baselines-incidents.md +++ b/docs/src/content/docs/user-guide/jobs-baselines-incidents.md @@ -12,14 +12,39 @@ logical targets while each resolved effective address is shown separately in host evidence. If a DNS target cannot be resolved, EdgeWatch still scans other targets it could resolve, marks the overall scan incomplete, and protects the unresolved target's baseline from false removals until a complete scan succeeds. -By default, DNS answer membership and each resolved host's reachability are -part of the monitored baseline. Jobs can opt into **Aggregate port and service -surface** in the job editor when DNS answers rotate routinely. Aggregate mode -continues comparing the logical DNS target's positive ports and service -fingerprints, but intentionally ignores answer additions/removals and individual -backend reachability; IP and CIDR targets remain address-sensitive. Per-IP scan -evidence is retained for investigation. This is a security-relevant change and -requires an explicit new baseline. +By default, in **Address-sensitive** mode, DNS answer membership, each resolved +host's reachability, and the resolved addresses that expose each port are part +of the monitored baseline. A port that opens on one address while another +address of the name already exposes it, or that closes on one address while +another keeps it open, is reported as a change on that address, for example +`edge.example tcp/22 on 2001:db8::10: not-open -> open`. A port that no +address exposed before, or that no address exposes any more, is a port change, +and an address that joins or leaves the DNS answer is a DNS change; the ports +of an address that joined the answer are compared after you accept that DNS +change. A port missing from an address +whose host is down is reported as that host's state change. While an address's +scan coverage is incomplete, a port that opened on another, complete address +is still reported, and closures wait for a complete scan. Baseline samples +converge only when they agree on which addresses expose each port. A baseline +port without recorded addresses, such as one accepted from an incident, takes +its addresses from the next complete scan without a report. + +Jobs can opt into **Aggregate port and service surface** in the job editor when +DNS answers rotate routinely. Aggregate mode continues comparing the logical DNS +target's positive ports and service fingerprints, but intentionally ignores +answer additions/removals, individual backend reachability, and which address +exposes a port. IP and CIDR targets remain address-sensitive, so list addresses +as IP targets when they need per-address monitoring under a name whose answers +rotate. Per-IP scan evidence is retained for investigation. This is a +security-relevant change and requires an explicit new baseline. + +Releases before per-address port comparison merged a DNS target's ports +across its addresses. After an upgrade, the first scans of an +address-sensitive job can report per-address changes that built up before the +upgrade; review them and accept the ones that are expected. A DNS target's +baseline that is still being learned during the upgrade may need one more +sample. + Schedules use five-field cron syntax in the selected IANA timezone. New jobs default to the deployment `timezone` from `config.yaml`, or to the browser's timezone when it is omitted. New jobs receive an optional 30-minute @@ -49,7 +74,11 @@ From **Incidents**, administrators and operators can: accepts that port; accepting the port alone leaves its service for a separate decision. Until you accept a service for that port, its fingerprint is reported as a change, also after a suppression or a scan - without a fingerprint, and never enters the baseline on its own. + without a fingerprint, and never enters the baseline on its own. Accepting + a change on one address of a DNS target updates which addresses the + baseline expects to expose the port. Accepting a closure also recomputes + the port's expected service from the remaining addresses and accepts a + reported service change that matches it. - **Suppress 1 scan** to defer the alert for the next successful scan. If the change remains, it is reported again afterward. diff --git a/internal/engine/dns_comparison_test.go b/internal/engine/dns_comparison_test.go index d1c0e473..70e7f53f 100644 --- a/internal/engine/dns_comparison_test.go +++ b/internal/engine/dns_comparison_test.go @@ -34,8 +34,11 @@ func TestAggregateDNSComparisonIgnoresAnswerRotationButKeepsPortAndServiceChange if baseline.Hash() == rotated.Hash() { t.Fatal("historical snapshot hash unexpectedly ignored DNS and effective-host changes") } - if got := snapshotHashForDNSMode(baseline, config.DNSComparisonAddressSensitive); got != baseline.Hash() { - t.Fatal("address-sensitive mode no longer uses the historical snapshot hash") + if got := snapshotHashForDNSMode(baseline, config.DNSComparisonAddressSensitive); got == baseline.Hash() { + t.Fatal("address-sensitive hash ignores which addresses expose a DNS target's port") + } + if got := snapshotHashForDNSMode(snapshot("open"), config.DNSComparisonAddressSensitive); got != snapshot("open").Hash() { + t.Fatal("address-sensitive hash of a snapshot without DNS port evidence differs from the snapshot hash") } if got, want := snapshotHashForDNSMode(rotated, job.DNSComparisonMode), snapshotHashForDNSMode(baseline, job.DNSComparisonMode); got != want { t.Fatalf("aggregate hash changed after DNS answer rotation: %s != %s", got, want) diff --git a/internal/engine/dns_port_address_test.go b/internal/engine/dns_port_address_test.go new file mode 100644 index 00000000..28cb1027 --- /dev/null +++ b/internal/engine/dns_port_address_test.go @@ -0,0 +1,657 @@ +package engine + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/crypt0rr/edgewatch/internal/config" + "github.com/crypt0rr/edgewatch/internal/model" + "github.com/crypt0rr/edgewatch/internal/scanner" + "github.com/crypt0rr/edgewatch/internal/store" + "github.com/crypt0rr/edgewatch/internal/store/storetest" +) + +const ( + dualStackTarget = "edge.example" + dualStackV4 = "192.0.2.10" + dualStackV6 = "2001:db8::10" +) + +func portAddressChangeKey(port int, address string) string { + return fmt.Sprintf("port-address|%s|tcp|%d|%s", dualStackTarget, port, address) +} + +// dualStackSnapshot is the complete result the scanner records for a DNS +// target whose IPv4 and IPv6 addresses are both up: one logical unit whose +// ports name the addresses exposing them, and per-address host evidence. +func dualStackSnapshot(exposure map[int][]string) model.Snapshot { + addresses := []string{dualStackV4, dualStackV6} + unit := model.Unit{Target: dualStackTarget, Protocol: "tcp", Addresses: addresses} + observed := map[string][]model.PortObservation{} + for port, on := range exposure { + unit.Ports = append(unit.Ports, model.PortState{Port: port, State: "open", Evidence: append([]string(nil), on...)}) + for _, address := range on { + observed[address] = append(observed[address], model.PortObservation{Port: port, State: "open", Verification: "confirmed"}) + } + } + snapshot := model.Snapshot{ + Scopes: []model.Scope{{Target: dualStackTarget, Protocol: "tcp", Ports: "22,443"}}, + DNS: map[string][]string{dualStackTarget: addresses}, + Units: []model.Unit{unit}, + } + for _, address := range addresses { + snapshot.Hosts = append(snapshot.Hosts, model.HostObservation{ + Address: address, SourceTargets: []string{dualStackTarget}, DNSNames: []string{dualStackTarget}, Status: "up", + Protocols: []model.ProtocolObservation{{Protocol: "tcp", Status: "up", DiscoveryState: "up", ScannedPorts: "22,443", ScannedPortCount: 2, Ports: observed[address]}}, + }) + } + snapshot.Normalize() + return snapshot +} + +func dualStackState(baseline model.Snapshot, job config.Job) model.JobState { + baseline = cloneSnapshot(baseline) + completeHostDiscoveryStates(&baseline) + return model.JobState{ + Baseline: &baseline, BaselineScanID: "baseline", BaselineConfigHash: job.SecurityHash(), + Pending: map[string]model.Pending{}, Incidents: map[string]model.Incident{}, + Suppressed: map[string]int{}, SuppressedChanges: map[string]model.Change{}, + FingerprintCandidates: map[string]model.ValueCount{}, + } +} + +func dualStackJob(mode string) config.Job { + return config.NormalizeJob(config.Job{ + Name: "dual-stack", Targets: []string{dualStackTarget}, DNSComparisonMode: mode, MaxExpandedHosts: 4, + TCP: &config.Protocol{Ports: "22,443", Mode: "connect"}, + Baseline: config.Baseline{Samples: 1}, Change: config.Change{Confirmations: 1}, + }) +} + +func dualStackScan(id string, job config.Job, snapshot model.Snapshot) model.Scan { + return model.Scan{ID: id, Job: job.Name, Status: "success", ConfigHash: job.SecurityHash(), Snapshot: snapshot, FinishedAt: time.Now().UTC()} +} + +func changeKeys(changes []model.Change) []string { + keys := make([]string, 0, len(changes)) + for _, change := range changes { + keys = append(keys, change.Key) + } + return keys +} + +func TestDiffReportsPortChangesOnOneAddressOfDNSTarget(t *testing.T) { + baseline := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}) + current := dualStackSnapshot(map[int][]string{22: {dualStackV4, dualStackV6}, 443: {dualStackV6}}) + changes := diffForJob(baseline, current, false, dualStackJob(config.DNSComparisonAddressSensitive)) + want := []model.Change{ + {Key: portAddressChangeKey(22, dualStackV6), Kind: "port-address", Severity: "critical", Target: dualStackTarget, Protocol: "tcp", Port: 22, Address: dualStackV6, Old: "not-open", New: "open"}, + {Key: portAddressChangeKey(443, dualStackV4), Kind: "port-address", Severity: "info", Target: dualStackTarget, Protocol: "tcp", Port: 443, Address: dualStackV4, Old: "open", New: "not-open"}, + } + if !slices.Equal(changes, want) { + t.Fatalf("per-address changes = %#v, want %#v", changes, want) + } + if !slices.Equal(Diff(baseline, current, false), want) { + t.Fatalf("default Diff = %#v, want %#v", Diff(baseline, current, false), want) + } + if got := model.ChangeSummary(want[0]); got != "edge.example tcp/22 on 2001:db8::10: not-open -> open" { + t.Fatalf("summary = %q", got) + } + if got := model.ChangeSummary(want[1]); got != "edge.example tcp/443 on 192.0.2.10: open -> not-open" { + t.Fatalf("summary = %q", got) + } + + // An unchanged per-address surface, IP literal targets, and an aggregate + // job report nothing. + if changes := Diff(baseline, baseline, false); len(changes) != 0 { + t.Fatalf("unchanged snapshot changes = %#v", changes) + } + if changes := diffForJob(baseline, current, false, dualStackJob(config.DNSComparisonAggregate)); len(changes) != 0 { + t.Fatalf("aggregate mode reported per-address changes: %#v", changes) + } +} + +func TestPortAddressChangeOpensAndRecoversIncident(t *testing.T) { + job := dualStackJob(config.DNSComparisonAddressSensitive) + baseline := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}) + state := dualStackState(baseline, job) + opened := dualStackSnapshot(map[int][]string{22: {dualStackV4, dualStackV6}, 443: {dualStackV4, dualStackV6}}) + events, changes, err := processSuccessWithChanges(&state, job, dualStackScan("opened", job, opened)) + key := portAddressChangeKey(22, dualStackV6) + if err != nil || len(events) != 1 || events[0].Type != "changes-detected" || !slices.Equal(changeKeys(changes), []string{key}) { + t.Fatalf("opened on IPv6: events %#v changes %#v err %v", events, changes, err) + } + if !strings.Contains(FormatEvent(events[0]), "🔴 EdgeWatch: 1 baseline change confirmed") || !strings.Contains(FormatEvent(events[0]), "- [critical] edge.example tcp/22 on 2001:db8::10: not-open -> open") { + t.Fatalf("notification = %q", FormatEvent(events[0])) + } + if _, ok := state.Incidents[key]; !ok { + t.Fatalf("incidents = %#v", state.Incidents) + } + if got := state.Baseline.Units[0].Ports[0].Evidence; !slices.Equal(got, []string{dualStackV4}) { + t.Fatalf("an incident changed the baseline evidence: %#v", got) + } + + events, changes, err = processSuccessWithChanges(&state, job, dualStackScan("closed-again", job, baseline)) + if err != nil || len(changes) != 0 || len(events) != 1 || events[0].Type != "changes-recovered" || events[0].Changes[0].Key != key || events[0].Changes[0].New != "not-open" { + t.Fatalf("closed again on IPv6: events %#v changes %#v err %v", events, changes, err) + } + if len(state.Incidents) != 0 { + t.Fatalf("incidents after recovery = %#v", state.Incidents) + } +} + +func TestPortAddressFindingIsRetiredWhenItCannotBeCompared(t *testing.T) { + job := dualStackJob(config.DNSComparisonAddressSensitive) + baseline := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}) + closedOn := portAddressChangeKey(443, dualStackV6) + openedOn := portAddressChangeKey(22, dualStackV6) + seed := func() model.JobState { + state := dualStackState(baseline, job) + state.Incidents[closedOn] = model.Incident{Change: model.Change{Key: closedOn, Kind: "port-address", Severity: "info", Target: dualStackTarget, Protocol: "tcp", Port: 443, Address: dualStackV6, Old: "open", New: "not-open"}} + state.Incidents[openedOn] = model.Incident{Change: model.Change{Key: openedOn, Kind: "port-address", Severity: "critical", Target: dualStackTarget, Protocol: "tcp", Port: 22, Address: dualStackV6, Old: "not-open", New: "open"}} + return state + } + + // When the logical port closes, the port change is the transition; the + // closure on one address must not be announced as a recovery to open. + state := seed() + gone := dualStackSnapshot(map[int][]string{22: {dualStackV4, dualStackV6}}) + events, changes, err := processSuccessWithChanges(&state, job, dualStackScan("port-gone", job, gone)) + if err != nil || !slices.Equal(changeKeys(changes), []string{openedOn, "port|edge.example|tcp|443"}) { + t.Fatalf("logical closure: changes %#v err %v", changes, err) + } + for _, event := range events { + if event.Type == "changes-recovered" { + t.Fatalf("retired finding was reported as a recovery: %#v", event) + } + } + if _, ok := state.Incidents[closedOn]; ok { + t.Fatalf("per-address finding of a closed port stayed open: %#v", state.Incidents) + } + + // An address whose host discovery completed down reports its host state + // only. + state = seed() + down := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4}}) + down.Hosts[1] = model.HostObservation{Address: dualStackV6, Status: "unreachable", StatusReason: "no-response", Protocols: []model.ProtocolObservation{{Protocol: "tcp", Status: "unreachable", StatusReason: "no-response", DiscoveryState: "down"}}} + events, changes, err = processSuccessWithChanges(&state, job, dualStackScan("ipv6-down", job, down)) + if err != nil || !slices.Equal(changeKeys(changes), []string{"host|" + dualStackV6}) { + t.Fatalf("down address: events %#v changes %#v err %v", events, changes, err) + } + if len(events) != 1 || events[0].Type != "changes-detected" || len(state.Incidents) != 1 { + t.Fatalf("down address events %#v incidents %#v", events, state.Incidents) + } + + // An address that left the DNS answer is reported as dns-removed only. + state = seed() + moved := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4}}) + moved.DNS[dualStackTarget] = []string{dualStackV4} + moved.Units[0].Addresses = []string{dualStackV4} + moved.Hosts = moved.Hosts[:1] + events, changes, err = processSuccessWithChanges(&state, job, dualStackScan("ipv6-left-answer", job, moved)) + if err != nil || !slices.Equal(changeKeys(changes), []string{"dns|edge.example|" + dualStackV6}) || len(events) != 1 || events[0].Type != "changes-detected" { + t.Fatalf("address left the answer: events %#v changes %#v err %v", events, changes, err) + } + if _, ok := state.Incidents[closedOn]; ok { + t.Fatalf("finding of an address outside the answer stayed open: %#v", state.Incidents) + } +} + +func TestAggregateDNSComparisonIgnoresPortAddresses(t *testing.T) { + job := dualStackJob(config.DNSComparisonAggregate) + baseline := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}) + current := dualStackSnapshot(map[int][]string{22: {dualStackV4, dualStackV6}, 443: {dualStackV6}}) + state := dualStackState(baseline, job) + stale := portAddressChangeKey(22, dualStackV6) + state.Pending[stale] = model.Pending{Change: model.Change{Key: stale, Kind: "port-address", Target: dualStackTarget, Protocol: "tcp", Port: 22, Address: dualStackV6, Old: "not-open", New: "open"}, Count: 1} + events, changes, err := processSuccessWithChanges(&state, job, dualStackScan("aggregate", job, current)) + if err != nil || len(events) != 0 || len(changes) != 0 || len(state.Pending) != 0 { + t.Fatalf("aggregate mode: events %#v changes %#v pending %#v err %v", events, changes, state.Pending, err) + } + if got := state.Baseline.Units[0].Ports[0].Evidence; !slices.Equal(got, []string{dualStackV4}) { + t.Fatalf("aggregate mode changed the baseline evidence: %#v", got) + } + if snapshotHashForDNSMode(baseline, config.DNSComparisonAggregate) != snapshotHashForDNSMode(current, config.DNSComparisonAggregate) { + t.Fatal("aggregate convergence identity depends on port evidence") + } +} + +func TestPortWithoutAddressEvidenceIsLearnedWithoutAChange(t *testing.T) { + job := dualStackJob(config.DNSComparisonAddressSensitive) + legacy := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}) + for index := range legacy.Units[0].Ports { + legacy.Units[0].Ports[index].Evidence = nil + } + current := dualStackSnapshot(map[int][]string{22: {dualStackV4, dualStackV6}, 443: {dualStackV6}}) + if changes := Diff(legacy, current, false); len(changes) != 0 { + t.Fatalf("baseline without evidence reported changes: %#v", changes) + } + withoutEvidence := current + withoutEvidence.Units = []model.Unit{{Target: dualStackTarget, Protocol: "tcp", Addresses: current.Units[0].Addresses, Ports: []model.PortState{{Port: 22, State: "open"}, {Port: 443, State: "open"}}}} + if changes := Diff(dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}), withoutEvidence, false); len(changes) != 0 { + t.Fatalf("snapshot without evidence reported changes: %#v", changes) + } + + state := dualStackState(legacy, job) + events, changes, err := processSuccessWithChanges(&state, job, dualStackScan("learn", job, current)) + if err != nil || len(events) != 0 || len(changes) != 0 { + t.Fatalf("first scan against a baseline without evidence: events %#v changes %#v err %v", events, changes, err) + } + if got := state.Baseline.Units[0].Ports; !slices.Equal(got[0].Evidence, []string{dualStackV4, dualStackV6}) || !slices.Equal(got[1].Evidence, []string{dualStackV6}) { + t.Fatalf("learned evidence = %#v", got) + } + // The learned addresses are compared from now on. + _, changes, err = processSuccessWithChanges(&state, job, dualStackScan("compare", job, dualStackSnapshot(map[int][]string{22: {dualStackV4, dualStackV6}, 443: {dualStackV4, dualStackV6}}))) + if err != nil || !slices.Equal(changeKeys(changes), []string{portAddressChangeKey(443, dualStackV4)}) { + t.Fatalf("changes after learning = %#v, %v", changes, err) + } + + // A down address leaves the evidence unknown until a later scan. + state = dualStackState(legacy, job) + down := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4}}) + down.Hosts[1] = model.HostObservation{Address: dualStackV6, Status: "unreachable", StatusReason: "no-response", Protocols: []model.ProtocolObservation{{Protocol: "tcp", Status: "unreachable", StatusReason: "no-response", DiscoveryState: "down"}}} + if _, _, err := processSuccessWithChanges(&state, job, dualStackScan("down", job, down)); err != nil { + t.Fatal(err) + } + for _, port := range state.Baseline.Units[0].Ports { + if len(port.Evidence) != 0 { + t.Fatalf("evidence learned while an address was down: %#v", state.Baseline.Units[0].Ports) + } + } +} + +func TestIncompleteDNSScanReportsPortAddressAdditionsAndDefersRemovals(t *testing.T) { + job := dualStackJob(config.DNSComparisonAddressSensitive) + baseline := dualStackSnapshot(map[int][]string{22: {dualStackV6}, 443: {dualStackV4, dualStackV6}}) + state := dualStackState(baseline, job) + deferred := portAddressChangeKey(443, dualStackV6) + state.Incidents[deferred] = model.Incident{Change: model.Change{Key: deferred, Kind: "port-address", Severity: "info", Target: dualStackTarget, Protocol: "tcp", Port: 443, Address: dualStackV6, Old: "open", New: "not-open"}} + partial := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4}}) + partial.Hosts[1] = model.HostObservation{Address: dualStackV6, Status: "down", StatusReason: "no-response"} + events, changes, err := processSuccessWithChanges(&state, job, dualStackScan("partial", job, partial)) + added := portAddressChangeKey(22, dualStackV4) + if err != nil || !slices.Equal(changeKeys(changes), []string{added}) { + t.Fatalf("incomplete scan changes = %#v, %v", changes, err) + } + if len(events) != 2 || events[0].Type != "changes-detected" || events[1].Type != "scan-incomplete" { + t.Fatalf("incomplete scan events = %#v", events) + } + if _, ok := state.Incidents[added]; !ok { + t.Fatalf("addition on a complete address did not open an incident: %#v", state.Incidents) + } + if _, ok := state.Incidents[deferred]; !ok || len(state.Incidents) != 2 { + t.Fatalf("incomplete coverage changed a protected finding: %#v", state.Incidents) + } + if _, reported := state.Incidents[portAddressChangeKey(22, dualStackV6)]; reported { + t.Fatal("closure on an incomplete address was reported") + } +} + +// A scan that is incomplete only for another target compares edge.example +// fully, but it must not take a missing per-address comparison as a recovery +// or retire the finding; the next complete scan does that. +func TestIncompleteScanKeepsUncomparedPortAddressFinding(t *testing.T) { + job := dualStackJob(config.DNSComparisonAddressSensitive) + const other = "198.51.100.7" + withOther := func(snapshot model.Snapshot, reachable bool) model.Snapshot { + snapshot.Scopes = append(snapshot.Scopes, model.Scope{Target: other, Protocol: "tcp", Ports: "22,443"}) + host := model.HostObservation{Address: other, Status: "up", Protocols: []model.ProtocolObservation{{Protocol: "tcp", Status: "up", DiscoveryState: "up"}}} + if !reachable { + host = model.HostObservation{Address: other, Status: "down", StatusReason: "no-response"} + } + snapshot.Hosts = append(snapshot.Hosts, host) + snapshot.Normalize() + return snapshot + } + state := dualStackState(withOther(dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}), true), job) + key := portAddressChangeKey(443, dualStackV6) + state.Incidents[key] = model.Incident{Change: model.Change{Key: key, Kind: "port-address", Severity: "info", Target: dualStackTarget, Protocol: "tcp", Port: 443, Address: dualStackV6, Old: "open", New: "not-open"}} + + gone := dualStackSnapshot(map[int][]string{22: {dualStackV4}}) + _, changes, err := processSuccessWithChanges(&state, job, dualStackScan("other-incomplete", job, withOther(gone, false))) + if err != nil || !slices.Equal(changeKeys(changes), []string{"port|edge.example|tcp|443"}) { + t.Fatalf("incomplete scan changes = %#v, %v", changes, err) + } + if incident, ok := state.Incidents[key]; !ok || incident.RecoveryCount != 0 { + t.Fatalf("incomplete scan advanced an uncompared finding: %#v", state.Incidents) + } + if _, _, err := processSuccessWithChanges(&state, job, dualStackScan("complete", job, withOther(gone, true))); err != nil { + t.Fatal(err) + } + if _, ok := state.Incidents[key]; ok { + t.Fatalf("complete scan kept an uncompared finding: %#v", state.Incidents) + } +} + +func TestAddressSensitiveBaselineConvergesOnStablePortAddresses(t *testing.T) { + first := dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}}) + moved := dualStackSnapshot(map[int][]string{22: {dualStackV6}, 443: {dualStackV4, dualStackV6}}) + + state := model.JobState{FingerprintCandidates: map[string]model.ValueCount{}} + advanceCandidateWithDNSMode(&state, scan("first", first), 2, false, config.DNSComparisonAddressSensitive) + advanceCandidateWithDNSMode(&state, scan("moved", moved), 2, false, config.DNSComparisonAddressSensitive) + if state.Baseline != nil || state.CandidateCount != 1 { + t.Fatalf("samples with different port addresses converged: candidate %d baseline %#v", state.CandidateCount, state.Baseline) + } + events := advanceCandidateWithDNSMode(&state, scan("repeat", moved), 2, false, config.DNSComparisonAddressSensitive) + if len(events) != 1 || events[0].Type != "baseline-complete" { + t.Fatalf("identical samples did not converge: %#v", events) + } + if got := state.Baseline.Units[0].Ports[0].Evidence; !slices.Equal(got, []string{dualStackV6}) { + t.Fatalf("converged baseline evidence = %#v", got) + } + + aggregate := model.JobState{FingerprintCandidates: map[string]model.ValueCount{}} + advanceCandidateWithDNSMode(&aggregate, scan("first", first), 2, false, config.DNSComparisonAggregate) + if events := advanceCandidateWithDNSMode(&aggregate, scan("moved", moved), 2, false, config.DNSComparisonAggregate); len(events) != 1 { + t.Fatalf("aggregate samples did not converge across port addresses: %#v", events) + } +} + +func TestAcceptAndSuppressPortAddressIncidents(t *testing.T) { + ctx := context.Background() + db, err := store.Open(storetest.FreshPath(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + job := dualStackJob(config.DNSComparisonAddressSensitive) + job.Schedule, job.Timezone = "0 * * * *", "UTC" + record, err := defaultTenant(db).CreateJob(ctx, job) + if err != nil { + t.Fatal(err) + } + e := &Engine{Store: db} + finalize := func(id string, snapshot model.Snapshot) ([]model.Event, model.Scan) { + t.Helper() + result := model.Scan{ID: id, JobID: record.ID, JobRevision: record.Revision, Job: record.Job.Name, Status: "success", ConfigHash: record.Job.SecurityHash(), Snapshot: snapshot, StartedAt: time.Now().UTC(), FinishedAt: time.Now().UTC()} + events, err := e.FinalizeManagedScan(ctx, record.ID, record.Job, &result, nil) + if err != nil { + t.Fatal(err) + } + // Reminders of the incidents that stay open are not under test here. + return slices.DeleteFunc(events, func(event model.Event) bool { return event.Type == "changes-reminder" }), result + } + if events, _ := finalize("baseline", dualStackSnapshot(map[int][]string{22: {dualStackV4}, 443: {dualStackV4, dualStackV6}})); len(events) != 1 || events[0].Type != "baseline-complete" { + t.Fatalf("baseline events = %#v", events) + } + drifted := dualStackSnapshot(map[int][]string{22: {dualStackV4, dualStackV6}, 443: {dualStackV6}}) + if events, result := finalize("drifted", drifted); len(events) != 1 || len(result.Changes) != 2 { + t.Fatalf("drifted scan events %#v changes %#v", events, result.Changes) + } + opened, closed := portAddressChangeKey(22, dualStackV6), portAddressChangeKey(443, dualStackV4) + audit := func(key string) store.AuditEntry { + return store.AuditEntry{Action: "incident.accepted", Detail: record.ID + ":" + key} + } + + // A suppressed per-address incident stays hidden for one scan and is + // reported again while the address still differs. + if _, err := defaultTenant(db).SuppressIncidentWithAudit(ctx, record.ID, record.Job.Name, closed, store.AuditEntry{Action: "incident.suppressed", Detail: closed}); err != nil { + t.Fatal(err) + } + if events, _ := finalize("suppressed", drifted); len(events) != 0 { + t.Fatalf("suppressed scan events = %#v", events) + } + if events, _ := finalize("reopened", drifted); len(events) != 1 || events[0].Type != "changes-detected" || len(events[0].Changes) != 1 || events[0].Changes[0].Key != closed { + t.Fatalf("reopened events = %#v", events) + } + + for _, key := range []string{opened, closed} { + events, err := defaultTenant(db).AcceptIncidentWithAudit(ctx, record.ID, record.Job.Name, key, audit(key)) + if err != nil || len(events) != 1 || events[0].Type != "incident-accepted" || events[0].Changes[0].Key != key { + t.Fatalf("accept %s = %#v, %v", key, events, err) + } + } + state, err := defaultTenant(db).RuntimeState(ctx, record.ID) + if err != nil { + t.Fatal(err) + } + if len(state.Incidents) != 0 { + t.Fatalf("incidents after acceptance = %#v", state.Incidents) + } + ports := state.Baseline.Units[0].Ports + if !slices.Equal(ports[0].Evidence, []string{dualStackV4, dualStackV6}) || !slices.Equal(ports[1].Evidence, []string{dualStackV6}) { + t.Fatalf("accepted baseline evidence = %#v", ports) + } + if events, result := finalize("identical", drifted); len(events) != 0 || len(result.Changes) != 0 { + t.Fatalf("identical scan after acceptance: events %#v changes %#v", events, result.Changes) + } +} + +// fakeDualStackNmap answers for the invocation's address family, reporting +// the open TCP ports listed in a per-family file next to the script and, when +// a per-family product file exists, a probed service on each of them. +const fakeDualStackNmap = `#!/bin/sh +family=ipv4 +for arg in "$@"; do + case "$arg" in + --version|-V) echo "Nmap version 7.95"; exit 0 ;; + -6) family=ipv6 ;; + esac +done +address='192.0.2.10'; files="${0%/*}/v4" +if [ "$family" = ipv6 ]; then + address='2001:db8::10'; files="${0%/*}/v6" +fi +ports=''; product='' +read -r ports < "${files}ports" +if [ -f "${files}product" ]; then + read -r product < "${files}product" +fi +printf '
' "$address" "$family" +for port in $ports; do + if [ -n "$product" ]; then + printf '' "$port" "$product" + else + printf '' "$port" + fi +done +printf '' +` + +type fakeDualStackResolver struct{} + +func (fakeDualStackResolver) LookupIP(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP(dualStackV6), net.ParseIP(dualStackV4)}, nil +} + +// fakeDualStackScanner scans edge.example with fakeDualStackNmap. The +// returned function sets the open ports, and optionally the service product, +// that later scans observe on the v4 or v6 address. +func fakeDualStackScanner(t *testing.T) (*scanner.Nmap, func(family, ports, product string)) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "nmap") + if err := os.WriteFile(path, []byte(fakeDualStackNmap), 0o700); err != nil { + t.Fatal(err) + } + n := scanner.New(path) + n.Resolver = fakeDualStackResolver{} + return n, func(family, ports, product string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, family+"ports"), []byte(ports+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if product != "" { + if err := os.WriteFile(filepath.Join(dir, family+"product"), []byte(product+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + } +} + +// The scanner merges a DNS target's ports across its addresses on both the +// direct and the resumable path. Each must still report a port that opens or +// closes on one address while another address exposes it. +func TestScannedDNSTargetReportsPortChangesOnOneAddress(t *testing.T) { + ctx := context.Background() + n, observe := fakeDualStackScanner(t) + expose := func(v4, v6 string) { + t.Helper() + observe("v4", v4, "") + observe("v6", v6, "") + } + job := dualStackJob("") + scanners := map[string]func() model.Snapshot{ + "direct": func() model.Snapshot { + t.Helper() + snapshot, err := n.Scan(ctx, job) + if err != nil { + t.Fatal(err) + } + return snapshot + }, + "resumable": func() model.Snapshot { + t.Helper() + plan, err := n.Plan(ctx, job) + if err != nil { + t.Fatal(err) + } + if len(plan.Units) != 2 { + t.Fatalf("plan units = %#v, want one unit per address family", plan.Units) + } + fragments := make([]model.Snapshot, 0, len(plan.Units)) + for _, unit := range plan.Units { + fragment, err := n.ScanWorkUnit(ctx, job, unit, nil) + if err != nil { + t.Fatal(err) + } + fragments = append(fragments, fragment) + } + return scanner.MergeWorkSnapshots(plan, fragments) + }, + } + observed := map[string][]string{} + for _, name := range []string{"direct", "resumable"} { + t.Run(name, func(t *testing.T) { + run := scanners[name] + state := model.JobState{Pending: map[string]model.Pending{}, Incidents: map[string]model.Incident{}, Suppressed: map[string]int{}, SuppressedChanges: map[string]model.Change{}, FingerprintCandidates: map[string]model.ValueCount{}} + expose("22 443", "443") + if events, _, err := processSuccessWithChanges(&state, job, dualStackScan("baseline", job, run())); err != nil || len(events) != 1 || events[0].Type != "baseline-complete" { + t.Fatalf("baseline: %#v, %v", events, err) + } + + expose("22 443", "22 443") + events, changes, err := processSuccessWithChanges(&state, job, dualStackScan("ipv6-opens-22", job, run())) + if err != nil || len(events) != 1 || events[0].Type != "changes-detected" || !slices.Equal(changeKeys(changes), []string{portAddressChangeKey(22, dualStackV6)}) { + t.Fatalf("22 opens on IPv6: events %#v changes %#v err %v", events, changes, err) + } + if change := changes[0]; change.Address != dualStackV6 || change.Old != "not-open" || change.New != "open" || change.Severity != "critical" { + t.Fatalf("22 opens on IPv6 change = %#v", change) + } + + expose("22", "22 443") + events, changes, err = processSuccessWithChanges(&state, job, dualStackScan("ipv4-closes-443", job, run())) + closed := portAddressChangeKey(443, dualStackV4) + if err != nil || len(events) != 1 || len(events[0].Changes) != 1 || events[0].Changes[0].Key != closed { + t.Fatalf("443 closes on IPv4: events %#v err %v", events, err) + } + if !slices.Equal(changeKeys(changes), []string{portAddressChangeKey(22, dualStackV6), closed}) || len(state.Incidents) != 2 { + t.Fatalf("443 closes on IPv4: changes %#v incidents %#v", changes, state.Incidents) + } + observed[name] = changeKeys(changes) + }) + } + if !slices.Equal(observed["direct"], observed["resumable"]) { + t.Fatalf("direct changes %v differ from resumable changes %v", observed["direct"], observed["resumable"]) + } +} + +// Accepting a per-address change must leave the baseline matching what the +// scanner reports next, including the service fingerprint it merges across +// the target's addresses. +func TestAcceptedPortAddressChangesMatchTheNextScan(t *testing.T) { + ctx := context.Background() + db, err := store.Open(storetest.FreshPath(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + n, observe := fakeDualStackScanner(t) + job := dualStackJob("") + job.Schedule, job.Timezone = "0 * * * *", "UTC" + job.TCP.ServiceDetection = true + job = config.NormalizeJob(job) + record, err := defaultTenant(db).CreateJob(ctx, job) + if err != nil { + t.Fatal(err) + } + e := &Engine{Store: db} + scans := 0 + finalize := func() ([]model.Event, []model.Change) { + t.Helper() + snapshot, err := n.Scan(ctx, record.Job) + if err != nil { + t.Fatal(err) + } + scans++ + result := model.Scan{ID: fmt.Sprintf("scan-%d", scans), JobID: record.ID, JobRevision: record.Revision, Job: record.Job.Name, Status: "success", ConfigHash: record.Job.SecurityHash(), Snapshot: snapshot, StartedAt: time.Now().UTC(), FinishedAt: time.Now().UTC()} + events, err := e.FinalizeManagedScan(ctx, record.ID, record.Job, &result, nil) + if err != nil { + t.Fatal(err) + } + return slices.DeleteFunc(events, func(event model.Event) bool { return event.Type == "changes-reminder" }), result.Changes + } + accept := func(key string) []model.Change { + t.Helper() + events, err := defaultTenant(db).AcceptIncidentWithAudit(ctx, record.ID, record.Job.Name, key, store.AuditEntry{Action: "incident.accepted", Detail: key}) + if err != nil || len(events) != 1 { + t.Fatalf("accept %s = %#v, %v", key, events, err) + } + return events[0].Changes + } + + observe("v4", "22 443", "nginx") + observe("v6", "443", "Caddy") + if events, _ := finalize(); len(events) != 1 || events[0].Type != "baseline-complete" { + t.Fatalf("baseline events = %#v", events) + } + + // 443 closes on IPv6. Its merged fingerprint loses Caddy, so the scan + // also reports a service change, which accepting the closure resolves. + observe("v6", "", "") + _, changes := finalize() + closed, service443 := portAddressChangeKey(443, dualStackV6), "service|edge.example|tcp|443" + if !slices.Equal(changeKeys(changes), []string{closed, service443}) { + t.Fatalf("443 closes on IPv6: changes %#v", changes) + } + if accepted := accept(closed); !slices.Equal(changeKeys(accepted), []string{closed, service443}) { + t.Fatalf("accepting the closure = %#v", accepted) + } + if events, changes := finalize(); len(events) != 0 || len(changes) != 0 { + t.Fatalf("identical scan after accepting the closure: events %#v changes %#v", events, changes) + } + + // 22 opens on IPv6 with another fingerprint. The new exposure and the + // changed fingerprint are separate decisions. + observe("v6", "22", "") + _, changes = finalize() + opened, service22 := portAddressChangeKey(22, dualStackV6), "service|edge.example|tcp|22" + if !slices.Equal(changeKeys(changes), []string{opened, service22}) { + t.Fatalf("22 opens on IPv6: changes %#v", changes) + } + if accepted := accept(opened); !slices.Equal(changeKeys(accepted), []string{opened}) { + t.Fatalf("accepting the opening = %#v", accepted) + } + if events, changes := finalize(); len(events) != 0 || !slices.Equal(changeKeys(changes), []string{service22}) { + t.Fatalf("identical scan after accepting the opening: events %#v changes %#v", events, changes) + } + accept(service22) + if events, changes := finalize(); len(events) != 0 || len(changes) != 0 { + t.Fatalf("identical scan after accepting the service: events %#v changes %#v", events, changes) + } + state, err := defaultTenant(db).RuntimeState(ctx, record.ID) + if err != nil { + t.Fatal(err) + } + if ports := state.Baseline.Units[0].Ports; !slices.Equal(ports[0].Evidence, []string{dualStackV4, dualStackV6}) || !slices.Equal(ports[1].Evidence, []string{dualStackV4}) { + t.Fatalf("accepted baseline evidence = %#v", ports) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3b08bb34..8248b821 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -213,6 +213,12 @@ func processSuccessWithReminderSettings(state *model.JobState, job config.Job, s // a service removal; retire any existing service finding for that port here // so applyChanges cannot mislabel the missing service key as a recovery. retireClosedPortServiceChanges(state, scan.Snapshot) + // The same holds for a port's exposure on one address of a DNS target: + // a finding that this scan could not compare is retired, not recovered. + retireUncomparedPortAddressChanges(state, scan.Snapshot, scopeChanged, job) + if !scopeChanged && job.DNSComparisonMode != config.DNSComparisonAggregate { + learnMissingPortEvidence(state.Baseline, scan.Snapshot, completedDownAddressesByProtocolForJob(*state.Baseline, scan.Snapshot, job)) + } previous := make(map[string]model.Incident, len(state.Incidents)) sendRemindersNow := remindersEnabled && incidentReminderDue(state.LastIncidentReminderAt, reminderSettings.Cadence, now) if sendRemindersNow { @@ -401,6 +407,11 @@ func processIncompleteSuccess(state *model.JobState, job config.Job, scan model. for key := range deferredLearning { protectedKeys[key] = true } + // Unlike a complete scan, an incomplete one does not retire a + // per-address finding it could not compare; the finding waits. + for key := range uncomparedPortAddressKeys(state, scan.Snapshot, state.BaselineConfigHash != scan.ConfigHash, job) { + protectedKeys[key] = true + } events = append(events, applyChangesWithIncomplete(state, job.Name, scan.ID, changes, job.Change.Confirmations, scan.FinishedAt, protectedKeys)...) } message := incompleteScanError(scan.Snapshot) @@ -414,7 +425,13 @@ func processIncompleteSuccess(state *model.JobState, job config.Job, scan model. // incompletePositiveAddition reports whether a positive port addition is // attributable only to effective addresses whose coverage completed for that // protocol. Legacy evidence without addresses remains conservatively blocked. +// A port that opened on one address of a DNS target names that address, so +// it is reported when that address completed; a closure on one address is +// deferred like any other removal. func incompletePositiveAddition(change model.Change, snapshot model.Snapshot, incomplete incompleteCoverage) bool { + if change.Kind == "port-address" { + return isPositivePortState(change.New) && !incompleteCoverageHas(incomplete, change.Address, change.Protocol) + } if change.Kind != "port" || !isPositivePortState(change.New) { return false } @@ -1106,7 +1123,7 @@ func advanceCandidateWithDNSMode(state *model.JobState, scan model.Scan, require func snapshotHashForDNSMode(snapshot model.Snapshot, dnsMode string) string { if dnsMode != config.DNSComparisonAggregate { - return snapshot.Hash() + return addressSensitiveSnapshotHash(snapshot) } stable := cloneSnapshot(snapshot) dnsTargets := dnsTargetsInSnapshot(stable) @@ -1139,6 +1156,42 @@ func snapshotHashForDNSMode(snapshot model.Snapshot, dnsMode string) string { return stable.Hash() } +// addressSensitiveSnapshotHash extends the snapshot hash, which leaves out +// port evidence, with the addresses that expose each positive port of a DNS +// target. Address-sensitive mode compares those addresses, so samples whose +// ports differ between a name's addresses must not converge into one +// baseline. A snapshot without such evidence keeps the snapshot hash. +func addressSensitiveSnapshotHash(snapshot model.Snapshot) string { + hash := snapshot.Hash() + var exposures []string + for _, unit := range snapshot.Units { + if !isDNSComparisonTarget(unit.Target) { + continue + } + for _, port := range unit.Ports { + if !isPositivePortState(port.State) || len(port.Evidence) == 0 { + continue + } + addresses := make([]string, 0, len(port.Evidence)) + for address := range canonicalAddressSet(port.Evidence) { + addresses = append(addresses, address) + } + sort.Strings(addresses) + exposures = append(exposures, fmt.Sprintf("%s\x00%s\x00%d\x00%s", unit.Target, unit.Protocol, port.Port, strings.Join(addresses, ","))) + } + } + if len(exposures) == 0 { + return hash + } + sort.Strings(exposures) + payload, _ := json.Marshal(struct { + Snapshot string `json:"snapshot"` + PortAddresses []string `json:"port_addresses"` + }{Snapshot: hash, PortAddresses: exposures}) + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + func fingerprintKey(target, protocol string, port int) string { return fmt.Sprintf("service|%s|%s|%d", target, protocol, port) } @@ -1454,11 +1507,222 @@ func diffWithDownAddresses(old, new model.Snapshot, intersectionOnly bool, downB } out = append(out, c) } + _, portAddresses := comparePortAddresses(old, new, intersectionOnly, downByProtocol) + out = append(out, portAddresses...) out = append(out, hostStateChanges(old, new, intersectionOnly)...) sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) return out } +func portAddressKey(target, protocol string, port int, address string) string { + return fmt.Sprintf("port-address|%s|%s|%d|%s", target, protocol, port, address) +} + +// comparePortAddresses compares which resolved addresses of a DNS target +// expose each of its ports. The target is one logical unit whose ports are +// merged across its DNS answer, so the port comparison cannot see a port that +// opens on one address while another address already exposes it, or that +// closes on one address while another keeps it open. Each such difference is +// one port-address change. +// +// Only a port that is positive in both snapshots, with address evidence in +// both, is compared, and only on addresses in both DNS answers: a port that +// appears or disappears altogether is a port change, an answer that changed +// is a dns-added or dns-removed change, and a port without evidence, as in +// an older baseline, has unknown addresses. A port missing from an address +// whose host discovery completed down is that host's state change. +// +// It returns the key of every comparison it made, whether or not the address +// changed, together with the changes. +func comparePortAddresses(old, current model.Snapshot, intersectionOnly bool, downByProtocol map[string]map[string]struct{}) (map[string]struct{}, []model.Change) { + compared := map[string]struct{}{} + var changes []model.Change + oldUnits := unitMap(old) + for _, unit := range current.Units { + if !isDNSComparisonTarget(unit.Target) { + continue + } + oldUnit, ok := oldUnits[unit.Target+"\x00"+unit.Protocol] + if !ok { + continue + } + oldAnswer := canonicalAddressSet(old.DNS[unit.Target]) + var addresses []string + for address := range canonicalAddressSet(current.DNS[unit.Target]) { + if _, inBoth := oldAnswer[address]; inBoth && net.ParseIP(address) != nil { + addresses = append(addresses, address) + } + } + if len(addresses) == 0 { + continue + } + sort.Strings(addresses) + oldPorts := make(map[int]model.PortState, len(oldUnit.Ports)) + for _, port := range oldUnit.Ports { + oldPorts[port.Port] = port + } + for _, port := range unit.Ports { + oldPort, ok := oldPorts[port.Port] + if !ok || !isPositivePortState(oldPort.State) || !isPositivePortState(port.State) || len(oldPort.Evidence) == 0 || len(port.Evidence) == 0 { + continue + } + if intersectionOnly && !inBothScopes(old, current, item{Kind: "port", Target: unit.Target, Protocol: unit.Protocol, Port: port.Port}) { + continue + } + before, after := canonicalAddressSet(oldPort.Evidence), canonicalAddressSet(port.Evidence) + for _, address := range addresses { + _, wasExposed := before[address] + _, isExposed := after[address] + if !isExposed && explicitlyDownForProtocol(downByProtocol, unit.Protocol, address) { + // A host that is down shows no ports, so this scan cannot + // compare the address. Its host state change reports it. + continue + } + key := portAddressKey(unit.Target, unit.Protocol, port.Port, address) + compared[key] = struct{}{} + if wasExposed == isExposed { + continue + } + change := model.Change{Key: key, Kind: "port-address", Target: unit.Target, Protocol: unit.Protocol, Port: port.Port, Address: address} + if isExposed { + change.Old, change.New, change.Severity = "not-open", port.State, "critical" + if port.State == "open|filtered" { + change.Severity = "warning" + } + } else { + change.Old, change.New, change.Severity = oldPort.State, "not-open", "info" + } + changes = append(changes, change) + } + } + } + return compared, changes +} + +// uncomparedPortAddressKeys returns the tracked port-address findings that +// this scan could not compare, for example because the address left the DNS +// answer, its host is down, or the port is no longer positive. The scan did +// not observe such an address returning to its expected state, so the +// finding must not count towards a recovery. +func uncomparedPortAddressKeys(state *model.JobState, current model.Snapshot, intersectionOnly bool, job config.Job) map[string]bool { + uncompared := map[string]bool{} + if state.Baseline == nil { + return uncompared + } + compared := map[string]struct{}{} + if job.DNSComparisonMode != config.DNSComparisonAggregate { + compared, _ = comparePortAddresses(*state.Baseline, current, intersectionOnly, completedDownAddressesByProtocolForJob(*state.Baseline, current, job)) + } + mark := func(key string, change model.Change) { + if change.Kind != "port-address" { + return + } + if _, ok := compared[key]; !ok { + uncompared[key] = true + } + } + for key, pending := range state.Pending { + mark(key, pending.Change) + } + for key, incident := range state.Incidents { + mark(key, incident.Change) + } + for key, change := range state.SuppressedChanges { + mark(key, change) + } + return uncompared +} + +// retireUncomparedPortAddressChanges removes the port-address findings that +// a complete scan could not compare. +func retireUncomparedPortAddressChanges(state *model.JobState, current model.Snapshot, intersectionOnly bool, job config.Job) { + for key := range uncomparedPortAddressKeys(state, current, intersectionOnly, job) { + delete(state.Pending, key) + delete(state.Incidents, key) + delete(state.Suppressed, key) + delete(state.SuppressedChanges, key) + } +} + +// learnMissingPortEvidence records which addresses expose a positive baseline +// port of a DNS target when the baseline does not say. An incident names only +// the logical target, so a port accepted from one has no address evidence, +// and neither has a port of a baseline recorded before ports carried it. +// Such a port's addresses are unknown rather than changed: this complete scan +// supplies them, limited to addresses in both DNS answers, so that later +// scans can compare them. A unit with an address whose host discovery +// completed down is left for a later scan. +func learnMissingPortEvidence(baseline *model.Snapshot, current model.Snapshot, downByProtocol map[string]map[string]struct{}) { + if baseline == nil { + return + } + currentUnits := unitMap(current) + for unitIndex := range baseline.Units { + unit := &baseline.Units[unitIndex] + if !isDNSComparisonTarget(unit.Target) { + continue + } + currentUnit, ok := currentUnits[unit.Target+"\x00"+unit.Protocol] + if !ok { + continue + } + baselineAnswer := canonicalAddressSet(baseline.DNS[unit.Target]) + currentAnswer := canonicalAddressSet(current.DNS[unit.Target]) + down := false + for address := range currentAnswer { + if explicitlyDownForProtocol(downByProtocol, unit.Protocol, address) { + down = true + break + } + } + if down { + continue + } + currentPorts := make(map[int]model.PortState, len(currentUnit.Ports)) + for _, port := range currentUnit.Ports { + currentPorts[port.Port] = port + } + for portIndex := range unit.Ports { + port := &unit.Ports[portIndex] + observed, ok := currentPorts[port.Port] + if len(port.Evidence) != 0 || !isPositivePortState(port.State) || !ok || !isPositivePortState(observed.State) { + continue + } + var evidence []string + for address := range canonicalAddressSet(observed.Evidence) { + _, inBaseline := baselineAnswer[address] + _, inCurrent := currentAnswer[address] + if inBaseline && inCurrent { + evidence = append(evidence, address) + } + } + if len(evidence) == 0 { + continue + } + sort.Strings(evidence) + port.Evidence = evidence + } + } +} + +func canonicalAddress(address string) string { + address = strings.TrimSpace(address) + if ip := net.ParseIP(address); ip != nil { + return ip.String() + } + return address +} + +func canonicalAddressSet(addresses []string) map[string]struct{} { + set := make(map[string]struct{}, len(addresses)) + for _, address := range addresses { + if address = canonicalAddress(address); address != "" { + set[address] = struct{}{} + } + } + return set +} + func inBothScopes(a, b model.Snapshot, v item) bool { if v.Kind == "dns" { return hasTarget(a, v.Target) && hasTarget(b, v.Target) @@ -1895,6 +2159,11 @@ func changeWithinScopeWithDNSAddresses(snapshot model.Snapshot, change model.Cha switch change.Kind { case "port": return scopeAllows(snapshot, change.Target, change.Protocol, change.Port, false) + case "port-address": + if job.DNSComparisonMode == config.DNSComparisonAggregate { + return false + } + return scopeAllows(snapshot, change.Target, change.Protocol, change.Port, false) case "service": return scopeAllows(snapshot, change.Target, change.Protocol, change.Port, true) case "host": @@ -2005,6 +2274,10 @@ func filterDNSAggregateChanges(old, current model.Snapshot, changes []model.Chan if _, dnsTarget := dnsTargets[change.Target]; dnsTarget { continue } + case "port-address": + // Aggregate mode compares only the logical port surface, not which + // of a name's addresses exposes each port. + continue case "host": if dnsOnlyAddressInSnapshot(change.Target, dnsAddresses, old) && !snapshotHasNonDNSAddressScope(current, change.Target) { continue diff --git a/internal/model/model.go b/internal/model/model.go index 8c76f7c9..eff3389a 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -282,8 +282,11 @@ type Change struct { Target string `json:"target"` Protocol string `json:"protocol,omitempty"` Port int `json:"port,omitempty"` - Old string `json:"old,omitempty"` - New string `json:"new,omitempty"` + // Address is the resolved address of a DNS target on which a port opened + // or closed (kind port-address). Target remains the configured name. + Address string `json:"address,omitempty"` + Old string `json:"old,omitempty"` + New string `json:"new,omitempty"` } type Pending struct { @@ -624,6 +627,8 @@ func ChangeSummary(c Change) string { return fmt.Sprintf("%s %s: %s", c.Target, c.Kind, nonempty(c.New, c.Old)) case "service": return fmt.Sprintf("%s %s/%d service: %s -> %s", c.Target, c.Protocol, c.Port, c.Old, c.New) + case "port-address": + return fmt.Sprintf("%s %s/%d on %s: %s -> %s", c.Target, c.Protocol, c.Port, c.Address, c.Old, c.New) default: return fmt.Sprintf("%s %s/%d: %s -> %s", c.Target, c.Protocol, c.Port, c.Old, c.New) } diff --git a/internal/store/cycle.go b/internal/store/cycle.go index 381b6a80..6d838987 100644 --- a/internal/store/cycle.go +++ b/internal/store/cycle.go @@ -621,11 +621,12 @@ func isPositiveCyclePortState(state string) bool { } // changeExpectsOpenPort reports whether a tracked change claims its port is -// currently open: a port change to a positive state, or a service change -// whose new value is a fingerprint rather than the absence of the port. +// currently open: a port change to a positive state, on the target or on one +// of its addresses, or a service change whose new value is a fingerprint +// rather than the absence of the port. func changeExpectsOpenPort(change model.Change) bool { switch change.Kind { - case "port": + case "port", "port-address": return isPositiveCyclePortState(change.New) case "service": value := strings.TrimSpace(change.New) diff --git a/internal/store/incident_actions.go b/internal/store/incident_actions.go index 82ece02e..e4e8b2fc 100644 --- a/internal/store/incident_actions.go +++ b/internal/store/incident_actions.go @@ -83,6 +83,19 @@ func (ts *TenantStore) AcceptIncidentWithExpectedOutboxAndAudit(ctx context.Cont return nil, err } } + // Accepting a port's closure on one address of a DNS target recomputes + // the port's expected service from the addresses that still expose it. + // A service incident that reported exactly that service is resolved by + // the same decision; left open, the next scan would announce it as a + // recovery to the old fingerprint. + if serviceKey, serviceIncident, ok := serviceIncidentResolvedByPortAddress(state, change); ok { + resolved := serviceIncident.Change + if resolved.Key == "" { + resolved.Key = serviceKey + } + accepted = append(accepted, resolved) + acceptedKeys = append(acceptedKeys, serviceKey) + } // The accepted comparison state is now a deliberate runtime overlay on // the immutable source scan. Host explorer endpoints use this marker to // avoid serving the source scan's stale expected ports or services. @@ -202,6 +215,39 @@ func newPortIncidentForService(state *model.JobState, key string, change model.C return "", model.Incident{}, false } +// serviceIncidentResolvedByPortAddress finds the open service incident of the +// port whose closure on one address was just accepted, when the baseline's +// recomputed service now matches what that incident reported. +func serviceIncidentResolvedByPortAddress(state *model.JobState, change model.Change) (string, model.Incident, bool) { + if change.Kind != "port-address" || change.New != "not-open" || state.Baseline == nil { + return "", model.Incident{}, false + } + key := fingerprintCandidateKey(change) + incident, open := state.Incidents[key] + if !open || incident.Change.Kind != "service" { + return "", model.Incident{}, false + } + unitIndex := findUnit(state.Baseline, change.Target, change.Protocol) + if unitIndex < 0 { + return "", model.Incident{}, false + } + for _, port := range state.Baseline.Units[unitIndex].Ports { + if port.Port != change.Port { + continue + } + expected := port.Service + if expected == "" { + // A fingerprint that disappeared is reported as not-open. + expected = "not-open" + } + if incident.Change.New != expected { + break + } + return key, incident, true + } + return "", model.Incident{}, false +} + func baselineHasPort(snapshot *model.Snapshot, change model.Change) bool { unitIndex := findUnit(snapshot, change.Target, change.Protocol) if unitIndex < 0 { @@ -367,6 +413,8 @@ func applyAcceptedChangeWithHostIndex(snapshot *model.Snapshot, change model.Cha switch change.Kind { case "port": return acceptPortChangeWithIndex(snapshot, change, hostIndex) + case "port-address": + return acceptPortAddressChange(snapshot, change) case "service": return acceptServiceChangeWithIndex(snapshot, change, hostIndex) case "host": @@ -576,6 +624,103 @@ func acceptPortChangeWithIndex(snapshot *model.Snapshot, change model.Change, ho return nil } +// acceptPortAddressChange records that a port of a DNS target is now, or is +// no longer, exposed on one of the target's resolved addresses. The logical +// port stays in the baseline. Its address evidence changes, the expected host +// view of the address follows, and a closure recomputes the port's expected +// service from the addresses that still expose it, as an accepted host-down +// change does. +func acceptPortAddressChange(snapshot *model.Snapshot, change model.Change) error { + address := normalizedAcceptedTarget(change.Address) + if strings.TrimSpace(change.Target) == "" || strings.TrimSpace(change.Protocol) == "" || change.Port < 1 || change.Port > 65535 || net.ParseIP(address) == nil { + return fmt.Errorf("%w: invalid port address change", ErrUnsupportedIncidentChange) + } + opened := positiveAcceptedPortState(change.New) + if !opened && change.New != "not-open" { + return fmt.Errorf("%w: invalid port address state", ErrUnsupportedIncidentChange) + } + unitIndex := findUnit(snapshot, change.Target, change.Protocol) + portIndex := -1 + if unitIndex >= 0 { + for i := range snapshot.Units[unitIndex].Ports { + if snapshot.Units[unitIndex].Ports[i].Port == change.Port { + portIndex = i + break + } + } + } + if portIndex < 0 { + // A port that has left the baseline is not exposed on any address. + if !opened { + return nil + } + return fmt.Errorf("%w: baseline port is missing", ErrUnsupportedIncidentChange) + } + port := &snapshot.Units[unitIndex].Ports[portIndex] + if len(port.Evidence) == 0 { + // The port's addresses are unknown, and the next complete scan records + // them. There is no expectation to change. + return nil + } + evidence := make([]string, 0, len(port.Evidence)+1) + for _, existing := range port.Evidence { + if existing = normalizedAcceptedTarget(existing); existing != address { + evidence = append(evidence, existing) + } + } + if opened { + evidence = append(evidence, address) + } + port.Evidence = evidence + syncAcceptedPortAddressHost(snapshot, change, address, port.State, opened) + if !opened && len(evidence) > 0 { + if service, found := acceptedServiceForEvidence(snapshot, change.Protocol, change.Port, evidence); found { + port.Service = service + } + } + snapshot.Normalize() + return nil +} + +// syncAcceptedPortAddressHost adds the accepted port to, or removes it from, +// the expected host view of one address. Other addresses of the target keep +// their own evidence. +func syncAcceptedPortAddressHost(snapshot *model.Snapshot, change model.Change, address, state string, opened bool) { + if opened { + target := normalizedAcceptedTarget(change.Target) + ensureAcceptedHostProtocols(snapshot, change, acceptedHostAddressIndex{target: {address: {}}}) + } + for hostIndex := range snapshot.Hosts { + host := &snapshot.Hosts[hostIndex] + if normalizedAcceptedTarget(host.Address) != address { + continue + } + for protocolIndex := range host.Protocols { + protocol := &host.Protocols[protocolIndex] + if !strings.EqualFold(protocol.Protocol, change.Protocol) { + continue + } + ports := protocol.Ports[:0] + found := false + for _, port := range protocol.Ports { + if port.Port != change.Port { + ports = append(ports, port) + continue + } + if opened { + port.State = state + ports = append(ports, port) + found = true + } + } + if opened && !found { + ports = append(ports, model.PortObservation{Port: change.Port, State: state}) + } + protocol.Ports = ports + } + } +} + func acceptServiceChange(snapshot *model.Snapshot, change model.Change) error { return acceptServiceChangeWithIndex(snapshot, change, buildAcceptedHostAddressIndex(snapshot)) } diff --git a/internal/store/incident_port_address_test.go b/internal/store/incident_port_address_test.go new file mode 100644 index 00000000..101ccbb2 --- /dev/null +++ b/internal/store/incident_port_address_test.go @@ -0,0 +1,183 @@ +package store + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + + "github.com/crypt0rr/edgewatch/internal/model" +) + +const ( + portAddressV4 = "192.0.2.10" + portAddressV6 = "2001:db8::10" +) + +// portAddressBaseline exposes 22 on the IPv4 address and 443 on both +// addresses of a dual-stack DNS target, with a different fingerprint on each. +func portAddressBaseline() model.Snapshot { + nginx := &model.ServiceObservation{Name: "https", Product: "nginx", Version: "1.25", Method: "probed"} + caddy := &model.ServiceObservation{Name: "https", Product: "Caddy", Version: "2.8", Method: "probed"} + snapshot := model.Snapshot{ + Scopes: []model.Scope{{Target: "edge.example", Protocol: "tcp", Ports: "22,443", ServiceDetection: true}}, + DNS: map[string][]string{"edge.example": {portAddressV4, portAddressV6}}, + Units: []model.Unit{{Target: "edge.example", Protocol: "tcp", Addresses: []string{portAddressV4, portAddressV6}, Ports: []model.PortState{ + {Port: 22, State: "open", Evidence: []string{portAddressV4}}, + {Port: 443, State: "open", Service: portAddressFingerprint(caddy) + " || " + portAddressFingerprint(nginx), Evidence: []string{portAddressV4, portAddressV6}}, + }}}, + Hosts: []model.HostObservation{ + {Address: portAddressV4, SourceTargets: []string{"edge.example"}, DNSNames: []string{"edge.example"}, Protocols: []model.ProtocolObservation{{Protocol: "tcp", Ports: []model.PortObservation{{Port: 22, State: "open"}, {Port: 443, State: "open", Service: nginx}}}}}, + {Address: portAddressV6, SourceTargets: []string{"edge.example"}, DNSNames: []string{"edge.example"}, Protocols: []model.ProtocolObservation{{Protocol: "tcp", Ports: []model.PortObservation{{Port: 443, State: "open", Service: caddy}}}}}, + }, + HostStates: []model.HostState{{Address: portAddressV4, State: "up"}, {Address: portAddressV6, State: "up"}}, + } + snapshot.Normalize() + return snapshot +} + +func portAddressFingerprint(service *model.ServiceObservation) string { + fingerprint, _ := acceptedServiceForEvidence(&model.Snapshot{Hosts: []model.HostObservation{{Address: "198.51.100.1", Protocols: []model.ProtocolObservation{{Protocol: "tcp", Ports: []model.PortObservation{{Port: 1, Service: service}}}}}}}, "tcp", 1, []string{"198.51.100.1"}) + return fingerprint +} + +func portAddressChange(port int, address, oldValue, newValue string) model.Change { + return model.Change{Key: fmt.Sprintf("port-address|edge.example|tcp|%d|%s", port, address), Kind: "port-address", Target: "edge.example", Protocol: "tcp", Port: port, Address: address, Old: oldValue, New: newValue} +} + +func hostPorts(snapshot model.Snapshot, address string) []int { + var ports []int + for _, host := range snapshot.Hosts { + if host.Address != address { + continue + } + for _, protocol := range host.Protocols { + for _, port := range protocol.Ports { + ports = append(ports, port.Port) + } + } + } + return ports +} + +func TestAcceptPortAddressChangeUpdatesEvidenceServiceAndHostView(t *testing.T) { + t.Parallel() + snapshot := portAddressBaseline() + nginx := portAddressFingerprint(&model.ServiceObservation{Name: "https", Product: "nginx", Version: "1.25", Method: "probed"}) + + opened := portAddressChange(22, "2001:DB8::10", "not-open", "open") + for range 2 { + if err := applyAcceptedChange(&snapshot, opened); err != nil { + t.Fatalf("accept opened port address: %v", err) + } + } + if got := snapshot.Units[0].Ports[0]; !slices.Equal(got.Evidence, []string{portAddressV4, portAddressV6}) || got.Service != "" { + t.Fatalf("opened port address baseline = %#v", got) + } + if got := hostPorts(snapshot, portAddressV6); !slices.Equal(got, []int{22, 443}) { + t.Fatalf("expected IPv6 host ports = %v", got) + } + + closed := portAddressChange(443, portAddressV6, "open", "not-open") + if err := applyAcceptedChange(&snapshot, closed); err != nil { + t.Fatalf("accept closed port address: %v", err) + } + if got := snapshot.Units[0].Ports[1]; !slices.Equal(got.Evidence, []string{portAddressV4}) || got.Service != nginx { + t.Fatalf("closed port address baseline = %#v, want service %q", got, nginx) + } + if got := hostPorts(snapshot, portAddressV6); !slices.Equal(got, []int{22}) { + t.Fatalf("expected IPv6 host ports after closure = %v", got) + } + if got := hostPorts(snapshot, portAddressV4); !slices.Equal(got, []int{22, 443}) { + t.Fatalf("closure on IPv6 changed the IPv4 host view: %v", got) + } + + // An address on a host the baseline has no evidence for gets one. + bare := portAddressBaseline() + bare.Hosts = nil + if err := applyAcceptedChange(&bare, opened); err != nil || !slices.Equal(hostPorts(bare, portAddressV6), []int{22}) { + t.Fatalf("opened port address without host evidence = %#v, %v", bare.Hosts, err) + } + + // A port without address evidence has no per-address expectation, and a + // port that has left the baseline is not exposed anywhere. + unknown := portAddressBaseline() + unknown.Units[0].Ports[0].Evidence = nil + if err := applyAcceptedChange(&unknown, opened); err != nil || len(unknown.Units[0].Ports[0].Evidence) != 0 { + t.Fatalf("port without evidence = %#v, %v", unknown.Units[0].Ports[0], err) + } + gone := portAddressChange(80, portAddressV6, "open", "not-open") + if err := applyAcceptedChange(&snapshot, gone); err != nil { + t.Fatalf("closure of a port outside the baseline = %v", err) + } + + for _, invalid := range []model.Change{ + portAddressChange(80, portAddressV6, "not-open", "open"), + portAddressChange(22, "", "not-open", "open"), + portAddressChange(22, "edge.example", "not-open", "open"), + portAddressChange(22, portAddressV6, "not-open", "closed"), + {Kind: "port-address", Target: "edge.example", Protocol: "tcp", Address: portAddressV6, New: "open"}, + } { + if err := applyAcceptedChange(&snapshot, invalid); !errors.Is(err, ErrUnsupportedIncidentChange) { + t.Fatalf("invalid change %#v error = %v", invalid, err) + } + } +} + +func TestAcceptPortAddressClosureResolvesMatchingServiceIncident(t *testing.T) { + t.Parallel() + nginx := portAddressFingerprint(&model.ServiceObservation{Name: "https", Product: "nginx", Version: "1.25", Method: "probed"}) + for _, test := range []struct { + name string + reported string + wantPaired bool + }{ + {name: "matching service", reported: nginx, wantPaired: true}, + {name: "different service", reported: "https | other | 1 |", wantPaired: false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + s := openTestStore(t) + record, err := defaultTenant(s).CreateJob(ctx, testJob("accept-port-address")) + if err != nil { + t.Fatal(err) + } + closed := portAddressChange(443, portAddressV6, "open", "not-open") + closed.Severity = "info" + baseline := portAddressBaseline() + serviceKey := "service|edge.example|tcp|443" + service := model.Change{Key: serviceKey, Kind: "service", Severity: "warning", Target: "edge.example", Protocol: "tcp", Port: 443, Old: baseline.Units[0].Ports[1].Service, New: test.reported} + if _, err := s.System().UpdateRuntime(ctx, record.ID, func(state *model.JobState) ([]model.Event, error) { + state.Baseline = &baseline + state.Incidents[closed.Key] = model.Incident{Change: closed, ScanID: "scan-2"} + state.Incidents[serviceKey] = model.Incident{Change: service, ScanID: "scan-2"} + return nil, nil + }); err != nil { + t.Fatal(err) + } + events, err := defaultTenant(s).AcceptIncidentWithExpectedOutboxAndAudit(ctx, record.ID, record.Job.Name, closed.Key, &IncidentExpectation{Change: closed}, nil, AuditEntry{Action: "incident.accepted", Detail: closed.Key}) + if err != nil { + t.Fatal(err) + } + wantChanges := []model.Change{closed} + if test.wantPaired { + wantChanges = append(wantChanges, service) + } + if len(events) != 1 || !slices.Equal(events[0].Changes, wantChanges) { + t.Fatalf("accept events = %#v, want changes %#v", events, wantChanges) + } + state, err := defaultTenant(s).RuntimeState(ctx, record.ID) + if err != nil { + t.Fatal(err) + } + if _, open := state.Incidents[serviceKey]; open == test.wantPaired { + t.Fatalf("service incident open = %t after accepting the closure, want %t", open, !test.wantPaired) + } + if got := state.Baseline.Units[0].Ports[1]; got.Service != nginx || !slices.Equal(got.Evidence, []string{portAddressV4}) || !state.BaselineModified { + t.Fatalf("accepted baseline port = %#v modified=%t", got, state.BaselineModified) + } + }) + } +} diff --git a/src/main.tsx b/src/main.tsx index 12174ff5..2e2be074 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -32,7 +32,7 @@ import { compactPortExpression } from './components/PortScopeDetails' import type { Incident } from './types' import { baselinePresentation } from './baseline' import { formatDateTime } from './format' -import { changeKindLabel, jobStatePresentation, severityLabel, severityTone } from './status' +import { changeKindLabel, changeTargetLabel, jobStatePresentation, severityLabel, severityTone } from './status' import './tailwind.css' import './styles.css' @@ -430,14 +430,14 @@ function IncidentTableRow({ row, busy, onAction }: { row: Incident; busy: string const key = row.incident.change.key const acceptID = `accept:${row.job_id}:${key ?? ''}` const suppressID = `suppress:${row.job_id}:${key ?? ''}` - return {row.job}{row.incident.change.target}{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}{severityLabel(row.incident.change.severity)}{formatDateTime(row.incident.last_seen_at)} + return {row.job}{changeTargetLabel(row.incident.change)}{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}{severityLabel(row.incident.change.severity)}{formatDateTime(row.incident.last_seen_at)} } function IncidentCard({ row, busy, onAction }: { row: Incident; busy: string; onAction: (row: Incident, action: 'accept' | 'suppress') => void }) { const key = row.incident.change.key const acceptID = `accept:${row.job_id}:${key ?? ''}` const suppressID = `suppress:${row.job_id}:${key ?? ''}` - return
{row.job}{severityLabel(row.incident.change.severity)}
Target
{row.incident.change.target}
Change
{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}
Last seen
{formatDateTime(row.incident.last_seen_at)}
+ return
{row.job}{severityLabel(row.incident.change.severity)}
Target
{changeTargetLabel(row.incident.change)}
Change
{formatIncidentChange(row.incident.change)}
{changeValues(row.incident.change)}
Last seen
{formatDateTime(row.incident.last_seen_at)}
} function formatIncidentChange(change: Incident['incident']['change']) { diff --git a/src/pages/Activity.test.tsx b/src/pages/Activity.test.tsx index 3c9a557a..0d47f782 100644 --- a/src/pages/Activity.test.tsx +++ b/src/pages/Activity.test.tsx @@ -52,6 +52,14 @@ describe('activity history', () => { expect(screen.getByRole('link', { name: 'Open job →' })).toHaveAttribute('href', '/jobs/job-1#pending-changes') }) + it('names the resolved address of a port change on one address of a DNS target', async () => { + const change = { key: 'port-address|edge.example|tcp|22|2001:db8::10', kind: 'port-address', target: 'edge.example', protocol: 'tcp', port: 22, address: '2001:db8::10', old: 'not-open', new: 'open', severity: 'critical' } + vi.mocked(listEvents).mockResolvedValue({ events: [{ type: 'changes-detected', job_id: 'job-1', job: 'Production', scan_id: 'scan-3', message: '1 baseline change confirmed', changes: [change], created_at: '2026-09-20T11:00:00Z' }], pagination: { ...page, total: 1 } }) + renderWithProviders() + + expect(await screen.findByText('Port opened on address · edge.example (2001:db8::10) · TCP:22 · not-open → open')).toBeInTheDocument() + }) + it('filters by job and resets pagination when the filter changes', async () => { renderWithProviders(, { route: ['/activity?job_id=job-1'] }) await screen.findByText('Change accepted') diff --git a/src/pages/Activity.tsx b/src/pages/Activity.tsx index 1cd220c8..94e8a6f8 100644 --- a/src/pages/Activity.tsx +++ b/src/pages/Activity.tsx @@ -7,7 +7,7 @@ import { ErrorNotice } from '../components/ErrorNotice' import { Pagination } from '../components/Pagination' import type { ActivityEvent, Change, Job } from '../types' import { formatDateTime } from '../format' -import { changeKindLabel, severityLabel, severityTone } from '../status' +import { changeKindLabel, changeTargetLabel, severityLabel, severityTone } from '../status' const pageSize = 20 @@ -37,7 +37,7 @@ function eventTone(type: string) { function changeDescription(change: Change) { const subject = change.protocol && change.port ? ` · ${change.protocol.toUpperCase()}:${change.port}` : '' const transition = change.old || change.new ? ` · ${change.old || '—'} → ${change.new || '—'}` : '' - return `${changeKindLabel(change.kind, change.old, change.new)} · ${change.target}${subject}${transition}` + return `${changeKindLabel(change.kind, change.old, change.new)} · ${changeTargetLabel(change)}${subject}${transition}` } function ActivityChange({ change }: { change: Change }) { diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx index eac72263..725607bd 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -42,7 +42,7 @@ import { SurfaceUnitList } from '../components/SurfaceUnitList' import type { ActiveScan, QueuedRun, WorkEstimate } from '../types' import { baselinePresentation } from '../baseline' import { formatDateTime } from '../format' -import { changeKindLabel, jobStatePresentation, scanOutcomeTone, severityTone } from '../status' +import { changeKindLabel, changeTargetLabel, jobStatePresentation, scanOutcomeTone, severityTone } from '../status' type JobDialog = 'reset' | 'approve' | 'archive' | 'delete' | 'discard-cycle' @@ -478,7 +478,7 @@ export function JobDetail() { {detail.data.changes.map((change, index) => (
{changeKindLabel(change.kind, change.old, change.new)} - {change.target}{change.port ? ` · ${change.protocol}:${change.port}` : ''} + {changeTargetLabel(change)}{change.port ? ` · ${change.protocol}:${change.port}` : ''} {change.old ?? '—'} → {change.new ?? '—'}
))} @@ -621,7 +621,7 @@ export function JobDetail() { )} {canReadScans && (value.baseline.pending ?? 0) > 0 &&

Pending confirmations

These differences have not reached the {value.job.change_confirmations}-scan confirmation threshold yet.

Activity history →
- {pendingChanges.isLoading ?
: pendingChanges.error ? pendingChanges.refetch()} /> : pendingChanges.data?.pending_changes.length ? <>
    {pendingChanges.data.pending_changes.map(item =>
  • {changeKindLabel(item.change.kind, item.change.old, item.change.new)} · {item.change.target}{item.change.protocol && item.change.port ? ` · ${item.change.protocol.toUpperCase()}:${item.change.port}` : ''}{item.change.old || item.change.new ? ` · ${item.change.old || '—'} → ${item.change.new || '—'}` : ''}{item.count} / {value.job.change_confirmations} scans
  • )}
: pendingChanges.data?.pagination.total ? :

No changes are awaiting confirmation.

} + {pendingChanges.isLoading ?
: pendingChanges.error ? pendingChanges.refetch()} /> : pendingChanges.data?.pending_changes.length ? <>
    {pendingChanges.data.pending_changes.map(item =>
  • {changeKindLabel(item.change.kind, item.change.old, item.change.new)} · {changeTargetLabel(item.change)}{item.change.protocol && item.change.port ? ` · ${item.change.protocol.toUpperCase()}:${item.change.port}` : ''}{item.change.old || item.change.new ? ` · ${item.change.old || '—'} → ${item.change.new || '—'}` : ''}{item.count} / {value.job.change_confirmations} scans
  • )}
: pendingChanges.data?.pagination.total ? :

No changes are awaiting confirmation.

}
} diff --git a/src/pages/JobEditor.behavior.test.tsx b/src/pages/JobEditor.behavior.test.tsx index 762e2636..209afca3 100644 --- a/src/pages/JobEditor.behavior.test.tsx +++ b/src/pages/JobEditor.behavior.test.tsx @@ -103,8 +103,9 @@ describe('job editor workflow coverage', () => { await waitFor(() => expect(screen.getByRole('heading', { name: 'Create a monitoring job' })).toBeInTheDocument()) fireEvent.change(screen.getByLabelText('Job name'), { target: { value: 'Rotating DNS service' } }) fireEvent.change(screen.getByLabelText('Target 1'), { target: { value: 'edge.example' } }) + expect(screen.getByText(/a port that opens or closes on one address alerts even while another address exposes it/)).toBeInTheDocument() fireEvent.change(screen.getByLabelText('DNS comparison'), { target: { value: 'aggregate' } }) - expect(screen.getByText(/Address rotation and individual backend reachability will not alert/)).toBeInTheDocument() + expect(screen.getByText(/Address rotation, individual backend reachability, and a port that opens or closes on one address while another address exposes it will not alert/)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Create job' })) await waitFor(() => expect(createJob).toHaveBeenCalled()) diff --git a/src/pages/JobEditor.tsx b/src/pages/JobEditor.tsx index f6aa513d..3b864fa0 100644 --- a/src/pages/JobEditor.tsx +++ b/src/pages/JobEditor.tsx @@ -381,8 +381,8 @@ export function JobEditor() { {dnsComparisonMode === 'aggregate' - ? 'For DNS names, compare the logical port and service surface only. Address rotation and individual backend reachability will not alert; per-IP scan evidence remains available. IP and CIDR targets stay individually monitored. Changing this requires confirming a new baseline.' - : 'Compare DNS answer membership, individual host reachability, and ports/services. Choose aggregate mode only when DNS answers rotate routinely; it will not alert on address membership or per-backend reachability. Changing this requires confirming a new baseline.'} + ? 'For DNS names, compare the logical port and service surface only. Address rotation, individual backend reachability, and a port that opens or closes on one address while another address exposes it will not alert; per-IP scan evidence remains available. IP and CIDR targets stay individually monitored. Changing this requires confirming a new baseline.' + : 'Compare DNS answer membership, individual host reachability, ports/services, and which resolved address exposes each port, so a port that opens or closes on one address alerts even while another address exposes it. Choose aggregate mode only when DNS answers rotate routinely; it will not alert on address membership, per-backend reachability, or per-address ports. Changing this requires confirming a new baseline.'} diff --git a/src/status.test.ts b/src/status.test.ts index 13397d15..d885476c 100644 --- a/src/status.test.ts +++ b/src/status.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { changeKindLabel, hostStatusLabel, jobStatePresentation, scanOutcomeTone, severityLabel, severityTone } from './status' +import { changeKindLabel, changeTargetLabel, hostStatusLabel, jobStatePresentation, scanOutcomeTone, severityLabel, severityTone } from './status' describe('shared status presentation', () => { it('uses consistent job labels and tones', () => { @@ -35,6 +35,15 @@ describe('shared status presentation', () => { expect(changeKindLabel('other_change')).toBe('Other change') }) + it('labels a port that opened or closed on one address of a DNS target', () => { + expect(changeKindLabel('port-address', 'not-open', 'open')).toBe('Port opened on address') + expect(changeKindLabel('port-address', 'not-open', 'open|filtered')).toBe('Port opened on address') + expect(changeKindLabel('port-address', 'open', 'not-open')).toBe('Port closed on address') + expect(changeKindLabel('port-address')).toBe('Port on address') + expect(changeTargetLabel({ target: 'edge.example', address: '2001:db8::10' })).toBe('edge.example (2001:db8::10)') + expect(changeTargetLabel({ target: '192.0.2.10' })).toBe('192.0.2.10') + }) + it('uses explicit readable labels for lower-case severity and host states', () => { expect(severityLabel('critical')).toBe('Critical') expect(severityLabel('warning')).toBe('Warning') diff --git a/src/status.ts b/src/status.ts index 010ef781..d07b25d2 100644 --- a/src/status.ts +++ b/src/status.ts @@ -36,12 +36,24 @@ export function changeKindLabel(kind: string, oldValue?: string, newValue?: stri if (oldPositive && !newPositive) return 'Port closed' return 'Port state' } + // A port that opened or closed on one resolved address of a DNS target + // while another address exposes it. + if (normalized === 'port_address') { + if (isPositivePortState(newValue) && !isPositivePortState(oldValue)) return 'Port opened on address' + if (isPositivePortState(oldValue) && !isPositivePortState(newValue)) return 'Port closed on address' + return 'Port on address' + } if (normalized === 'dns' || normalized.startsWith('dns_')) return 'DNS' if (normalized === 'host' || normalized.startsWith('host_')) return 'Host state' if (normalized === 'service' || normalized.startsWith('service_')) return 'Service' return humanize(normalized || kind) } +/** Names a change's target, followed by the resolved address for a change on one address of a DNS target. */ +export function changeTargetLabel(change: { target: string; address?: string }): string { + return change.address ? `${change.target} (${change.address})` : change.target +} + export function severityLabel(severity: string): string { const normalized = severity.trim().toLowerCase() if (normalized === 'critical') return 'Critical' diff --git a/src/types.ts b/src/types.ts index 09e74a57..22809eff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,8 +31,10 @@ export type ScanCycle = { id: string; job_id: string; job_revision: number; stat // effective addresses on which the positive port was observed. export type Unit = { target: string; protocol: string; addresses?: string[]; ports?: { port: number; state: string; service?: string; addresses?: string[] }[] } export type Scope = { target: string; protocol: string; ports: string; service_detection: boolean } -export type Incident = { job_id: string; job: string; incident: { change: { key?: string; kind: string; target: string; protocol?: string; port?: number; old?: string; new?: string; severity: string }; scan_id?: string; opened_at: string; last_seen_at: string; recovery_count?: number } } -export type Change = { key?: string; kind: string; target: string; protocol?: string; port?: number; old?: string; new?: string; severity: string } +export type Incident = { job_id: string; job: string; incident: { change: { key?: string; kind: string; target: string; protocol?: string; port?: number; address?: string; old?: string; new?: string; severity: string }; scan_id?: string; opened_at: string; last_seen_at: string; recovery_count?: number } } +// `address` names the resolved address of a DNS target on which a port opened +// or closed (kind `port-address`); `target` stays the configured name. +export type Change = { key?: string; kind: string; target: string; protocol?: string; port?: number; address?: string; old?: string; new?: string; severity: string } export type ActivityEvent = { type: string job_id?: string