fix: harden hostname tracking release behavior

This commit is contained in:
NotoriousRebel
2026-08-29 17:16:17 -04:00
parent 7800737d96
commit d181c42601
5 changed files with 65 additions and 7 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ When a change alters one of these boundaries, update this document and the neare
### Deferred boundaries
- Cross-run change detection, alerts, and automatic reactions remain separate future product decisions. A scheduled occurrence only submits finite enumeration runs.
- Read-only cross-run change projections over finalized evidence are part of the release contract. Alerts and automatic reactions remain deferred, and a scheduled occurrence only submits finite enumeration runs.
- Cross-run source ranking remains a reporting decision. One run's hostname-yield summary does not automatically select, disable, or rank sources.
- Distributed workers, multi-host operation, PostgreSQL, and hosted multi-user authorization require measured demand and new decisions. The release remains SQLite-first and local-operator focused.
- Automatic scope expansion remains deferred. Evidence can suggest a later target, while the operator controls every scope change.
+8
View File
@@ -533,6 +533,14 @@ def test_hostname_tracking_filters_persisted_run_changes(
expect(panel).to_contain_text('TimeoutError')
expect(panel).not_to_contain_text('stable.example.test')
page.locator('#tracking-change-filter').select_option('persisting')
expect(page.locator('#tracking-persisting-filter')).to_be_checked()
expect(rows).to_have_count(1)
expect(panel).to_contain_text('stable.example.test')
page.locator('#tracking-persisting-filter').uncheck()
expect(page.locator('#tracking-change-filter')).to_have_value('')
expect(rows).to_have_count(3)
page.locator('#tracking-exclusive-filter').check()
expect(rows).to_have_count(2)
expect(panel).not_to_contain_text('missing.example.test')
+40
View File
@@ -16,6 +16,7 @@ from theHarvester.lib.database import ResultStore
from theHarvester.lib.evidence_types import RESULT_KINDS, ExecutionStatus
RUN_ONE = UUID('11111111-1111-4111-8111-111111111111')
RUN_TIE_LATER = UUID('11111111-1111-4111-8111-111111111112')
RUN_TWO = UUID('22222222-2222-4222-8222-222222222222')
@@ -572,6 +573,42 @@ def test_target_changes_reports_every_run_pair_and_a_clear_null_baseline(
assert {row['run_id'] for row in payload['hostname_changes']} == {str(RUN_TWO)}
def test_run_id_breaks_an_equal_completion_time_tie_deterministically(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
database = tmp_path / 'runs.sqlite'
store = ResultStore(database)
asyncio.run(store.initialize())
asyncio.run(
store.save_run(
_completed_run(
RUN_TIE_LATER,
observations=(ResultObservation('alpha', 'hostname', 'later.example.test'),),
)
)
)
asyncio.run(
store.save_run(
_completed_run(
RUN_ONE,
observations=(ResultObservation('alpha', 'hostname', 'earlier.example.test'),),
)
)
)
asyncio.run(store.dispose())
assert (
source_yields.main(
['--database', str(database), '--run-id', str(RUN_TIE_LATER), '--changes', '--format', 'json']
)
== 0
)
comparison = json.loads(capsys.readouterr().out)['comparisons'][0]
assert comparison['baseline_run_id'] == str(RUN_ONE)
def test_include_persisting_adds_unchanged_hostname_rows_without_changing_counts(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
@@ -729,6 +766,9 @@ def test_unscoped_empty_database_reports_an_explicit_empty_scope(
assert source_yields.main(['--database', str(database)]) == 0
assert capsys.readouterr().out.splitlines()[:3] == ['Targets: none', 'Kind: hostname', 'Run count: 0']
assert source_yields.main(['--database', str(database), '--changes']) == 0
assert capsys.readouterr().out.startswith('Targets: none\nComparison count: 0\n')
def test_missing_database_fails_without_creating_file(
tmp_path: Path,
+13 -4
View File
@@ -1422,10 +1422,19 @@
const query = event.target.value.trim().toLowerCase();
state.resultTable?.setFilter(row => !query || row.value.toLowerCase().includes(query));
});
for (const filter of [
nodes.trackingChange, nodes.trackingSource, nodes.trackingResolution,
nodes.trackingExclusive, nodes.trackingPersisting
]) filter.addEventListener('change', () => renderHostnameTracking(state.detail));
nodes.trackingChange.addEventListener('change', () => {
if (nodes.trackingChange.value === 'persisting') nodes.trackingPersisting.checked = true;
renderHostnameTracking(state.detail);
});
nodes.trackingPersisting.addEventListener('change', () => {
if (!nodes.trackingPersisting.checked && nodes.trackingChange.value === 'persisting') {
nodes.trackingChange.value = '';
}
renderHostnameTracking(state.detail);
});
for (const filter of [nodes.trackingSource, nodes.trackingResolution, nodes.trackingExclusive]) {
filter.addEventListener('change', () => renderHostnameTracking(state.detail));
}
nodes.sourceSearch.addEventListener('input', event => renderSourceGroups(event.target.value));
nodes.selectCapability.addEventListener('click', selectCapability);
nodes.selectP0.addEventListener('click', () => setP0Selection(true));
+3 -2
View File
@@ -210,7 +210,8 @@ def _targets_table(rows: list[dict[str, str | int]]) -> str:
def _tracking_table(payload: dict[str, object]) -> str:
comparisons = cast('list[dict[str, object]]', payload['comparisons'])
changes = cast('list[dict[str, object]]', payload['hostname_changes'])
lines = [f'Target: {payload["target"]}', f'Comparison count: {payload["comparison_count"]}']
scope = f'Target: {payload["target"]}' if payload['target'] is not None else 'Targets: none'
lines = [scope, f'Comparison count: {payload["comparison_count"]}']
summary_columns = (
('run_id', 'CURRENT RUN'),
('baseline_run_id', 'BASELINE RUN'),
@@ -227,7 +228,7 @@ def _tracking_table(payload: dict[str, object]) -> str:
}
for comparison in comparisons
]
summary_widths = [max(len(label), *(len(str(row[key])) for row in summary_rows)) for key, label in summary_columns]
summary_widths = [max([len(label), *(len(str(row[key])) for row in summary_rows)]) for key, label in summary_columns]
lines.append(' '.join(label.ljust(summary_widths[index]) for index, (_key, label) in enumerate(summary_columns)))
lines.extend(
' '.join(str(row[key]).ljust(summary_widths[index]) for index, (key, _label) in enumerate(summary_columns))