diff --git a/docs/src/content/docs/reference/api-compatibility.md b/docs/src/content/docs/reference/api-compatibility.md index 4412141..2ba5fb7 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 a7ff2d6..16fd913 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 @@ -57,7 +82,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 d1c0e47..70e7f53 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 0000000..28cb102 --- /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 feb0cae..debb587 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -216,6 +216,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 { @@ -404,6 +410,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) @@ -417,7 +428,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 } @@ -1109,7 +1126,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) @@ -1142,6 +1159,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) } @@ -1467,11 +1520,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) @@ -1908,6 +2172,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": @@ -2018,6 +2287,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 bdb7a04..fb7579a 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -289,8 +289,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 { @@ -652,6 +655,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 381b6a8..6d83898 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 e388864..8df9154 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 0000000..101ccbb --- /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 ca9c79e..25fb2b1 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' @@ -467,14 +467,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 a436c2f..31165c6 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 dc6be71..946979f 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 @@ -39,7 +39,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 dd625f7..3734d15 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -47,7 +47,7 @@ import { SurfaceUnitList } from '../components/SurfaceUnitList' import type { ActiveScan, QueuedRun, ScanBudget, 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' @@ -512,7 +512,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 ?? '—'}
))} @@ -662,7 +662,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 090207a..e914a40 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 f7b1b92..212d825 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 13397d1..d885476 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 010ef78..d07b25d 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 11f8323..816967e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,8 +34,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