Merge pull request #2555 from NotoriousRebel/codex/release-feature-validation

Add repeatable release validation and harden HarvestView
This commit is contained in:
Matt
2026-08-16 11:29:39 -04:00
committed by GitHub
17 changed files with 432 additions and 145929 deletions
@@ -1,6 +1,7 @@
name: HarvestView container smoke
on:
workflow_call:
workflow_dispatch:
pull_request:
paths:
+1
View File
@@ -1,6 +1,7 @@
name: HarvestView browser E2E
on:
workflow_call:
workflow_dispatch:
pull_request:
paths:
+124 -44
View File
@@ -1,26 +1,38 @@
name: Passive provider smoke
name: Release validation
on:
workflow_dispatch:
inputs:
run_live:
description: Run bounded P0 provider checks against mozilla.org
required: true
default: false
type: boolean
permissions:
contents: read
jobs:
Passive-provider-smoke:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
# Mozilla publishes a bug bounty and safe harbor; keep this job passive.
SMOKE_TEST_DOMAIN: mozilla.org
python-ci:
uses: ./.github/workflows/theHarvester.yml
harvestview-e2e:
uses: ./.github/workflows/harvestview-e2e.yml
container-smoke:
uses: ./.github/workflows/harvestview-container.yml
package-smoke:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -29,49 +41,117 @@ jobs:
with:
python-version: '3.14'
enable-cache: true
cache-dependency-glob: "uv.lock"
cache-dependency-glob: uv.lock
- name: Build distributions
run: uv build --out-dir release-dist
- name: Verify installed distributions
run: |
wheel_path="$(find release-dist -maxdepth 1 -name '*.whl' -print -quit)"
sdist_path="$(find release-dist -maxdepth 1 -name '*.tar.gz' -print -quit)"
test -n "$wheel_path"
test -n "$sdist_path"
uv run --isolated --no-project --with "$wheel_path" theHarvester --help
uv run --isolated --no-project --with "$wheel_path" harvestview --help
uv run --isolated --no-project --with "$sdist_path" theHarvester --help
- name: Upload release distributions
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-distributions-${{ github.sha }}
path: release-dist/*
if-no-files-found: error
retention-days: 7
live-provider-tests:
if: ${{ inputs.run_live }}
needs: [python-ci, harvestview-e2e, container-smoke, package-smoke]
runs-on: ubuntu-latest
timeout-minutes: 10
env:
SMOKE_TEST_DOMAIN: mozilla.org
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: '3.14'
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install dependencies
run: uv sync --all-groups --frozen
- name: Validate P0 provider test contract
run: |
sudo mkdir -p /usr/local/etc/theHarvester
sudo cp theHarvester/data/*.yaml /usr/local/etc/theHarvester/
sudo chown -R runner:runner /usr/local/etc/theHarvester/
uv sync --all-groups --frozen
echo "$GITHUB_WORKSPACE/.venv/bin" >> $GITHUB_PATH
uv run python -c \
'import sys; from theHarvester.lib.source_catalog import ActivityClass, get_source_spec; assert all(get_source_spec(name).activity is ActivityClass.PASSIVE for name in sys.argv[1:])' \
certspotter otx thc
- name: Run opt-in live provider tests
timeout-minutes: 5
run: pytest --run-live-network -m live_network
run: |
uv run pytest --run-live-network -m live_network -q \
tests/discovery/test_certspotter.py::TestCertspotterSearch::test_api \
tests/discovery/test_otx.py::TestOtx::test_api \
tests/discovery/test_thc.py::TestThcApi
# These are bounded CLI crash smokes, not provider conformance tests.
- name: Run theHarvester module CertSpotter
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b certspotter -l 10 -q
live-cli-smoke:
if: ${{ inputs.run_live }}
needs: [python-ci, harvestview-e2e, container-smoke, package-smoke]
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
source:
- certspotter
- crtsh
- duckduckgo
- hackertarget
- otx
- rapiddns
- urlscan
- yahoo
env:
SMOKE_TEST_DOMAIN: mozilla.org
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Run theHarvester module Crtsh
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b crtsh -l 10 -q
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: '3.14'
enable-cache: true
- name: Run theHarvester module DuckDuckGo
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b duckduckgo -l 10 -q
- name: Download release distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-distributions-${{ github.sha }}
path: release-dist
- name: Run theHarvester module HackerTarget
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b hackertarget -l 10 -q
- name: Verify source is P0
run: |
wheel_path="$(find release-dist -maxdepth 1 -name '*.whl' -print -quit)"
test -n "$wheel_path"
uv run --isolated --no-project --with "$wheel_path" python -c \
'import sys; from theHarvester.lib.source_catalog import ActivityClass, get_source_spec; assert get_source_spec(sys.argv[1]).activity is ActivityClass.PASSIVE' \
'${{ matrix.source }}'
- name: Run theHarvester module Otx
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b otx -l 10 -q
- name: Run theHarvester module RapidDns
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b rapiddns -l 10 -q
- name: Run theHarvester module Urlscan
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b urlscan -l 10 -q
- name: Run theHarvester module Yahoo
timeout-minutes: 5
run: theHarvester -d "$SMOKE_TEST_DOMAIN" -b yahoo -l 10 -q
- name: Run installed-wheel P0 CLI smoke
run: |
wheel_path="$(find release-dist -maxdepth 1 -name '*.whl' -print -quit)"
uv run --isolated --no-project --with "$wheel_path" \
theHarvester -d "$SMOKE_TEST_DOMAIN" -b '${{ matrix.source }}' -l 10 -q
+1
View File
@@ -1,6 +1,7 @@
name: TheHarvester Python CI
on:
workflow_call:
push:
branches:
- '*'
+2 -3
View File
@@ -30,12 +30,12 @@ test-results/
# Ephemeral output, runtime state, and per-dev overrides.
# Unanchored: .impeccable may sit at the repo root or under a nested
# workspace (apps/web/.impeccable/...); anchored patterns would miss it.
# Shared artifacts stay tracked: config.json, live/config.json,
# design.json, critique/*.md.
# Shared artifacts stay tracked: config.json, live/config.json, design.json.
**/.impeccable/config.local.json
**/.impeccable/hook.cache.json
**/.impeccable/hook.pending.json
**/.impeccable/*.png
**/.impeccable/critique/
**/.impeccable/live/server.json
**/.impeccable/live/sessions/
**/.impeccable/live/previews/
@@ -53,4 +53,3 @@ test-results/
!**/.impeccable/config.json
!**/.impeccable/live/config.json
!**/.impeccable/design.json
!**/.impeccable/critique/*.md
+5
View File
@@ -9,6 +9,11 @@ theHarvester is a Python OSINT reconnaissance tool for collecting public informa
- For code changes, follow [CONTRIBUTING.md](CONTRIBUTING.md).
- Make the smallest requested change, reuse existing code, and preserve unrelated worktree changes.
## Upstream publication
- Target every upstream pull request at `dev`.
- Treat `upstream/master` as maintainer-only and read-only for agents. Leave every `dev`-to-`master` promotion, merge, and direct update to upstream maintainers.
## Domain language
Read [CONTEXT.md](CONTEXT.md) when changing discovery terminology, evidence classification, scope handling, DNS validation, or P0/P1/P2 activity boundaries.
+12
View File
@@ -75,6 +75,18 @@ uv run pytest
uv run mypy theHarvester
```
Before a release, manually dispatch the **Release validation** workflow against
the exact release branch or tag. For example:
```bash
gh workflow run provider-smoke.yml --ref dev -f run_live=false
```
This composes the Python, real-browser HarvestView, package, and container checks
on clean GitHub-hosted runners. Maintainers with explicit authorization may set
`run_live=true` to add bounded P0 provider checks against `mozilla.org`; the live
lane never enables DNS or direct target interaction.
Routine verification must use mocks, local fixtures, and reserved example domains. The test harness blocks external Python socket traffic unless a test is marked `live_network` and pytest is invoked with both `--run-live-network` and `-m live_network`. A live-marked test never satisfies the provider-contract coverage gate.
Do not run broad or active reconnaissance against third-party targets. If live verification is essential, use only a target you own or are explicitly authorized to test, limit the request scope, and keep collected data out of commits, issues, and pull requests. The manually dispatched provider workflow uses `mozilla.org` for small passive CLI crash smokes. Those runs can detect packaging, credential, or provider drift; they are not conformance tests and should not be retried merely to obtain more results.
+2
View File
@@ -20,6 +20,8 @@ class TestOtx:
url = f'https://otx.alienvault.com/api/v1/indicators/domain/{live_test_domain}/passive_dns'
response = httpx.get(url, headers={'User-Agent': Core.get_user_agent()}, timeout=30)
if response.status_code == 429:
pytest.skip('OTX rate limited the anonymous live-network probe')
assert response.status_code == 200
assert isinstance(response.json().get('passive_dns'), list)
+73 -3
View File
@@ -108,6 +108,7 @@ def test_versioned_assets_and_tooltips_work_at_supported_viewports(
assert 'Credential warnings identify sources that cannot start' in tooltip.get_attribute('aria-description')
assert tooltip.bounding_box()['width'] >= 44
assert tooltip.bounding_box()['height'] >= 44
page.locator('#submit-run-button').scroll_into_view_if_needed()
assert page.locator('#submit-run-button').evaluate(
"""button => {
const buttonBox = button.getBoundingClientRect();
@@ -140,10 +141,56 @@ def test_mobile_source_picker_avoids_nested_scroll_and_blocks_unconfigured_sourc
crtsh = page.locator('.source-choice').filter(has_text='crtsh')
expect(crtsh.locator('input')).to_be_enabled()
expect(crtsh).to_contain_text('Ready')
expect(page.locator('#source-selection-summary')).to_contain_text('Selected 1 ready source')
expect(page.locator('#source-selection-summary')).to_have_text('Selected 1 ready source: crtsh.')
expect(page.locator('#final-authorization-summary')).to_contain_text('Target not set')
expect(page.locator('#final-authorization-summary')).to_contain_text('1 source: crtsh')
page.locator('#run-target').fill('example.com')
page.locator('[name="screenshot"]').check()
expect(page.locator('#final-authorization-summary')).to_contain_text('Target example.com')
expect(page.locator('#final-authorization-summary')).to_contain_text('P2 selected')
p0_group.locator('summary').click()
crtsh.locator('input').uncheck()
expect(page.locator('#source-selection-summary')).to_contain_text('Selected 0 ready sources')
expect(page.locator('#source-selection-summary')).to_have_text('Selected 0 ready sources.')
def test_imported_run_separates_original_execution_from_local_import(
harvestview_server_url: str,
page: Page,
) -> None:
run = {
'run_id': 'imported-run',
'target': 'example.test',
'status': 'completed',
'origin': 'imported',
'created_at': '2026-08-16T05:37:52+00:00',
'started_at': '2026-08-15T03:10:00+00:00',
'completed_at': '2026-08-15T03:10:08+00:00',
'cancellation_requested_at': None,
'evidence_status': 'complete',
'result_count': 0,
'activities': ['P0'],
'sources': ['crtsh'],
'request': {'sources': ['crtsh'], 'filename': 'evidence.jsonl'},
'source_executions': [],
'action_executions': [],
'results': [],
'screenshots': [],
'log': '',
'error': None,
}
page.route(f'{harvestview_server_url}/api/v1/runs', lambda route: route.fulfill(json=[run]))
page.route(f'{harvestview_server_url}/api/v1/runs/imported-run', lambda route: route.fulfill(json=run))
page.goto(f'{harvestview_server_url}/')
facts = page.locator('#run-facts')
expect(facts).to_contain_text('OriginImported evidence')
expect(facts).to_contain_text('Imported')
expect(facts).to_contain_text('Original started')
expect(facts).to_contain_text('Original completed')
expect(facts).not_to_contain_text('Submitted')
expect(page.locator('#lifecycle-track strong')).to_have_text(['Original started', 'Original completed', 'Imported'])
expect(page.locator('#lifecycle-note')).to_contain_text('original execution timing')
def test_disabled_worker_rejects_submission_without_creating_a_run(
@@ -803,8 +850,19 @@ def test_hostname_actions_queue_isolated_runs(
expect(action_row).to_contain_text('TimeoutError')
page.get_by_role('button', name='Take screenshot of api.example.com (P2)').click()
review = page.locator('#result-action-dialog')
expect(review.get_by_role('heading')).to_have_text('Review screenshot interaction')
expect(review).to_contain_text('api.example.com')
expect(review).to_contain_text('P2 · Direct interaction')
expect(review).to_contain_text('Creates a separate finite run')
assert submissions == []
review.get_by_role('button', name='Start screenshot run').click()
expect(page.locator('#toast')).to_contain_text('Screenshot captured')
page.get_by_role('button', name='DNS brute force api.example.com (P1)').click()
expect(review.get_by_role('heading')).to_have_text('Review DNS brute force interaction')
expect(review).to_contain_text('P1 · DNS interaction')
expect(review).to_contain_text('192.0.2.53')
review.get_by_role('button', name='Start DNS brute force run').click()
expect(page.locator('#toast')).to_contain_text('DNS brute captured')
assert submissions == [
@@ -883,6 +941,7 @@ def test_accepted_result_action_is_not_reported_as_failed_when_refresh_fails(
page.goto(f'{harvestview_server_url}/')
page.get_by_role('button', name='Take screenshot of api.example.com (P2)').click()
page.locator('#result-action-dialog').get_by_role('button', name='Start screenshot run').click()
expect(page.locator('#toast')).to_contain_text('was queued, but the run view could not refresh')
expect(page.locator('#toast')).to_contain_text('Do not submit it again')
@@ -1245,7 +1304,7 @@ def test_completed_empty_import_explains_terminal_outcome(
expect(page.locator('#results-empty-copy')).to_have_text(
'crtsh returned no normalized evidence. The retained evidence record is complete.'
)
expect(page.locator('#lifecycle-track strong')).to_have_text(['Submitted', 'Started', 'Completed'])
expect(page.locator('#lifecycle-track strong')).to_have_text(['Original started', 'Original completed', 'Imported'])
expect(page.get_by_role('button', name='All JSONL')).to_be_enabled()
with page.expect_download() as jsonl_download:
page.get_by_role('button', name='All JSONL').click()
@@ -1391,6 +1450,10 @@ def test_harvestview_can_import_and_analyze_fixture_evidence_through_the_real_ui
dns_filter = page.locator('.tabulator-col[tabulator-field="dns_status"] .tabulator-header-filter input')
expect(value_filter).to_have_attribute('placeholder', 'Filter values')
expect(dns_filter).to_have_attribute('placeholder', 'Filter DNS')
expect(value_filter).to_have_attribute('aria-label', 'Filter Value column')
expect(dns_filter).to_have_attribute('aria-label', 'Filter DNS column')
expect(page.get_by_role('checkbox', name='Select all rows on this route')).to_be_visible()
expect(page.get_by_role('checkbox', name='Select 192.0.2.10')).to_be_visible()
value_filter.press_sequentially('198.51')
expect(page.locator('.tabulator-row:visible')).to_have_count(1)
@@ -1445,6 +1508,9 @@ def test_harvestview_can_import_and_analyze_fixture_evidence_through_the_real_ui
page.keyboard.press('Escape')
expect(page.get_by_role('button', name='Start enumeration').first).to_be_focused()
page.get_by_role('button', name='Hostnames 1').click()
page.set_viewport_size({'width': 820, 'height': 1180})
assert page.locator('.app-shell').evaluate("node => getComputedStyle(node).display === 'block'")
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth')
page.set_viewport_size({'width': 390, 'height': 844})
expect(page.locator('#provider-outcome-summary')).to_be_visible()
expect(page.get_by_role('columnheader', name='Outcome')).to_be_visible()
@@ -1456,3 +1522,7 @@ def test_harvestview_can_import_and_analyze_fixture_evidence_through_the_real_ui
expect(value_filter).to_be_visible()
assert value_filter.bounding_box()['height'] >= 44
assert page.locator('#route-tabs').evaluate('node => node.scrollWidth > node.clientWidth')
expect(page.get_by_role('button', name='Next Page')).to_be_visible()
workbench_box = page.locator('#result-workbench').bounding_box()
paginator_box = page.locator('.tabulator-paginator').bounding_box()
assert paginator_box['x'] + paginator_box['width'] <= workbench_box['x'] + workbench_box['width'] + 1
+50 -11
View File
@@ -7,8 +7,9 @@ import yaml
WORKFLOW_DIR = Path(__file__).parents[1] / '.github' / 'workflows'
CI_WORKFLOW_PATH = WORKFLOW_DIR / 'theHarvester.yml'
SMOKE_WORKFLOW_PATH = WORKFLOW_DIR / 'provider-smoke.yml'
RELEASE_WORKFLOW_PATH = WORKFLOW_DIR / 'provider-smoke.yml'
HARVESTVIEW_WORKFLOW_PATH = WORKFLOW_DIR / 'harvestview-e2e.yml'
CONTAINER_WORKFLOW_PATH = WORKFLOW_DIR / 'harvestview-container.yml'
def _workflow(path: Path) -> dict[str, Any]:
@@ -18,7 +19,7 @@ def _workflow(path: Path) -> dict[str, Any]:
def test_routine_ci_is_read_only_and_offline() -> None:
workflow = _workflow(CI_WORKFLOW_PATH)
assert workflow['permissions'] == {'contents': 'read'}
assert set(workflow['on']) == {'push', 'pull_request'}
assert set(workflow['on']) == {'workflow_call', 'push', 'pull_request'}
routine_job = workflow['jobs']['Python']
commands = '\n'.join(step.get('run', '') for step in routine_job['steps'])
@@ -29,18 +30,56 @@ def test_routine_ci_is_read_only_and_offline() -> None:
assert routine_job['strategy']['matrix']['python-version'] == ['3.12', '3.13', '3.14']
def test_live_provider_smoke_requires_manual_dispatch() -> None:
workflow = _workflow(SMOKE_WORKFLOW_PATH)
smoke_job = workflow['jobs']['Passive-provider-smoke']
commands = '\n'.join(step.get('run', '') for step in smoke_job['steps'])
def test_release_validation_is_manual_and_composes_existing_checks() -> None:
workflow = _workflow(RELEASE_WORKFLOW_PATH)
assert workflow['name'] == 'Release validation'
assert set(workflow['on']) == {'workflow_dispatch'}
assert workflow['permissions'] == {'contents': 'read'}
assert smoke_job['env']['SMOKE_TEST_DOMAIN'] == 'mozilla.org'
assert 'pytest --run-live-network -m live_network' in commands
cli_smokes = [line for line in commands.splitlines() if line.startswith('theHarvester -d')]
assert cli_smokes
assert all('-l 10 -q' in command for command in cli_smokes)
live_input = workflow['on']['workflow_dispatch']['inputs']['run_live']
assert live_input['type'] == 'boolean'
assert live_input['default'] == 'false'
reused_workflows = {job['uses'] for job in workflow['jobs'].values() if 'uses' in job}
assert reused_workflows == {
'./.github/workflows/harvestview-container.yml',
'./.github/workflows/harvestview-e2e.yml',
'./.github/workflows/theHarvester.yml',
}
assert 'package-smoke' in workflow['jobs']
def test_release_live_checks_are_opt_in_p0_and_fixed_to_mozilla() -> None:
workflow = _workflow(RELEASE_WORKFLOW_PATH)
live_jobs = [job for name, job in workflow['jobs'].items() if name.startswith('live-')]
assert live_jobs
assert all(job['if'] == '${{ inputs.run_live }}' for job in live_jobs)
assert all(job['env']['SMOKE_TEST_DOMAIN'] == 'mozilla.org' for job in live_jobs)
assert workflow['jobs']['live-cli-smoke']['strategy']['matrix']['source'] == [
'certspotter',
'crtsh',
'duckduckgo',
'hackertarget',
'otx',
'rapiddns',
'urlscan',
'yahoo',
]
provider_commands = '\n'.join(step.get('run', '') for step in workflow['jobs']['live-provider-tests']['steps'])
cli_commands = '\n'.join(step.get('run', '') for step in workflow['jobs']['live-cli-smoke']['steps'])
assert 'ActivityClass.PASSIVE' in provider_commands
assert 'tests/discovery/test_certspotter.py::TestCertspotterSearch::test_api' in provider_commands
assert 'tests/discovery/test_otx.py::TestOtx::test_api' in provider_commands
assert 'tests/discovery/test_thc.py::TestThcApi' in provider_commands
assert 'ActivityClass.PASSIVE' in cli_commands
assert '-l 10 -q' in cli_commands
def test_release_dependencies_are_reusable_workflows() -> None:
for path in (CI_WORKFLOW_PATH, HARVESTVIEW_WORKFLOW_PATH, CONTAINER_WORKFLOW_PATH):
assert 'workflow_call' in _workflow(path)['on']
def test_harvestview_browser_failures_keep_only_targeted_diagnostics() -> None:
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
inurl:"contact"
intext:email filetype:log
"Index of /mail"
"admin account info" filetype:log
intext:@
administrator accounts/
intitle:"Index of" .bash_history
intitle:"index of" members OR accounts
inurl:/shared/help.php
inurl:public
intitle:index.of inbox
intitle:"Server Administration"
inurl:passwd.txt
robots.txt
php-addressbook "This is the addressbook for *" -warning
@@ -1,7 +0,0 @@
admin
test
hello
uk
login
book
robots.txt
File diff suppressed because it is too large Load Diff
@@ -426,7 +426,7 @@ a:focus-visible {
.status-chip.queued, .status-chip.cancelling, .status-chip.partial, .status-chip.rate-limited { color: var(--warning); background: var(--warning-soft); }
.status-chip.failed, .status-chip.cancelled { color: var(--danger); background: var(--danger-soft); }
.run-facts { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); margin: 0; border-block-end: 1px solid var(--line); }
.run-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); margin: 0; border-block-end: 1px solid var(--line); }
.run-facts div { min-width: 0; padding: 17px 15px; border-inline-end: 1px solid var(--line); }
.run-facts div:first-child { padding-inline-start: 0; }
.run-facts div:last-child { border: 0; }
@@ -584,7 +584,11 @@ a:focus-visible {
.tabulator-row.tabulator-selected { color: var(--ink); background: var(--accent-soft); }
.tabulator-row .tabulator-cell { padding: 11px 10px; border-color: var(--line); }
.tabulator .tabulator-row .tabulator-cell.tabulator-row-header { background: inherit; }
.tabulator input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--accent-strong); }
.tabulator .tabulator-footer { color: var(--ink-soft); background: var(--surface-muted); border-color: var(--line); }
.tabulator .tabulator-footer .tabulator-footer-contents,
.tabulator .tabulator-footer .tabulator-paginator { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.tabulator .tabulator-footer .tabulator-paginator { justify-content: flex-end; }
.tabulator .tabulator-footer .tabulator-page { min-width: 44px; min-height: 44px; color: var(--ink); background: var(--surface-raised); border-color: var(--line); }
.tabulator .tabulator-footer .tabulator-page-size { min-height: 44px; }
.tabulator .tabulator-footer .tabulator-page.active { color: var(--nav); background: var(--accent); }
@@ -646,7 +650,7 @@ dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.form-grid.three { grid-template-columns: minmax(220px, 1.4fr) minmax(130px, 0.6fr) minmax(160px, 0.7fr); }
.form-grid.three .api-scan-paths { grid-column: 1 / -1; }
.advanced-execution { margin-block: 18px 0; padding: 12px 14px; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; }
.advanced-execution summary { min-height: 32px; align-content: center; font-weight: 780; cursor: pointer; }
.advanced-execution summary { min-height: 44px; align-content: center; font-weight: 780; cursor: pointer; }
.advanced-execution .form-grid { margin-block-end: 0; }
.dialog-content .inline-choice { display: flex; min-height: 44px; align-items: center; align-self: end; gap: 8px; }
.inline-choice input { width: 17px; height: 17px; accent-color: var(--accent-strong); }
@@ -690,6 +694,16 @@ dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.activity-summary { padding: 11px 13px; color: var(--ink) !important; background: var(--accent-soft); border: 1px solid var(--accent); font: 700 12px/1.4 var(--font-mono); }
.form-error { margin: 12px 0 0; padding: 10px 12px; color: var(--danger) !important; background: var(--danger-soft); border: 1px solid var(--danger); font-weight: 650; }
.dialog-actions { display: flex; justify-content: end; gap: 8px; margin-block-start: 24px; }
.final-authorization-review {
display: grid;
min-width: min(100%, 330px);
flex: 1 1 380px;
gap: 3px;
margin: 0 auto 0 0;
color: var(--ink-soft);
}
.final-authorization-review strong { color: var(--ink); font-size: 12px; }
.final-authorization-review span { font: 700 11px/1.45 var(--font-mono); overflow-wrap: anywhere; }
#new-run-dialog .dialog-actions {
position: sticky;
z-index: var(--z-sticky);
@@ -699,6 +713,13 @@ dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
border-block-start: 1px solid var(--line);
}
.action-review-grid { display: grid; grid-template-columns: 1fr 1fr; margin: 22px 0 0; border: 1px solid var(--line-strong); }
.action-review-grid div { min-width: 0; padding: 13px 14px; border-block-end: 1px solid var(--line); }
.action-review-grid div:nth-child(odd) { border-inline-end: 1px solid var(--line); }
.action-review-grid div:last-child { grid-column: 1 / -1; border-block-end: 0; border-inline-end: 0; }
.action-review-grid dt { color: var(--muted); font: 700 10px/1.2 var(--font-mono); letter-spacing: 0.06em; text-transform: uppercase; }
.action-review-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; font-weight: 680; }
.file-drop { position: relative; display: grid; min-height: 210px; margin-block-start: 22px; padding: 28px; place-items: center; align-content: center; text-align: center; background: var(--surface); border: 2px dashed var(--line-strong); border-radius: 9px; cursor: pointer; }
.file-drop:hover, .file-drop:focus-within { border-color: var(--accent); background: var(--accent-soft); }
.file-drop input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; }
@@ -725,19 +746,19 @@ dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.form-grid.three label:last-child { grid-column: 1 / -1; }
}
@media (max-width: 780px) {
@media (max-width: 940px) {
body { font-size: 16px; }
.help-tip::after { position: fixed; inset: auto 16px 16px; width: auto; }
.app-header { position: relative; flex-wrap: wrap; min-height: auto; gap: 9px; padding: 10px 14px; }
.app-header { position: relative; flex-wrap: wrap; min-height: auto; gap: 9px; padding: 10px max(14px, env(safe-area-inset-right)) 10px max(14px, env(safe-area-inset-left)); }
.brand { min-width: 0; }
.brand small { display: none; }
.header-actions { width: 100%; flex-wrap: wrap; order: 2; }
.header-button { flex: 0 0 auto; }
.app-shell { display: block; }
.history-panel { position: relative; inset-block-start: auto; height: auto; padding: 18px 14px; overflow: visible; border-inline-end: 0; border-block-end: 1px solid var(--nav-line); }
.history-panel { position: relative; inset-block-start: auto; height: auto; padding: 18px max(14px, env(safe-area-inset-right)) 18px max(14px, env(safe-area-inset-left)); overflow: visible; border-inline-end: 0; border-block-end: 1px solid var(--nav-line); }
.run-list { display: flex; padding-block-end: 4px; overflow-x: auto; }
.run-item { min-width: 235px; flex: 0 0 235px; }
.workbench { padding: 25px 16px 48px; }
.workbench { padding: 25px max(16px, env(safe-area-inset-right)) max(48px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
.detail-header, .section-heading, .results-heading { align-items: start; flex-direction: column; }
.detail-status { width: 100%; align-items: start; }
.status-chips { justify-content: start; }
@@ -755,7 +776,7 @@ dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.activity-bands { grid-template-columns: 1fr; }
.request-options { grid-template-columns: 1fr; }
.source-groups { max-height: none; grid-template-columns: 1fr; overflow: visible; }
.source-selection-summary { position: sticky; z-index: var(--z-sticky); inset-block-start: 0; }
.source-selection-summary { position: static; }
.source-tools { align-items: stretch; flex-direction: column; }
.source-actions .button { flex: 1; }
.action-grid { grid-template-columns: 1fr 1fr; }
@@ -767,10 +788,13 @@ dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.table-toolbar input { width: 100%; }
.table-guidance { max-width: none; }
.tabulator .tabulator-header .tabulator-col .tabulator-header-filter input { min-height: 44px; }
.tabulator .tabulator-footer .tabulator-footer-contents { justify-content: flex-start; white-space: normal; }
.tabulator .tabulator-footer .tabulator-paginator { width: 100%; justify-content: flex-start; }
.provider-table th:nth-child(5), .provider-table td:nth-child(5) { display: none; }
.empty-guardrails { grid-template-columns: 1fr; }
.empty-guardrails div { border-inline-end: 0; border-block-end: 1px solid var(--line); }
.empty-guardrails div:last-child { border-block-end: 0; }
#new-run-dialog .dialog-actions { position: static; }
}
@media (max-width: 500px) {
@@ -782,8 +806,13 @@ dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
.detail-identity h2 { font-size: 34px; }
.action-grid { grid-template-columns: 1fr; }
.dialog-content { padding: 20px 16px; }
.dialog-actions { align-items: stretch; flex-direction: column-reverse; }
.dialog-actions { align-items: stretch; flex-direction: column; }
.dialog-actions .button { width: 100%; }
.final-authorization-review { min-width: 0; flex: 0 1 auto; }
.action-review-grid { grid-template-columns: 1fr; }
.action-review-grid div, .action-review-grid div:nth-child(odd), .action-review-grid div:last-child { grid-column: auto; border-inline-end: 0; }
.action-review-grid div:last-child { border-block-end: 0; }
.brand { min-height: 44px; }
.provider-table th:nth-child(4), .provider-table td:nth-child(4) { display: none; }
}
+98 -11
View File
@@ -26,6 +26,7 @@
pollTimer: null,
pollErrorShown: false,
resultTable: null,
pendingResultAction: null,
screenshotUrls: new Map(),
};
@@ -50,11 +51,16 @@
exportJsonl: $('#export-jsonl-button'), screenshotSection: $('#screenshot-section'),
screenshotGallery: $('#screenshot-gallery'), logSection: $('#log-section'), logOutput: $('#run-log-output'),
newRunDialog: $('#new-run-dialog'), newRunForm: $('#new-run-form'), sourceSearch: $('#source-search'), sourceGroups: $('#source-groups'),
sourceSelectionSummary: $('#source-selection-summary'),
sourceSelectionSummary: $('#source-selection-summary'), finalAuthorizationSummary: $('#final-authorization-summary'),
sourceCapability: $('#source-capability'), selectCapability: $('#select-capability-button'),
selectP0: $('#select-p0-button'), clearP0: $('#clear-p0-button'),
dnsResolvers: $('#dns-resolvers'), dnsResolverFile: $('#dns-resolver-file'),
activitySummary: $('#activity-summary'), newRunError: $('#new-run-error'), submitRun: $('#submit-run-button'),
resultActionDialog: $('#result-action-dialog'), resultActionForm: $('#result-action-form'),
resultActionTitle: $('#result-action-title'), resultActionIntro: $('#result-action-intro'),
resultActionTarget: $('#result-action-target'), resultActionBand: $('#result-action-band'),
resultActionNetwork: $('#result-action-network'), resultActionResolvers: $('#result-action-resolvers'),
confirmResultAction: $('#confirm-result-action-button'),
importDialog: $('#import-dialog'), importForm: $('#import-form'), resultFile: $('#result-file'), fileLabel: $('#file-label'),
importError: $('#import-error'), submitImport: $('#submit-import-button'), screenshotDialog: $('#screenshot-dialog'),
screenshotDialogTitle: $('#screenshot-dialog-title'), screenshotDialogImage: $('#screenshot-dialog-image'),
@@ -260,14 +266,29 @@
}
function renderFacts(run) {
const facts = [
['Origin', run.origin], ['Submitted', formatDate(run.created_at)], ['Started', formatDate(run.started_at)],
['Duration', formatDuration(run.started_at, run.completed_at)], ['Results', Number(run.result_count || 0).toLocaleString()]
];
const facts = run.origin === 'imported'
? [
['Origin', 'Imported evidence'], ['Imported', formatDate(run.created_at)],
['Original started', formatDate(run.started_at)], ['Original completed', formatDate(run.completed_at)],
['Duration', formatDuration(run.started_at, run.completed_at)], ['Results', Number(run.result_count || 0).toLocaleString()]
]
: [
['Origin', run.origin], ['Submitted', formatDate(run.created_at)], ['Started', formatDate(run.started_at)],
['Duration', formatDuration(run.started_at, run.completed_at)], ['Results', Number(run.result_count || 0).toLocaleString()]
];
nodes.runFacts.innerHTML = facts.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join('');
}
function renderLifecycle(run) {
if (run.origin === 'imported') {
const steps = [
['Original started', run.started_at], ['Original completed', run.completed_at], ['Imported', run.created_at]
];
nodes.lifecycleTrack.innerHTML = steps.map(([label, time]) => `
<li class="lifecycle-step ${time ? 'reached' : ''}"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(formatDate(time))}</span></li>`).join('');
nodes.lifecycleNote.textContent = 'Imported evidence retains its original execution timing; local import time is shown separately.';
return;
}
const terminalLabel = run.status === 'completed' ? 'Completed' : run.status === 'failed' ? 'Failed' : run.status === 'cancelled' ? 'Cancelled' : 'Terminal';
const steps = [['Submitted', run.created_at], ['Started', run.started_at]];
if (run.cancellation_requested_at) steps.push(['Cancellation requested', run.cancellation_requested_at]);
@@ -336,7 +357,11 @@
function updateSourceSelectionSummary() {
const selected = state.sources.filter(source => state.selectedSources.has(source.name) && sourceIsReady(source));
nodes.sourceSelectionSummary.textContent = `Selected ${formatCount(selected.length, 'ready source')}.`;
const names = selected.map(source => source.name);
const visibleNames = names.slice(0, 4).join(', ');
const remainder = names.length > 4 ? ` +${names.length - 4} more` : '';
const selection = names.length ? `: ${visibleNames}${remainder}` : '';
nodes.sourceSelectionSummary.textContent = `Selected ${formatCount(names.length, 'ready source')}${selection}.`;
}
function executionReason(execution) {
@@ -610,6 +635,20 @@
return escapeHtml(values.join(', ') || '-');
}
function labelResultTableControls() {
const headerSelection = nodes.resultWorkbench.querySelector('.tabulator-header .tabulator-row-header input[type="checkbox"]');
headerSelection?.setAttribute('aria-label', 'Select all rows on this route');
for (const column of nodes.resultWorkbench.querySelectorAll('.tabulator-col[tabulator-field]')) {
const title = column.querySelector('.tabulator-col-title')?.textContent?.trim();
const filter = column.querySelector('.tabulator-header-filter input');
if (title && filter) filter.setAttribute('aria-label', `Filter ${title} column`);
}
for (const row of state.resultTable?.getRows() || []) {
const checkbox = row.getElement().querySelector('.tabulator-row-header input[type="checkbox"]');
if (checkbox) checkbox.setAttribute('aria-label', `Select ${row.getData().value}`);
}
}
function mountResultTable(rows) {
state.resultTable?.destroy();
nodes.copySelected.disabled = true;
@@ -666,7 +705,7 @@
const button = event.target.closest('[data-run-action]');
if (!button) return;
event.stopPropagation();
queueResultAction(button.dataset.runAction, cell.getRow().getData().value, button);
reviewResultAction(button.dataset.runAction, cell.getRow().getData().value, button);
},
});
}
@@ -695,6 +734,8 @@
nodes.copySelected.disabled = selected.length === 0;
nodes.copySelected.textContent = selected.length ? `Copy selected (${selected.length})` : 'Copy selected';
});
state.resultTable.on('renderComplete', labelResultTableControls);
requestAnimationFrame(labelResultTableControls);
}
function renderResults(run) {
@@ -932,6 +973,22 @@
const activities = selectedActivities();
nodes.activitySummary.textContent = `P0 ${activities.has('P0') ? 'selected' : 'off'} · P1 ${activities.has('P1') ? 'selected' : 'off'} · P2 ${activities.has('P2') ? 'selected' : 'off'}`;
nodes.activitySummary.style.borderColor = activities.has('P2') ? 'var(--danger)' : activities.has('P1') ? 'var(--warning)' : 'var(--accent)';
const target = nodes.newRunForm.elements.target.value.trim();
const selected = state.sources.filter(source => state.selectedSources.has(source.name) && sourceIsReady(source));
const names = selected.map(source => source.name);
const sourceNames = names.slice(0, 4).join(', ');
const sourceRemainder = names.length > 4 ? ` +${names.length - 4} more` : '';
const sourceSummary = names.length
? `${formatCount(names.length, 'source')}: ${sourceNames}${sourceRemainder}`
: '0 sources';
const deadline = nodes.newRunForm.elements.deadline_seconds.value;
nodes.finalAuthorizationSummary.textContent = [
`Target ${target || 'not set'}`, sourceSummary,
`P0 ${activities.has('P0') ? 'selected' : 'off'}`,
`P1 ${activities.has('P1') ? 'selected' : 'off'}`,
`P2 ${activities.has('P2') ? 'selected' : 'off'}`,
`Deadline ${deadline ? `${deadline} seconds` : 'unlimited'}`
].join(' · ');
}
function openNewRun() {
@@ -1013,6 +1070,36 @@
}
}
function reviewResultAction(action, target, button) {
const screenshot = action === 'screenshot';
const resolvers = state.detail?.request?.dns_resolvers;
state.pendingResultAction = {action, target, button};
nodes.resultActionTitle.textContent = screenshot
? 'Review screenshot interaction'
: 'Review DNS brute force interaction';
nodes.resultActionIntro.textContent = screenshot
? 'Confirm a direct browser interaction with this retained hostname.'
: 'Confirm DNS candidate-label queries for this retained hostname.';
nodes.resultActionTarget.textContent = target;
nodes.resultActionBand.textContent = screenshot ? 'P2 · Direct interaction' : 'P1 · DNS interaction';
nodes.resultActionNetwork.textContent = screenshot
? 'Launches a browser request to the retained hostname and captures the response.'
: 'Queries DNS candidate labels for the retained hostname.';
nodes.resultActionResolvers.textContent = screenshot
? 'Not applicable'
: Array.isArray(resolvers) && resolvers.length ? resolvers.join(', ') : 'Use the default configured resolvers';
nodes.confirmResultAction.textContent = screenshot ? 'Start screenshot run' : 'Start DNS brute force run';
openDialog(nodes.resultActionDialog, '#confirm-result-action-button');
}
async function submitResultAction(event) {
event.preventDefault();
const pending = state.pendingResultAction;
if (!pending) return;
closeDialog(nodes.resultActionDialog);
await queueResultAction(pending.action, pending.target, pending.button);
}
async function submitRun(event) {
event.preventDefault();
showFormError(nodes.newRunError, '');
@@ -1216,6 +1303,7 @@
nodes.importButton.addEventListener('click', openImport);
nodes.historySearch.addEventListener('input', renderHistory);
nodes.newRunForm.addEventListener('submit', submitRun);
nodes.resultActionForm.addEventListener('submit', submitResultAction);
nodes.importForm.addEventListener('submit', submitImport);
nodes.cancel.addEventListener('click', requestCancellation);
nodes.reviewOutcomes.addEventListener('click', () => {
@@ -1242,9 +1330,7 @@
updateActivitySummary();
});
nodes.newRunForm.addEventListener('change', updateActivitySummary);
nodes.newRunForm.addEventListener('input', event => {
if (event.target.matches('[name="vhost_endpoint"], [name="vhost_candidates"]')) updateActivitySummary();
});
nodes.newRunForm.addEventListener('input', updateActivitySummary);
nodes.dnsResolverFile.addEventListener('change', async () => {
const file = nodes.dnsResolverFile.files[0];
if (!file) return;
@@ -1278,11 +1364,12 @@
if (closeButton) closeDialog(closeButton.closest('dialog'));
});
for (const dialog of [nodes.newRunDialog, nodes.importDialog, nodes.screenshotDialog]) {
for (const dialog of [nodes.newRunDialog, nodes.resultActionDialog, nodes.importDialog, nodes.screenshotDialog]) {
dialog.addEventListener('click', event => {
if (event.target === dialog) closeDialog(dialog);
});
}
nodes.resultActionDialog.addEventListener('close', () => { state.pendingResultAction = null; });
window.addEventListener('beforeunload', () => {
stopPolling();
@@ -2,7 +2,7 @@
<html lang="en" data-theme="system">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>HarvestView</title>
<link rel="icon" href="/static/harvestview/logo.webp">
@@ -337,12 +337,37 @@
<p id="activity-summary" class="activity-summary">P0 selected · P1 off · P2 off</p>
<p id="new-run-error" class="form-error" role="alert" hidden></p>
<footer class="dialog-actions">
<p class="final-authorization-review">
<strong>Final authorization review</strong>
<span id="final-authorization-summary" aria-live="polite">Target not set · 1 source: crtsh · P0 selected · P1 off · P2 off · Deadline unlimited</span>
</p>
<button class="button" type="button" data-close-dialog>Keep reviewing</button>
<button id="submit-run-button" class="button primary" type="submit">Start enumeration</button>
</footer>
</form>
</dialog>
<dialog id="result-action-dialog" class="form-dialog" aria-labelledby="result-action-title">
<form id="result-action-form" class="dialog-content">
<header class="dialog-header">
<div><h2 id="result-action-title">Review result interaction</h2></div>
<button class="icon-button dialog-close" type="button" data-close-dialog aria-label="Close result action review">×</button>
</header>
<p id="result-action-intro">Review the exact interaction before creating a child run.</p>
<dl class="action-review-grid">
<div><dt>Target</dt><dd id="result-action-target"></dd></div>
<div><dt>Authorization band</dt><dd id="result-action-band"></dd></div>
<div><dt>Network behavior</dt><dd id="result-action-network"></dd></div>
<div><dt>Resolver settings</dt><dd id="result-action-resolvers"></dd></div>
<div><dt>Evidence boundary</dt><dd>Creates a separate finite run. The parent evidence remains unchanged.</dd></div>
</dl>
<footer class="dialog-actions">
<button class="button" type="button" data-close-dialog>Keep reviewing</button>
<button id="confirm-result-action-button" class="button primary" type="submit">Start child run</button>
</footer>
</form>
</dialog>
<dialog id="import-dialog" class="form-dialog" aria-labelledby="import-title">
<form id="import-form" class="dialog-content">
<header class="dialog-header">