From d6ce2275a93e39207db673fe4f26afaac90ccab7 Mon Sep 17 00:00:00 2001 From: HungBil Date: Mon, 7 Sep 2026 20:56:38 +0700 Subject: [PATCH 1/3] feat(reports): add sanitized DOCX CSV JSON downloads and release checklist --- README.md | 8 + contracts/api/public-api.openapi.yaml | 38 +++- docs/ACCEPTANCE_CRITERIA.md | 5 +- docs/ARCHITECTURE.md | 2 +- docs/PUBLIC_RELEASE_READINESS.md | 201 ++++++++++++++++++ frontend/src/pages/Reports.tsx | 53 ++++- frontend/tests/report-downloads.mjs | 82 +++++++ gateway/public-api/package.json | 1 + gateway/public-api/pnpm-lock.yaml | 144 +++++++++++++ .../modules/reports/report-downloads.test.ts | 78 +++++++ .../src/modules/reports/report-downloads.ts | 77 +++++++ .../src/modules/reports/reports.controller.ts | 16 +- .../src/modules/reports/reports.service.ts | 26 ++- 13 files changed, 715 insertions(+), 16 deletions(-) create mode 100644 docs/PUBLIC_RELEASE_READINESS.md create mode 100644 frontend/tests/report-downloads.mjs create mode 100644 gateway/public-api/src/modules/reports/report-downloads.test.ts create mode 100644 gateway/public-api/src/modules/reports/report-downloads.ts diff --git a/README.md b/README.md index b94d7ef1..ca7545fb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ Technical requirements for OpenHunterAI's black-box pentest workflow and MVP architecture. +> Release status: this document mixes implemented features with proposed tools. +> The running stack is React/Vite, Express/TypeScript, Go, Postgres and NATS; +> the technology catalogue below is not a ready-to-use capability guarantee. +> See [public release gates](docs/PUBLIC_RELEASE_READINESS.md), +> [current architecture](docs/ARCHITECTURE.md) and +> [mandatory security guardrails](docs/SECURITY_GUARDRAILS.md). +> Raw credentials, HAR and sensitive request/response data are not report artifacts. + ## 1. Technical Scope - **Model**: AI Agentic Workflow — AI orchestrates, analyzes context, and chooses actions; open-source tools handle recon, crawling, request sending, OAST, and known-vulnerability checks. diff --git a/contracts/api/public-api.openapi.yaml b/contracts/api/public-api.openapi.yaml index 0889a742..21063419 100644 --- a/contracts/api/public-api.openapi.yaml +++ b/contracts/api/public-api.openapi.yaml @@ -72,11 +72,45 @@ paths: - name: format in: query schema: - enum: [html, pdf] + type: string + enum: [docx, csv, json, html, pdf] + default: html - name: view in: query schema: + type: string enum: [snapshot, latest] + default: latest responses: "200": - description: On-demand sanitized HTML/PDF export + description: On-demand sanitized final or superseded report download; Content-Disposition attachment. JSON contains snapshot and optional latestOverlay. CSV separates findings, hardening, coverage gaps, and limitations. + content: + application/json: + schema: + type: object + required: [snapshot] + properties: + snapshot: + type: object + latestOverlay: + type: object + text/csv: + schema: + type: string + application/vnd.openxmlformats-officedocument.wordprocessingml.document: + schema: + type: string + format: binary + text/html: + schema: + type: string + application/pdf: + schema: + type: string + format: binary + "400": + description: Unsupported format/view, or report is not finalized + "401": + description: Authentication required + "404": + description: Report does not exist in the current organization diff --git a/docs/ACCEPTANCE_CRITERIA.md b/docs/ACCEPTANCE_CRITERIA.md index 46bb7700..4a107d07 100644 --- a/docs/ACCEPTANCE_CRITERIA.md +++ b/docs/ACCEPTANCE_CRITERIA.md @@ -115,7 +115,10 @@ raw credentials/cookies/tokens/HAR/request/response are not stored/logged/report detected secrets store only masked fingerprint/hash/metadata report_v1 structured JSON is the canonical report snapshot draft sections stream over SSE -HTML/PDF exports are generated on demand and not persisted as artifacts +DOCX/CSV/JSON/HTML/PDF exports are generated on demand and not persisted as artifacts +downloads require an org-scoped final or superseded report; drafts cannot be downloaded as final +JSON preserves the report_v1 snapshot; latest status is an optional separate overlay +CSV neutralizes spreadsheet formulas and separates validated findings from hardening/coverage rows LLM ranking recommendations cannot invent findings or override deterministic policy ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a1d30172..f60c3000 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,7 @@ web dashboard → manual retest / Monitor Workspace ``` -Core v1 stores sanitized reports/findings in Postgres. External artifact storage is not a core v1 dependency. HTML/PDF exports are rendered on demand from sanitized report JSON and are not stored as files. +Core v1 stores sanitized reports/findings in Postgres. External artifact storage is not a core v1 dependency. DOCX/CSV/JSON/HTML/PDF exports are rendered on demand from sanitized report JSON and are not stored as files. Downloads require an org-scoped final or superseded snapshot. JSON includes the canonical snapshot and, when requested, a separate latest-status overlay. CSV is a flat findings/coverage summary, not a lossless replacement for JSON. Reports use `report_v1` structured JSON snapshots in Postgres. Draft report sections stream to the UI through SSE while a scan runs. diff --git a/docs/PUBLIC_RELEASE_READINESS.md b/docs/PUBLIC_RELEASE_READINESS.md new file mode 100644 index 00000000..50143984 --- /dev/null +++ b/docs/PUBLIC_RELEASE_READINESS.md @@ -0,0 +1,201 @@ +# Public Release Readiness + +Baseline: LumosLab-Innovation/OpenHunterAI `main` at +`48bf9036debf797cef3d24308c3cfa1ec2a20c15` (2026-08-26). +Implementation branch: `feat/public-release-readiness`. + +This is a release gate, not a claim that the product is ready. Do not change +repository visibility, deploy, or start a target scan as part of this checklist. +The earlier June checkout is not the release baseline. + +## 1. License and ownership + +Owner intent: community inspection, contribution and noncommercial use; +commercial use requires a separately negotiated license from the rights holder. + +Recommended model: unmodified **PolyForm Noncommercial 1.0.0** for community +distribution, plus a separate commercial agreement. Describe this as +**source-available**, not OSI open source. An OSI license cannot prohibit +commercial use. PolyForm also permits specified nonprofit/institutional uses; +it is not a personal-use-only license. + +The commercial agreement should cover the intended use (internal commercial +testing, paid services, hosting, redistribution), term, fees, support, liability +and transfer on acquisition. The community license does not automatically collect +fees or guarantee a sale. Existing grants and third-party rights survive according +to their terms. + +Do not apply a new root license until these gates are met: + +- [ ] Owner confirms the actual legal licensor name and commercial contact. +- [ ] Confirm rights to the existing contributions. Git author names are not + proof of employment, assignment or permission to relicense. +- [ ] Inventory third-party code and retain its licenses/notices. In particular, + `third_party_research/openhack/LICENSE` is MIT and + `third_party_research/strix/LICENSE` is Apache-2.0; neither becomes + noncommercial merely by adding a root license. +- [ ] Counsel reviews the commercial agreement and contributor agreement (CLA). + Prefer a nonexclusive grant that lets contributors retain their copyright, + while expressly allowing commercial sublicensing, relicensing and transfer + to a successor. A DCO or PR checkbox alone does not establish these rights. +- [ ] Establish a verifiable CLA acceptance record before merging new external + contributions. Resolve existing contributions separately; no retroactive + assumption of consent. +- [ ] Add the exact license text, copyright notice, licensing FAQ and contribution + instructions after approval. Do not publish a placeholder legal identity. + +References: [PolyForm terms](https://polyformproject.org/licenses/noncommercial/1.0.0), +[OSI definition](https://opensource.org/osd), +[Harmony contributor agreement options](https://www.harmonyagreements.org/docs/ha-combined-v1). +These are implementation recommendations, not a legal opinion. + +## 2. Keep the actual architecture + +```text +Local web / terminal / user-invoked coding skill + -> authenticated Public API + -> verified scope + authorization + deterministic plan + approval gates + -> Postgres (state, sanitized activity, report_v1) + -> NATS -> orchestrator -> Browser / R / Z / N / O / S + -> LLM Gateway (configured aliases only) + -> finding gate -> reporting + -> browser-session (manual login; encrypted captured state with expiry) + <- persisted SSE events, final report downloads, explicit failure/coverage +``` + +Use the existing Express/TypeScript API, React/Vite frontend, Go workers, +Postgres and NATS. Do not introduce Python/FastAPI/Celery/Next.js/MinIO merely +because the current README names them in a technology wish list. Reconcile +the README with implementation and move unimplemented proposals into a roadmap. +The priority docs remain SECURITY_GUARDRAILS, ACCEPTANCE_CRITERIA and PRD. + +R is already in current main's plan and local composition. Do not delete it to +match an older four-unit description. Its staging/CI deployment remains pending +per PRODUCTION_READINESS. Compact UI labels and upstream attribution serve +different purposes: preserve legal/tool attribution in documentation. + +## 3. Delivery phases and exit checks + +### A. Downloads (implemented on this branch, not deployed) + +- DOCX, CSV and JSON join the existing HTML/PDF export endpoint. +- Generate on demand; no new artifact store or raw evidence retention. +- Require the existing org-scoped report lookup and final/superseded state. +- JSON: `snapshot.content` remains `report_v1`; `view=latest` adds + `latestOverlay` without rewriting the stored snapshot. +- DOCX: scope, summary, findings/proof references, developer fixes, coverage, + hardening, limitations and manual retest context. +- CSV: UTF-8 BOM, CRLF, quoted cells, formula neutralization; + `type,id,title,severity,confidence,asset,detail,fix` columns. Row types are + summary, validated_finding, hardening, coverage_gap, limitation, skipped and + optional latest_status. Use JSON for complete machine-readable data. +- The web uses an authenticated download, format/view selectors, pending/error + states and disables download while the report is a draft. + +Checks: `npm test` and `npm run typecheck` in `gateway/public-api`; +`npm run build` in `frontend`; browser checks for desktop/mobile download, +failure and draft states. Tests use explicitly local fixtures, not real scans. + +Verified 2026-09-07: 48 API tests passed, API typecheck passed, frontend production +build passed. `frontend/tests/report-downloads.mjs` passed desktop (1440px) and +mobile (390px) download/view checks, draft disabling, error display and no +horizontal overflow or page errors. This does not prove a deployed scan. +Run the browser check against a local Vite server on port 3017 with +`node tests/report-downloads.mjs` from `frontend`. It needs Playwright; +`PLAYWRIGHT_PACKAGE` may point to an existing installation and +`PLAYWRIGHT_CHROMIUM_EXECUTABLE` to an existing Chromium executable. It does not +connect to a real target or install a production mock path. + +Existing limitation: the older PDF renderer truncates to 45 lines and does not +properly support Unicode. It is not proof of a complete report export. Fix or +retire that implementation before claiming every advertised format is complete. + +### B. One reproducible local setup + +- Pin the toolchain and dependency locks. Fresh clone must not depend on a + developer's generated Prisma client or machine-global ignore settings. +- Supply a Docker-first bootstrap, migration and health check using existing + compose files. Keep internal services off public interfaces. +- Generate local secrets, keep provider keys in ignored local configuration, + and report missing configuration truthfully. Never embed production keys. +- Document verification, authorization, manual login, start, stop and export + as a short working quickstart. Separate optional provider setup from startup. +- Exit: a clean machine can reach the UI, configure its own provider, authorize + an external test target, obtain a real final report and stop the local stack. + Restart preserves data; uninstall does not delete volumes without confirmation. + +### C. Terminal streaming and live console + +- Reuse persisted live events and the same public authentication/scope checks. + No extra event bus and no browser-accessible shell execution. +- A bounded full-width log view: timestamp, actor, phase, level and sanitized + text; filters, pause display, follow tail and connection state. +- A terminal client renders the same events. Strip control/ANSI/OSC sequences + from untrusted text; never accept shell commands from an event body. +- Cursor ordering must include a stable tie-breaker; resume after disconnect + without gaps or duplicate rows. Initial pagination cannot silently discard + events beyond a fixed window. Terminal completion follows backend completion. +- Exit: test more than 1,000 events, reconnect/reload, equal timestamps, + cancellation, slow consumer, failed backend and mobile overflow. No fake + delay, simulated evidence or raw chain-of-thought. + +### D. Module settings and durable checkpoints + +- Reuse the deterministic plan builder. User selection only narrows what + authorization, package, intensity, budgets and policy already permit. +- Allow optional execution units to be disabled; record effective settings + on the scan and skipped units with reasons in coverage. Fan-in waits only + for the effective plan, never a disabled unit. +- Scope validation, credential/evidence sanitizer, approval enforcement, + finding promotion and final reporting are mandatory and cannot be toggled off. +- Checkpoints pause before new module dispatch, not by pretending an in-flight + action was undone. Persist state; resume must not repeat completed work. + Cancellation prevents new actions and finalizes partial coverage honestly. +- Keep user checkpoint decisions distinct from sensitive-action approval. + A checkpoint resume never grants an approval or refreshes expired scope. +- Exit: restart at each checkpoint, duplicate callback/decision, scope expiry, + timeout, cancel during work, skipped dependencies and all-optional-units-off. + No quota or Free Hunter policy changes without explicit owner approval. + +### E. Coding-tool package + +- Start with one user-invoked skill and a thin client for existing public APIs, + not new GitHub/Jira integrations or a second privileged execution path. +- Document supported clients and installation/removal. A plugin only packages + that skill/client when a selected coding client actually requires one. +- Actions: inspect an authorized plan, start on explicit user request, follow + events, retrieve sanitized reports and request a narrow manual retest. +- Never give the model raw login storage, cookies, passwords, keys or an + automatic approval capability. Use existing human approval/login rooms. +- Exit: clean install, sign-in/out, expired auth, wrong-org denial, uninstall, + safe error rendering and no background/deployment-triggered retest. + +### F. Release acceptance + +- [ ] All legal gates in section 1 approved. +- [ ] README matches the actual stack and tested capabilities. +- [ ] Check tracked files and full git history for secrets without printing + values. Rotate any previously exposed credentials before public release; + deleting them from the latest file alone is insufficient. +- [ ] Publish a third-party notice inventory; verify compatibility of binaries, + templates and copied code, not just top-level npm dependencies. +- [ ] Full local E2E on an authorized controlled external test target with a + known vulnerable route and fixed negative control; no private target. +- [ ] User completes any required login/captcha/MFA manually. +- [ ] Validated finding has actual sanitized evidence; fixed route creates no + fabricated finding; coverage-only completion remains honest. +- [ ] Exports match the final report, including Free limits and skipped modules. +- [ ] Independent QA reviews evidence for each phase. A green build alone does + not certify deployment, scan effectiveness or secret absence. +- [ ] Any release/deployment records exact SHA and runtime/browser evidence. + +## 4. Owner decisions still needed + +1. Legal licensor identity, rights to existing contributions, commercial contact. +2. Local distribution retains existing Free/paid quotas, or has a separately + approved noncommercial entitlement? Until decided, preserve existing quotas. +3. Named coding clients for the first supported installation; do not promise + every editor or add unrequested integrations. + +No changes to licensing, package entitlements, public visibility or production +infrastructure are implied by passing phase A. diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 8b98659c..d8d450da 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -5,6 +5,7 @@ import { API_BASE, apiFetch } from '../lib/api'; import { PageHeader } from '../components/PageHeader'; import { Badge, SeverityBadge, StatusBadge } from '../components/ui/Badge'; import { Button } from '../components/ui/Button'; +import { Select } from '../components/ui/Field'; import { Card, CardBody, CardHeader, CardTitle } from '../components/ui/Card'; import { EmptyState, ErrorState } from '../components/ui/States'; import { SkeletonRows } from '../components/ui/Loading'; @@ -144,6 +145,10 @@ function Section({ title, children }: { title: string; children: React.ReactNode export function ReportDetailPage() { const { id = '' } = useParams(); const [report, setReport] = useState(null); + const [format, setFormat] = useState('docx'); + const [view, setView] = useState('snapshot'); + const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -156,28 +161,62 @@ export function ReportDetailPage() { }, [id]); const content = report?.snapshot.content ?? {}; - const exportBase = `${API_BASE}/v1/reports/${id}/export`; + const exportFormats = ['docx', 'csv', 'json', 'html', 'pdf'].filter((item) => report?.exports.formats.includes(item)); + const selectedFormat = exportFormats.includes(format) ? format : (exportFormats[0] ?? 'docx'); + const canExport = !!report && ['final', 'superseded'].includes(report.snapshot.state) && exportFormats.length > 0; const findings = content.findings ?? []; + async function downloadReport() { + if (!canExport || downloading) return; + setDownloading(true); + setDownloadError(null); + try { + const response = await fetch(`${API_BASE}/v1/reports/${encodeURIComponent(id)}/export?${new URLSearchParams({ format: selectedFormat, view })}`, { + credentials: 'include', cache: 'no-store', signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.error?.message ?? `Download failed (HTTP ${response.status})`); + } + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement('a'); + link.href = url; + link.download = `openhunter-report-${id}.${selectedFormat}`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch (err) { + setDownloadError(err instanceof Error ? err.message : 'Report download failed'); + } finally { + setDownloading(false); + } + } + return (
{id}} actions={ -
+
- -
} /> {error && } + {downloadError && } {loading ? ( ) : ( diff --git a/frontend/tests/report-downloads.mjs b/frontend/tests/report-downloads.mjs new file mode 100644 index 00000000..5569b69e --- /dev/null +++ b/frontend/tests/report-downloads.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Local UI transport check. Actual DOCX/CSV/JSON generation is tested in public-api. +const require = createRequire(import.meta.url); +const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE ?? 'playwright'); +const base = new URL(process.env.QA_BASE_URL ?? 'http://127.0.0.1:3017'); +assert(['127.0.0.1', 'localhost'].includes(base.hostname), 'Fixtures are local-only'); +const output = join(tmpdir(), 'openhunter-report-qa'); +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ + headless: true, + ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE } : {}), +}); +try { + const page = await browser.newPage(); + page.setDefaultTimeout(10_000); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + let state = 'final'; + let rejectDownload = false; + const requests = []; + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + if (url.pathname.endsWith('/auth/me')) return route.fulfill({ json: { + user: { id: 'qa', email: 'qa@example.test', displayName: 'Local QA', orgName: 'MOCK / DEMO DATA' }, + } }); + if (url.pathname.endsWith('/export')) { + requests.push(url.search); + return route.fulfill(rejectDownload + ? { status: 400, json: { error: { message: 'Report is still being prepared' } } } + : { contentType: 'application/octet-stream', body: 'MOCK / DEMO DATA: download transport only' }); + } + if (url.pathname.endsWith('/reports/qa-report')) return route.fulfill({ json: { report: { + snapshot: { id: 'qa-report', version: 1, state, generatedAt: '2026-09-07T00:00:00.000Z', content: { + ownerSummary: { headline: 'MOCK / DEMO DATA: report export QA', riskLevel: 'none', + topRiskOrOutcome: 'No validated Critical/High findings in the tested scope.', + businessImpact: 'Local UI fixture only.', recommendedNextAction: 'Review coverage and limitations.' }, + findings: [], coverage: { workersRun: ['Browser', 'R', 'Z', 'N', 'O', 'S'], huntersRun: [], + limitations: ['UI fixture, not scan evidence.'] }, + } }, + latestOverlay: { findingStatuses: [], retestStates: [], monitor: { maxRetests: 1, cooldownDays: 7, maxMonitoredFindings: 1 } }, + exports: { formats: ['docx', 'csv', 'json', 'html', 'pdf'], generatedOnDemand: true }, + } } }); + return route.fulfill({ status: 404, json: { error: { code: 'NOT_FOUND' } } }); + }); + for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) { + await page.setViewportSize(viewport); + await page.goto(`${base.origin}/reports/qa-report`); + const button = page.getByRole('button', { name: 'Download report', exact: true }); + await button.waitFor(); + for (const format of ['docx', 'csv', 'json']) { + await page.getByLabel('Report format').selectOption(format); + const pending = page.waitForEvent('download'); + await button.click(); + const download = await pending; + assert.equal(download.suggestedFilename(), `openhunter-report-qa-report.${format}`); + assert.equal(requests.at(-1), `?format=${format}&view=snapshot`); + } + await page.getByLabel('Report view').selectOption('latest'); + const pending = page.waitForEvent('download'); + await button.click(); + await pending; + assert.equal(requests.at(-1), '?format=json&view=latest'); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await page.screenshot({ path: join(output, `reports-${viewport.width}.png`), fullPage: true }); + } + rejectDownload = true; + await page.getByRole('button', { name: 'Download report', exact: true }).click(); + await page.getByText('Report is still being prepared', { exact: true }).waitFor(); + state = 'draft'; + await page.reload(); + await page.getByRole('heading', { name: 'MOCK / DEMO DATA: report export QA' }).waitFor(); + assert.equal(await page.getByRole('button', { name: 'Download report', exact: true }).isDisabled(), true); + assert.deepEqual(errors, []); + console.log(`PASS: desktop/mobile downloads, latest view, no overflow, error and draft states. Screenshots: ${output}`); +} finally { + await browser.close(); +} diff --git a/gateway/public-api/package.json b/gateway/public-api/package.json index 45ade038..e0af2ccb 100644 --- a/gateway/public-api/package.json +++ b/gateway/public-api/package.json @@ -18,6 +18,7 @@ "@x-hunter/shared": "file:../../shared/security-core", "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "docx": "9.7.1", "express": "^4.19.2", "express-rate-limit": "^7.4.0", "helmet": "^7.1.0", diff --git a/gateway/public-api/pnpm-lock.yaml b/gateway/public-api/pnpm-lock.yaml index 6cb38e27..02048e66 100644 --- a/gateway/public-api/pnpm-lock.yaml +++ b/gateway/public-api/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.6 + docx: + specifier: 9.7.1 + version: 9.7.1 express: specifier: ^4.19.2 version: 4.22.2 @@ -555,6 +558,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -666,6 +672,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -707,6 +716,10 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + docx@9.7.1: + resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==} + engines: {node: '>=10'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -819,6 +832,9 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -843,6 +859,9 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -854,12 +873,21 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + local-pkg@0.5.1: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} @@ -905,6 +933,9 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -919,6 +950,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + nats@2.29.3: resolution: {integrity: sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==} engines: {node: '>= 14.0.0'} @@ -956,6 +992,9 @@ packages: resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} engines: {node: '>=18'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -994,6 +1033,9 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1013,6 +1055,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -1021,12 +1066,19 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -1035,6 +1087,9 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -1083,6 +1138,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -1132,10 +1190,16 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -1215,6 +1279,13 @@ packages: engines: {node: '>=8'} hasBin: true + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -1505,6 +1576,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -1644,6 +1719,8 @@ snapshots: cookie@0.7.2: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -1673,6 +1750,15 @@ snapshots: diff-sequences@29.6.3: {} + docx@9.7.1: + dependencies: + '@types/node': 25.9.5 + hash.js: 1.1.7 + jszip: 3.10.1 + nanoid: 5.1.16 + xml: 1.0.1 + xml-js: 1.6.11 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1857,6 +1943,11 @@ snapshots: has-symbols@1.1.0: {} + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -1885,16 +1976,31 @@ snapshots: dependencies: safer-buffer: 2.1.2 + immediate@3.0.6: {} + inherits@2.0.4: {} ipaddr.js@1.9.1: {} is-stream@3.0.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} js-tokens@9.0.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + local-pkg@0.5.1: dependencies: mlly: 1.8.2 @@ -1928,6 +2034,8 @@ snapshots: mimic-fn@4.0.0: {} + minimalistic-assert@1.0.1: {} + mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -1941,6 +2049,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.16: {} + nats@2.29.3: dependencies: nkeys.js: 1.1.0 @@ -1971,6 +2081,8 @@ snapshots: dependencies: yocto-queue: 1.2.2 + pako@1.0.11: {} + parseurl@1.3.3: {} path-key@3.1.1: {} @@ -2005,6 +2117,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + process-nextick-args@2.0.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2025,6 +2139,16 @@ snapshots: react-is@18.3.1: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + requires-port@1.0.0: {} rollup@4.62.0: @@ -2058,10 +2182,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} + sax@1.6.1: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -2089,6 +2217,8 @@ snapshots: transitivePeerDependencies: - supports-color + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -2137,6 +2267,10 @@ snapshots: std-env@3.10.0: {} + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-final-newline@3.0.0: {} strip-literal@2.1.1: @@ -2172,8 +2306,12 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.24.6: {} + unpipe@1.0.0: {} + util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} vary@1.1.2: {} @@ -2248,6 +2386,12 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + xml-js@1.6.11: + dependencies: + sax: 1.6.1 + + xml@1.0.1: {} + yocto-queue@1.2.2: {} zod@3.25.76: {} diff --git a/gateway/public-api/src/modules/reports/report-downloads.test.ts b/gateway/public-api/src/modules/reports/report-downloads.test.ts new file mode 100644 index 00000000..4a3bd0bc --- /dev/null +++ b/gateway/public-api/src/modules/reports/report-downloads.test.ts @@ -0,0 +1,78 @@ +import { createRequire } from 'node:module'; +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_SURFACE_FLAGS, type ReportContentV1 } from '@x-hunter/shared'; +import { reportCsv, reportDocx } from './report-downloads.js'; +import { ReportsService } from './reports.service.js'; + +const report: ReportContentV1 = { + formatVersion: 'report_v1', reportType: 'finding_report', packageTier: 'free_hunter', scanMode: 'free_hunter', + targetType: 'interactive_web_app', authScope: 'none', testIntensityMode: 'safe_discovery', surfaceFlags: DEFAULT_SURFACE_FLAGS, + ownerSummary: { headline: 'Test report', riskLevel: 'high', whatWasTested: 'Verified public scope', + topRiskOrOutcome: 'Validated issue', businessImpact: 'Test impact', recommendedNextAction: 'Review fix' }, + findings: [{ id: 'f1', rank: 1, title: ' =HYPERLINK("https://example.com")', severity: 'high', confidence: 'high', + affectedAsset: 'https://example.com', category: 'access_control', attackerPath: 'Controlled test', + sanitizedProof: { description: 'Bearer secret-test-value', evidenceRefs: ['e1'], sanitized: true }, + impact: 'Test impact', fixSummary: 'Fix ownership checks' }], + developerFixPack: [{ findingId: 'f1', rootCauseHypothesis: 'Ownership check missing', concreteFixPrompt: 'Check owner', + validationSteps: ['Verify rejection'], regressionTestIdeas: ['Different owner'], acceptanceCriteria: 'Returns 403' }], + coverage: { targetType: 'interactive_web_app', workersRun: ['Browser', 'R', 'Z'], huntersRun: [], + skippedHunters: [{ hunter: 'S', reason: 'Disabled by user' }], coverageGaps: ['Authentication not tested'], + limitations: ['Limited to authorized scope', 'Vietnamese: Tiếng Việt'] }, + hardeningRecommendations: ['Check headers'], + retestAndMonitor: { eligibleFindings: ['f1'], remainingRetestQuota: 1, cooldownDays: 7, manualRetestActions: ['Manual retest f1'] }, + generatedAt: '2026-09-07T00:00:00.000Z', +}; + +describe('report downloads', () => { + it('quotes CSV fields, neutralizes formulas, and keeps hardening separate from validated findings', () => { + const csv = reportCsv(report); + expect(csv).toContain('"\' =HYPERLINK(""https://example.com"")"'); + expect(csv).toContain('"validated_finding","f1"'); + expect(csv).toContain('"hardening"'); + expect(csv).toContain('"coverage_gap"'); + expect(csv).toContain('"skipped"'); + expect(csv).not.toContain('secret-test-value'); + expect(report.findings[0].sanitizedProof.description).toContain('secret-test-value'); + }); + + it('creates a real DOCX with all report sections, Unicode and no raw token', async () => { + // Read the archive with the same ZIP library already used by docx. + const require = createRequire(import.meta.url); + const JSZip = createRequire(require.resolve('docx'))('jszip'); + const archive = await JSZip.loadAsync(await reportDocx(report)); + const xml = await archive.file('word/document.xml').async('string'); + for (const text of ['Developer fix pack', 'Ownership check missing', 'Returns 403', 'Manual retest f1', 'Tiếng Việt', 'Limitations']) { + expect(xml).toContain(text); + } + expect(xml).not.toContain('secret-test-value'); + expect(xml).toContain('[REDACTED_TOKEN]'); + expect(archive.file('[Content_Types].xml')).not.toBeNull(); + }); + + it('exports empty findings as coverage without inventing a vulnerability', () => { + const csv = reportCsv({ ...report, reportType: 'coverage_only', findings: [] }); + expect(csv).not.toContain('validated_finding'); + expect(csv).toContain('Limited to authorized scope'); + }); + + it('uses the org-scoped report lookup and exports immutable JSON snapshot separately from latest state', async () => { + const service = new ReportsService(); + const get = vi.spyOn(service, 'get').mockResolvedValue({ + snapshot: { id: 'r1', state: 'final', content: report, generatedAt: new Date(report.generatedAt) }, + latestOverlay: { findingStatuses: [] }, + } as any); + const result = await service.export('r1', 'org1', 'json', 'snapshot'); + expect(get).toHaveBeenCalledWith('r1', 'org1'); + const parsed = JSON.parse(result!.body.toString()); + expect(parsed.snapshot.content.formatVersion).toBe('report_v1'); + expect(parsed.snapshot.generatedAt).toBe(report.generatedAt); + expect(result!.body.toString()).not.toContain('secret-test-value'); + expect(parsed.latestOverlay).toBeUndefined(); + const latest = await service.export('r1', 'org1', 'json', 'latest'); + expect(JSON.parse(latest!.body.toString()).latestOverlay).toEqual({ findingStatuses: [] }); + get.mockResolvedValue(null); + expect(await service.export('r1', 'other-org', 'docx', 'snapshot')).toBeNull(); + get.mockResolvedValue({ snapshot: { state: 'draft' } } as any); + await expect(service.export('r1', 'org1', 'json', 'snapshot')).rejects.toThrow('still being prepared'); + }); +}); diff --git a/gateway/public-api/src/modules/reports/report-downloads.ts b/gateway/public-api/src/modules/reports/report-downloads.ts new file mode 100644 index 00000000..5dfa8e01 --- /dev/null +++ b/gateway/public-api/src/modules/reports/report-downloads.ts @@ -0,0 +1,77 @@ +import { Document, HeadingLevel, Packer, Paragraph } from 'docx'; +import { sanitizeReportContent, sanitizeText, type ReportContentV1 } from '@x-hunter/shared'; + +export const REPORT_EXPORT_FORMATS = ['docx', 'csv', 'json', 'html', 'pdf'] as const; +export type ReportExportFormat = (typeof REPORT_EXPORT_FORMATS)[number]; + +export function reportCsv(input: ReportContentV1, latestStatus?: string): string { + const report = sanitizeReportContent(input); + const rows: unknown[][] = [['type', 'id', 'title', 'severity', 'confidence', 'asset', 'detail', 'fix']]; + rows.push(['summary', '', report.ownerSummary.headline, '', '', '', report.ownerSummary.topRiskOrOutcome, report.ownerSummary.recommendedNextAction]); + for (const f of report.findings) { + rows.push(['validated_finding', f.id, f.title, f.severity, f.confidence, f.affectedAsset, + `${f.impact}\n${f.sanitizedProof.description}`, f.fixSummary]); + } + for (const text of report.hardeningRecommendations) rows.push(['hardening', '', '', '', '', '', text, '']); + for (const text of report.coverage.coverageGaps) rows.push(['coverage_gap', '', '', '', '', '', text, '']); + for (const text of report.coverage.limitations) rows.push(['limitation', '', '', '', '', '', text, '']); + for (const item of report.coverage.skippedHunters) rows.push(['skipped', '', item.hunter, '', '', '', item.reason, '']); + if (latestStatus) rows.push(['latest_status', '', '', '', '', '', sanitizeText(latestStatus), '']); + return '\uFEFF' + rows.map((row) => row.map(csvCell).join(',')).join('\r\n') + '\r\n'; +} + +function csvCell(value: unknown): string { + let text = String(value ?? ''); + // Spreadsheet apps can execute formulas even inside quoted CSV cells. + if (/^[\s\u0000-\u001f]*[=+@-]/u.test(text) || /^[\t\r\n]/u.test(text)) text = "'" + text; + return '"' + text.replace(/"/g, '""') + '"'; +} + +export async function reportDocx(input: ReportContentV1, latestStatus?: string): Promise { + const report = sanitizeReportContent(input); + const children: Paragraph[] = [new Paragraph({ text: 'OpenHunterAI Report', heading: HeadingLevel.TITLE })]; + const section = (title: string, values: string[]) => { + children.push(new Paragraph({ text: title, heading: HeadingLevel.HEADING_1 })); + for (const value of values) { + for (const line of value.split(/\r?\n/)) children.push(new Paragraph({ text: line })); + } + }; + section('Scope', [ + `Generated: ${report.generatedAt}`, `Package: ${report.packageTier}`, `Mode: ${report.scanMode}`, + `Target type: ${report.targetType}`, `Intensity: ${report.testIntensityMode}`, `Authentication: ${report.authScope}`, + report.ownerSummary.whatWasTested, + ]); + section('Owner summary', Object.values(report.ownerSummary)); + section('Validated findings', report.findings.length ? [] : [ + 'No validated Critical/High findings in the tested scope. Review coverage and limitations below.', + ]); + for (const f of report.findings) { + children.push(new Paragraph({ text: `${f.rank}. ${f.title}`, heading: HeadingLevel.HEADING_2 })); + for (const text of [ + `Severity: ${f.severity}; confidence: ${f.confidence}`, `Asset: ${f.affectedAsset}`, + `Category: ${f.category}`, `Attacker path: ${f.attackerPath}`, `Impact: ${f.impact}`, + `Sanitized proof: ${f.sanitizedProof.description}`, `Evidence references: ${f.sanitizedProof.evidenceRefs.join(', ')}`, + `Fix: ${f.fixSummary}`, `Retest scenario: ${JSON.stringify(f.retestScenario ?? null)}`, + ]) children.push(new Paragraph({ text })); + } + for (const fix of report.developerFixPack) section(`Developer fix pack: ${fix.findingId}`, [ + fix.rootCauseHypothesis, fix.concreteFixPrompt, ...fix.validationSteps, ...fix.regressionTestIdeas, fix.acceptanceCriteria, + ]); + section('Coverage', [ + `Pipeline: ${report.coverage.workersRun.join(', ')}`, `Hunters: ${report.coverage.huntersRun.join(', ')}`, + ...report.coverage.skippedHunters.map((item) => `Skipped ${item.hunter}: ${item.reason}`), + ...report.coverage.coverageGaps, + ]); + section('Hardening', report.hardeningRecommendations); + section('Limitations', report.coverage.limitations); + section('Manual retest and monitor', [ + `Retests remaining: ${report.retestAndMonitor.remainingRetestQuota}`, + `Cooldown days: ${report.retestAndMonitor.cooldownDays}`, ...report.retestAndMonitor.manualRetestActions, + ]); + if (latestStatus) section('Latest status (separate from scan snapshot)', [sanitizeText(latestStatus)]); + return Packer.toBuffer(new Document({ + creator: 'OpenHunterAI', title: 'OpenHunterAI Report', + styles: { default: { document: { run: { font: 'Calibri', size: 22 } } } }, + sections: [{ children }], + })); +} diff --git a/gateway/public-api/src/modules/reports/reports.controller.ts b/gateway/public-api/src/modules/reports/reports.controller.ts index 67aae8a7..17eb3200 100644 --- a/gateway/public-api/src/modules/reports/reports.controller.ts +++ b/gateway/public-api/src/modules/reports/reports.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import { currentUser } from '../../middlewares/auth.middleware.js'; import { ReportsService } from './reports.service.js'; +import { REPORT_EXPORT_FORMATS, type ReportExportFormat } from './report-downloads.js'; const service = new ReportsService(); @@ -18,14 +19,23 @@ export async function getReport(req: Request, res: Response) { } export async function exportReport(req: Request, res: Response) { - const format = req.query.format === 'pdf' ? 'pdf' : 'html'; - const view = req.query.view === 'snapshot' ? 'snapshot' : 'latest'; - const exported = await service.export(req.params.id!, currentUser(req).orgId, format, view); + const format = req.query.format ?? 'html'; + if (typeof format !== 'string' || !REPORT_EXPORT_FORMATS.includes(format as ReportExportFormat)) { + res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'Unsupported report format' } }); + return; + } + const view = req.query.view ?? 'latest'; + if (view !== 'snapshot' && view !== 'latest') { + res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'Unsupported report view' } }); + return; + } + const exported = await service.export(req.params.id!, currentUser(req).orgId, format as ReportExportFormat, view); if (!exported) { res.status(404).json({ error: { code: 'NOT_FOUND' } }); return; } res.setHeader('Content-Type', exported.contentType); + res.setHeader('Cache-Control', 'private, no-store'); res.setHeader('Content-Disposition', `attachment; filename="${exported.filename}"`); res.send(exported.body); } diff --git a/gateway/public-api/src/modules/reports/reports.service.ts b/gateway/public-api/src/modules/reports/reports.service.ts index e64b6a73..a9a7bb1e 100644 --- a/gateway/public-api/src/modules/reports/reports.service.ts +++ b/gateway/public-api/src/modules/reports/reports.service.ts @@ -6,7 +6,9 @@ import { sanitizeReportContent, sanitizeText, type ReportContentV1, + GuardrailError, } from '@x-hunter/shared'; +import { REPORT_EXPORT_FORMATS, reportCsv, reportDocx, type ReportExportFormat } from './report-downloads.js'; export class ReportsService { private readonly prisma = getPrisma(); @@ -54,10 +56,30 @@ export class ReportsService { return this.toEnvelope(report); } - async export(id: string, orgId: string, format: 'html' | 'pdf', view: 'snapshot' | 'latest') { + async export(id: string, orgId: string, format: ReportExportFormat, view: 'snapshot' | 'latest') { const envelope = await this.get(id, orgId); if (!envelope) return null; + if (!['final', 'superseded'].includes(envelope.snapshot.state)) { + throw new GuardrailError('INVALID_INPUT', 'Report is still being prepared'); + } const content = sanitizeReportContent(envelope.snapshot.content as ReportContentV1); + if (format === 'json') return { + contentType: 'application/json; charset=utf-8', filename: `openhunter-report-${id}.json`, + // Serialize dates before sanitizing the complete public download envelope. + body: Buffer.from(JSON.stringify(sanitizeReportContent(JSON.parse(JSON.stringify({ + snapshot: envelope.snapshot, + ...(view === 'latest' ? { latestOverlay: envelope.latestOverlay } : {}), + }))), null, 2), 'utf8'), + }; + if (format === 'csv') return { + contentType: 'text/csv; charset=utf-8', filename: `openhunter-report-${id}.csv`, + body: Buffer.from(reportCsv(content, view === 'latest' ? renderOverlayMarkdown(envelope.latestOverlay) : undefined), 'utf8'), + }; + if (format === 'docx') return { + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + filename: `openhunter-report-${id}.docx`, + body: await reportDocx(content, view === 'latest' ? renderOverlayMarkdown(envelope.latestOverlay) : undefined), + }; const html = renderReportHtml(content, view === 'latest' ? envelope.latestOverlay : undefined); if (format === 'html') { return { @@ -156,7 +178,7 @@ ${renderOverlayMarkdown(envelope.latestOverlay)}` }, latestOverlay, exports: { - formats: ['html', 'pdf'], + formats: REPORT_EXPORT_FORMATS, generatedOnDemand: true, }, }; From 1a49c52c1603586501153b8fd392aa38a6345ffa Mon Sep 17 00:00:00 2001 From: HungBil Date: Mon, 7 Sep 2026 22:12:08 +0700 Subject: [PATCH 2/3] feat(web): replace sales homepage with product guide --- frontend/.env.public-site | 1 + frontend/package.json | 1 + frontend/src/main.tsx | 20 +- frontend/src/pages/Home.tsx | 444 ++++++++++++++++++++++++------------ frontend/vite.config.ts | 5 +- 5 files changed, 324 insertions(+), 147 deletions(-) create mode 100644 frontend/.env.public-site diff --git a/frontend/.env.public-site b/frontend/.env.public-site new file mode 100644 index 00000000..3e107f01 --- /dev/null +++ b/frontend/.env.public-site @@ -0,0 +1 @@ +VITE_PUBLIC_SITE=true diff --git a/frontend/package.json b/frontend/package.json index 34b06c63..e8b835d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.json && vite build", + "build:public-site": "tsc -p tsconfig.json && vite build --mode public-site", "dev": "vite --host 0.0.0.0 --port 3001", "preview": "vite preview --host 0.0.0.0 --port 3001", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 016801c4..95a9b71d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -20,8 +20,18 @@ function RequireAuth({ children }: { children: React.ReactNode }) { return <>{children}; } -ReactDOM.createRoot(document.getElementById('root')!).render( - +const isPublicSite = import.meta.env.VITE_PUBLIC_SITE === 'true'; + +function PublicSite() { + return ( + + + + ); +} + +function Application() { + return ( @@ -44,5 +54,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render( + ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + {isPublicSite ? : } , ); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 5e84ebe8..af96ac79 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,189 +1,347 @@ import { + Activity, + ArrowDown, ArrowRight, + CircleAlert, + CircleCheck, + EyeOff, FileCheck2, - Crosshair, + FileJson2, + FileSpreadsheet, + LockKeyhole, + Moon, Radar, ScanLine, ShieldCheck, - Lock, - EyeOff, - GitPullRequestArrow, + Sun, } from 'lucide-react'; -import { Badge } from '../components/ui/Badge'; +import { Button } from '../components/ui/Button'; import { ButtonLink } from '../components/ui/ButtonLink'; -import { Card } from '../components/ui/Card'; -import { cn } from '../lib/cn'; - -const STEPS = [ - { icon: ShieldCheck, title: 'Verify & authorize', body: 'Prove domain ownership and create an immutable scan authorization. No verified scope, no scan.' }, - { icon: ScanLine, title: 'Run the pipeline', body: 'A deterministic Scan Plan drives browser inspection plus Z/N/O/S stages within your scope.' }, - { icon: Crosshair, title: 'Triage findings', body: 'Sanitized evidence becomes ranked findings on a board — raw secrets never leave the boundary.' }, - { icon: FileCheck2, title: 'Report & retest', body: 'Owner summary, developer fix pack, and coverage. Retest each finding manually when you are ready.' }, +import { useTheme } from '../lib/theme'; + +const IS_PUBLIC_SITE = import.meta.env.VITE_PUBLIC_SITE === 'true'; +const LOGO_SRC = `${import.meta.env.BASE_URL}logo.png`; + +const CONTROL_POINTS = [ + { + icon: ShieldCheck, + title: 'Verified scope first', + body: 'A scan starts only after domain verification and an explicit authorization. Redirects, private addresses, expired verification, and out-of-scope hosts are blocked.', + }, + { + icon: ScanLine, + title: 'A plan you can inspect', + body: 'Target type, surface flags, authentication scope, intensity, quotas, and policy gates form a deterministic plan before any unit is dispatched.', + }, + { + icon: Activity, + title: 'Controlled execution', + body: 'Browser, R, Z, N, O, and S run only inside the effective plan. A missing tool becomes a coverage gap, never a made-up success.', + }, + { + icon: LockKeyhole, + title: 'Human control at risk points', + body: 'Sensitive actions require approval. Manual login stays in a temporary browser session; credentials and storage are not sent to reports or model prompts.', + }, +]; + +const PIPELINE = [ + { + code: '01', + title: 'Scope and authorization', + detail: 'Confirm ownership, allowed hosts and paths, exclusions, test window, rate limits, environment, and risk acceptance for the selected intensity.', + output: 'Immutable authorization and allowed scope', + }, + { + code: '02', + title: 'Deterministic scan plan', + detail: 'Select one target type, surface flags, auth scope, and intensity. Policy and package budgets decide which units may run and which must be skipped.', + output: 'Effective plan, approval gates, coverage expectations', + }, + { + code: '03', + title: 'Browser and signal stages', + detail: 'Browser observes the authorized surface. R, Z, N, O, and S contribute scoped signals, hypotheses, or controlled validation plans.', + output: 'Sanitized activity, signals, candidates, coverage gaps', + }, + { + code: '04', + title: 'Finding gate', + detail: 'A passive signal is not automatically a vulnerability. Promotion requires scoped, sanitized, evidence-backed validation and a clear impact statement.', + output: 'Validated finding, candidate, hardening item, or limitation', + }, + { + code: '05', + title: 'Report and manual retest', + detail: 'The final report records what was tested, validated findings, developer guidance, coverage, limitations, and narrow manual retest actions.', + output: 'Immutable report_v1 snapshot and current-status overlay', + }, +]; + +const RESULT_TYPES = [ + { + icon: CircleCheck, + title: 'Validated finding', + body: 'A scoped issue with sanitized evidence and a demonstrated impact. It can be ranked, reported, and manually retested.', + accent: 'text-signal', + }, + { + icon: CircleAlert, + title: 'Candidate', + body: 'A hypothesis or signal that still needs controlled validation. It is visible as unvalidated and is not treated as a confirmed defect.', + accent: 'text-medium', + }, + { + icon: EyeOff, + title: 'Hardening or coverage', + body: 'A configuration improvement, skipped stage, or limitation. It explains what remains to be checked without fabricating a vulnerability.', + accent: 'text-info', + }, +]; + +const REPORT_OUTPUTS = [ + { icon: FileCheck2, title: 'Owner summary', body: 'Outcome, tested scope, business impact, and next action.' }, + { icon: FileJson2, title: 'report_v1 snapshot', body: 'Structured, immutable scan record with a separate latest-status overlay.' }, + { icon: FileSpreadsheet, title: 'On-demand downloads', body: 'Sanitized DOCX, CSV, JSON, HTML, and PDF output from final reports.' }, ]; -const TIERS = [ +const EXECUTION_POLICIES = [ { name: 'Free Hunter', - price: '$0', - blurb: 'One valuable finding, then stop. Coverage report either way.', - features: ['1 returned finding', '1 monitored finding', '1 retest', '7-day cooldown'], - cta: 'Start free', - to: '/register', - featured: false, + body: 'Uses the same planning model, then stops after its first valuable evidence-gated finding. If it finds none within budget, it returns coverage, hardening, and limitations.', + rules: ['One returned finding', 'One monitored finding', 'One manual retest', 'Seven-day cooldown'], }, { name: 'AI Black-hat Mindset Check', - price: 'PAYG', - blurb: 'Full attacker-mindset reasoning with controlled validation in scope.', - features: ['Up to 50 findings', 'Controlled validation', 'Approval-gated actions', 'Full finding board'], - cta: 'Create workspace', - to: '/register', - featured: true, + body: 'Runs attacker-mindset reasoning and controlled validation in the verified scope. Authentication remains an auth scope, not a separate product.', + rules: ['Controlled validation', 'Approval-gated actions', 'Full finding workspace', 'Coverage and limitations'], }, { - name: 'Enterprise / PAYG', - price: 'Custom', - blurb: 'Monitor workspace, expanded quota, and pay-as-you-go scanning.', - features: ['Monitor workspace', 'Expanded quota', 'Priority pipeline', 'Custom scope'], - cta: 'Talk to us', - to: '/register', - featured: false, + name: 'Monitor Workspace', + body: 'Keeps report history, monitor state, quotas, and the manual retest queue. It does not trigger scans after deployments or from CI/CD.', + rules: ['Historical reports', 'Manual retest queue', 'Monitor state', 'No automatic retest'], }, ]; -const GUARDRAILS = [ - { icon: Lock, text: 'Authorized targets only — verified domains inside an immutable scope.' }, - { icon: EyeOff, text: 'No raw secrets, cookies, or tokens persisted in logs, prompts, or reports.' }, - { icon: GitPullRequestArrow, text: 'Sensitive actions pass an explicit approval gate before they run.' }, -]; +function PublicHeader() { + const { theme, toggle } = useTheme(); + return ( +
+
+ + + + + + OpenHunterAI + + + + +
+
+ ); +} + +function PrimaryActions() { + if (IS_PUBLIC_SITE) { + return ( + + ); + } + return ( +
+ + Create workspace + + Sign in +
+ ); +} export function HomePage() { return ( -
- {/* Hero */} -
-
-
- - Authorized security testing workspace - -

- Hunt like an attacker. -
- Stay inside the lines. -

-

- Verify a domain, create an immutable scan authorization, run the hunter pipeline, and turn - sanitized evidence into findings and reports — with governed execution at every step. -

-
- - Create workspace - - - Sign in - +
+ {IS_PUBLIC_SITE && } + +
+
+
+
+

Authorized external security testing

+

OpenHunterAI

+

+ A governed workspace for testing verified public web and application targets with an attacker mindset, evidence gates, and an explicit account of coverage. +

+

+ It is not an unrestricted scanner. The product makes the scope, plan, approvals, evidence status, and testing limits visible before it asks anyone to trust an outcome. +

+ +
+ +
+ OpenHunterAI +
+
+ {[ + ['01', 'Verified scope'], + ['02', 'Deterministic plan'], + ['03', 'Browser · R · Z · N · O · S'], + ['04', 'Evidence gate'], + ['05', 'Report_v1 · manual retest'], + ].map(([code, label], index) => ( +
+ {code} + {label} + {index < 4 && } +
+ ))} +
+
Scan lifecycle
+
+
+
+ +
+
+

Operating model

+
+

What the workspace controls

+

The product separates what a user authorizes, what the runtime may execute, and what a report is allowed to claim.

+
+
+ {CONTROL_POINTS.map(({ icon: Icon, title, body }) => ( +
+ +

{title}

+

{body}

+
+ ))}
- {/* How it works */} -
-
-

- The pipeline -

-
- {STEPS.map(({ icon: Icon, title, body }, i) => ( - - 0{i + 1} - -

{title}

-

{body}

-
+
+
+
+
+

Workflow

+

From permission to a report someone can challenge

+

Every scan keeps the decision trail: why an action was permitted, what ran, what was skipped, and why a result is or is not a finding.

+
+
    + {PIPELINE.map((step) => ( +
  1. + {step.code} +
    +

    {step.title}

    +

    {step.detail}

    +
    +

    {step.output}

    +
  2. + ))} +
+
+
+
+ +
+
+
+
+

Evidence language

+

A signal is not a finding

+
+

Reports distinguish confirmed issues from hypotheses and hardening work. Missing headers or passive observations do not become a medium-severity vulnerability unless controlled evidence supports the impact.

+
+
+ {RESULT_TYPES.map(({ icon: Icon, title, body, accent }) => ( +
+ +

{title}

+

{body}

+
))}
+
+
+

What the final report contains

+
+ {REPORT_OUTPUTS.map(({ icon: Icon, title, body }) => ( +
+ +

{title}

+

{body}

+
+ ))} +
+
+ +
- {/* Pricing */} -
-
-

- Pick your intensity -

-

- Same Target Type, Surface Flags, and Test Intensity model across tiers — the limits change, - not the engine. -

-
- {TIERS.map((tier) => ( - - {tier.featured && ( - - Recommended - - )} -

{tier.name}

-

{tier.price}

-

{tier.blurb}

-
    - {tier.features.map((f) => ( -
  • - {f} -
  • - ))} +
    +
    +

    Execution policies

    +

    Packages change limits, not the truth standard

    +
    + {EXECUTION_POLICIES.map(({ name, body, rules }) => ( +
    +

    {name}

    +

    {body}

    +
      + {rules.map((rule) =>
    • +{rule}
    • )}
    - - {tier.cta} - - +
    ))}
    - {/* Guardrails */} -
    -
    -

    - Governed by design -

    -
    - {GUARDRAILS.map(({ icon: Icon, text }) => ( -
    - - - -

    {text}

    -
    - ))} +
    +
    +
    +
    +

    Boundaries

    +

    What OpenHunterAI will not do

    +
    +
    + {[ + 'No scan of an unverified domain, a private/local/metadata address, or a redirect outside the authorized scope.', + 'No destructive wipe, malware, persistence, stealth/evasion, credential stuffing, brute force, or raw secret exfiltration.', + 'No CI/CD-based or deployment-triggered retest. Retests are narrow, manual, and tied to a finding.', + 'No fabricated success when a unit or integration is unavailable; the report records a skipped stage or coverage gap.', + ].map((item, index) => ( +
    + 0{index + 1} +

    {item}

    +
    + ))} +
    -
    - OpenHunterAI +
    + OpenHunterAI Authorized external web/app security testing. Verified scope only.
    diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9e91cbae..575db3d9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react'; const apiBase = process.env.VITE_DEV_API_PROXY_TARGET ?? 'http://localhost:4000'; -export default defineConfig({ +export default defineConfig(({ mode }) => ({ + base: mode === 'public-site' ? '/openhunterai-site/' : '/', plugins: [react()], server: { proxy: { @@ -14,4 +15,4 @@ export default defineConfig({ }, }, }, -}); +})); From cb72425b33ab2a8444a13b8ce3f5375e7363b378 Mon Sep 17 00:00:00 2001 From: HungBil Date: Mon, 7 Sep 2026 22:27:08 +0700 Subject: [PATCH 3/3] fix(web): isolate public Pages artifact --- frontend/index.public-site.html | 17 +++++++++++++++++ frontend/package.json | 2 +- frontend/src/public-site.tsx | 13 +++++++++++++ frontend/vite.config.ts | 31 ++++++++++++++++++++----------- 4 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 frontend/index.public-site.html create mode 100644 frontend/src/public-site.tsx diff --git a/frontend/index.public-site.html b/frontend/index.public-site.html new file mode 100644 index 00000000..b14b3876 --- /dev/null +++ b/frontend/index.public-site.html @@ -0,0 +1,17 @@ + + + + + + + + OpenHunterAI — Authorized security testing workspace + + +
    + + + diff --git a/frontend/package.json b/frontend/package.json index e8b835d8..de00ee13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.json && vite build", - "build:public-site": "tsc -p tsconfig.json && vite build --mode public-site", + "build:public-site": "tsc -p tsconfig.json && vite build --mode public-site && node -e \"require('fs').renameSync('dist/index.public-site.html', 'dist/index.html')\"", "dev": "vite --host 0.0.0.0 --port 3001", "preview": "vite preview --host 0.0.0.0 --port 3001", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/frontend/src/public-site.tsx b/frontend/src/public-site.tsx new file mode 100644 index 00000000..42b697ce --- /dev/null +++ b/frontend/src/public-site.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { ThemeProvider } from './lib/theme'; +import { HomePage } from './pages/Home'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 575db3d9..cc9a391f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,18 +1,27 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; const apiBase = process.env.VITE_DEV_API_PROXY_TARGET ?? 'http://localhost:4000'; +const rootDir = dirname(fileURLToPath(import.meta.url)); -export default defineConfig(({ mode }) => ({ - base: mode === 'public-site' ? '/openhunterai-site/' : '/', - plugins: [react()], - server: { - proxy: { - '/api': { - target: apiBase, - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ''), +export default defineConfig(({ mode }) => { + const publicSite = mode === 'public-site'; + return { + base: publicSite ? '/openhunterai-site/' : '/', + plugins: [react()], + build: publicSite + ? { rollupOptions: { input: { index: resolve(rootDir, 'index.public-site.html') } } } + : undefined, + server: { + proxy: { + '/api': { + target: apiBase, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, }, }, - }, -})); + }; +});