mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
Merge pull request #2555 from NotoriousRebel/codex/release-feature-validation
Add repeatable release validation and harden HarvestView
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
name: HarvestView container smoke
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: HarvestView browser E2E
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
name: TheHarvester Python CI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user