diff --git a/.github/workflows/hi-rockits-outside-in-audit-v0-1.yml b/.github/workflows/hi-rockits-outside-in-audit-v0-1.yml new file mode 100644 index 00000000..7a30f00a --- /dev/null +++ b/.github/workflows/hi-rockits-outside-in-audit-v0-1.yml @@ -0,0 +1,125 @@ +name: Hi Rockits Outside-In Audit v0.1 + +on: + workflow_dispatch: + push: + branches: + - agent/hi-rockits-outside-in-audit-v0-1 + paths: + - .github/workflows/hi-rockits-outside-in-audit-v0-1.yml + - .github/workflows/hi-rockits-rendered-audit-v0-2.yml + - audits/hi-rockits/public-audit-v0.1/contract.json + - audits/browser/hi-rockits/public-rendered-matrix-v0.2.json + - scripts/hi_rockits_public_audit_probe.py + - scripts/hi_rockits_rendered_audit_probe.mjs + - tests/test_hi_rockits_public_audit_probe.py + - docs/audits/HI_ROCKITS_OUTSIDE_IN_AUDIT_V0_1.md + - docs/audits/HI_ROCKITS_COLLABORATION_BRIEF.md + pull_request: + branches: + - main + paths: + - .github/workflows/hi-rockits-outside-in-audit-v0-1.yml + - .github/workflows/hi-rockits-rendered-audit-v0-2.yml + - audits/hi-rockits/public-audit-v0.1/contract.json + - audits/browser/hi-rockits/public-rendered-matrix-v0.2.json + - scripts/hi_rockits_public_audit_probe.py + - scripts/hi_rockits_rendered_audit_probe.mjs + - tests/test_hi_rockits_public_audit_probe.py + - docs/audits/HI_ROCKITS_OUTSIDE_IN_AUDIT_V0_1.md + - docs/audits/HI_ROCKITS_COLLABORATION_BRIEF.md + +permissions: + contents: read + +concurrency: + group: hi-rockits-outside-in-v0-1-${{ github.ref }} + cancel-in-progress: true + +jobs: + contract: + name: Validate Hi Rockits audit contract + runs-on: ubuntu-latest + steps: + - name: Checkout exact workflow revision + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Validate syntax and fail-closed contract + run: | + set -euo pipefail + python3 -m json.tool audits/hi-rockits/public-audit-v0.1/contract.json >/dev/null + python3 -m py_compile scripts/hi_rockits_public_audit_probe.py + python3 scripts/hi_rockits_public_audit_probe.py --validate-only + - name: Run regression tests + run: | + set -euo pipefail + python3 -m unittest tests/test_hi_rockits_public_audit_probe.py -v + + observe: + name: Recheck allowlisted Hi Rockits public content + needs: contract + runs-on: ubuntu-latest + timeout-minutes: 12 + env: + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} + steps: + - name: Checkout exact workflow revision + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Run passive sequential audit + run: | + set -euo pipefail + rm -rf reports/hi-rockits/public-audit-v0.1 + python3 scripts/hi_rockits_public_audit_probe.py \ + --contract audits/hi-rockits/public-audit-v0.1/contract.json \ + --output-dir reports/hi-rockits/public-audit-v0.1 + cat reports/hi-rockits/public-audit-v0.1/summary.md >> "$GITHUB_STEP_SUMMARY" + - name: Validate evidence and authority boundary + run: | + set -euo pipefail + jq -e ' + .schema_version == "liminalqa-hi-rockits-public-audit-result-v1" and + .boundaries.public_pages_only == true and + .boundaries.authentication == false and + .boundaries.form_submission == false and + .boundaries.resume_upload == false and + .boundaries.email_or_external_contact == false and + .boundaries.external_submission_authorized == false and + .boundaries.merge_authorized == false and + .authority.mode == "evidence_only" and + .authority.grants.external_submission == false and + .authority.grants.merge == false and + .aggregate.expected_target_count == 4 and + (.aggregate.findings | length) == 6 + ' reports/hi-rockits/public-audit-v0.1/result.json >/dev/null + - name: Record exact attempt and verify checksums + run: | + set -euo pipefail + OUT=reports/hi-rockits/public-audit-v0.1 + jq -n \ + --arg run_id "$GITHUB_RUN_ID" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg source_head_sha "$GITHUB_HEAD_SHA" \ + --arg workflow_sha "$GITHUB_SHA" \ + --arg base_sha "$GITHUB_BASE_SHA" \ + --arg event_name "$GITHUB_EVENT_NAME" \ + '{run_id:$run_id,run_attempt:$run_attempt,source_head_sha:$source_head_sha,workflow_sha:$workflow_sha,base_sha:$base_sha,event_name:$event_name,tested_revision_kind:(if $event_name == "pull_request" then "pull_request_merge_ref_with_separate_source_head" else "branch_head" end)}' \ + > "$OUT/exact-attempt.json" + ( + cd "$OUT" + find . -maxdepth 1 -type f ! -name 'ARTIFACT_SHA256SUMS.txt' -printf '%P\0' \ + | sort -z \ + | xargs -0 sha256sum > ARTIFACT_SHA256SUMS.txt + sha256sum -c ARTIFACT_SHA256SUMS.txt + ) + - name: Upload exact-attempt evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: hi-rockits-outside-in-v0-1-${{ github.run_id }}-${{ github.run_attempt }} + path: reports/hi-rockits/public-audit-v0.1/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/hi-rockits-rendered-audit-v0-2.yml b/.github/workflows/hi-rockits-rendered-audit-v0-2.yml new file mode 100644 index 00000000..41dcfa4d --- /dev/null +++ b/.github/workflows/hi-rockits-rendered-audit-v0-2.yml @@ -0,0 +1,121 @@ +name: Hi Rockits Rendered Audit v0.2 + +on: + workflow_dispatch: + push: + branches: + - agent/hi-rockits-outside-in-audit-v0-1 + paths: + - .github/workflows/hi-rockits-outside-in-audit-v0-1.yml + - .github/workflows/hi-rockits-rendered-audit-v0-2.yml + - audits/hi-rockits/public-audit-v0.1/contract.json + - audits/browser/hi-rockits/public-rendered-matrix-v0.2.json + - scripts/hi_rockits_public_audit_probe.py + - scripts/hi_rockits_rendered_audit_probe.mjs + - tests/test_hi_rockits_public_audit_probe.py + - docs/audits/HI_ROCKITS_OUTSIDE_IN_AUDIT_V0_1.md + - docs/audits/HI_ROCKITS_COLLABORATION_BRIEF.md + pull_request: + branches: + - main + paths: + - .github/workflows/hi-rockits-outside-in-audit-v0-1.yml + - .github/workflows/hi-rockits-rendered-audit-v0-2.yml + - audits/hi-rockits/public-audit-v0.1/contract.json + - audits/browser/hi-rockits/public-rendered-matrix-v0.2.json + - scripts/hi_rockits_public_audit_probe.py + - scripts/hi_rockits_rendered_audit_probe.mjs + - tests/test_hi_rockits_public_audit_probe.py + - docs/audits/HI_ROCKITS_OUTSIDE_IN_AUDIT_V0_1.md + - docs/audits/HI_ROCKITS_COLLABORATION_BRIEF.md + +permissions: + contents: read + +concurrency: + group: hi-rockits-rendered-v0-2-${{ github.ref }} + cancel-in-progress: true + +jobs: + rendered: + name: Confirm Hi Rockits signals in desktop and mobile browsers + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} + steps: + - name: Checkout exact workflow revision + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Validate bounded configuration + run: | + set -euo pipefail + python3 -m json.tool audits/browser/hi-rockits/public-rendered-matrix-v0.2.json >/dev/null + python3 -m json.tool audits/hi-rockits/public-audit-v0.1/contract.json >/dev/null + node --check scripts/hi_rockits_rendered_audit_probe.mjs + - name: Install pinned browser driver + run: | + set -euo pipefail + npm install --no-save --ignore-scripts puppeteer-core@24.15.0 + - name: Validate runtime and locate Chrome + run: | + set -euo pipefail + node --version + npm --version + CHROME_PATH="$(command -v google-chrome || command -v chromium || command -v chromium-browser)" + test -n "$CHROME_PATH" + echo "CHROME_PATH=$CHROME_PATH" >> "$GITHUB_ENV" + "$CHROME_PATH" --version + - name: Run bounded rendered matrix + run: | + set -euo pipefail + rm -rf reports/hi-rockits/rendered-audit-v0.2 + node scripts/hi_rockits_rendered_audit_probe.mjs \ + --config audits/browser/hi-rockits/public-rendered-matrix-v0.2.json \ + --contract audits/hi-rockits/public-audit-v0.1/contract.json \ + --chrome "$CHROME_PATH" \ + --output-dir reports/hi-rockits/rendered-audit-v0.2 + cat reports/hi-rockits/rendered-audit-v0.2/hi-rockits-rendered-summary.md >> "$GITHUB_STEP_SUMMARY" + - name: Validate evidence and authority boundary + run: | + set -euo pipefail + jq -e ' + .schema_version == "liminalqa-hi-rockits-public-rendered-result-v1" and + .aggregate.expected_observation_count == 8 and + .aggregate.observed_route_profile_count == 8 and + (.aggregate.findings | length) == 6 and + .authority.mode == "evidence_only" and + .authority.grants.external_submission == false and + .authority.grants.deployment == false and + .authority.grants.merge == false + ' reports/hi-rockits/rendered-audit-v0.2/hi-rockits-rendered-result.json >/dev/null + - name: Record exact attempt and verify checksums + run: | + set -euo pipefail + OUT=reports/hi-rockits/rendered-audit-v0.2 + jq -n \ + --arg run_id "$GITHUB_RUN_ID" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg source_head_sha "$GITHUB_HEAD_SHA" \ + --arg workflow_sha "$GITHUB_SHA" \ + --arg base_sha "$GITHUB_BASE_SHA" \ + --arg event_name "$GITHUB_EVENT_NAME" \ + '{run_id:$run_id,run_attempt:$run_attempt,source_head_sha:$source_head_sha,workflow_sha:$workflow_sha,base_sha:$base_sha,event_name:$event_name,tested_revision_kind:(if $event_name == "pull_request" then "pull_request_merge_ref_with_separate_source_head" else "branch_head" end)}' \ + > "$OUT/exact-attempt.json" + ( + cd "$OUT" + find . -type f ! -name 'ARTIFACT_SHA256SUMS.txt' -printf '%P\0' \ + | sort -z \ + | xargs -0 sha256sum > ARTIFACT_SHA256SUMS.txt + sha256sum -c ARTIFACT_SHA256SUMS.txt + ) + - name: Upload exact rendered evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: hi-rockits-rendered-v0-2-${{ github.run_id }}-${{ github.run_attempt }} + path: reports/hi-rockits/rendered-audit-v0.2/ + if-no-files-found: error + retention-days: 30 diff --git a/audits/browser/hi-rockits/public-rendered-matrix-v0.2.json b/audits/browser/hi-rockits/public-rendered-matrix-v0.2.json new file mode 100644 index 00000000..67f7efbe --- /dev/null +++ b/audits/browser/hi-rockits/public-rendered-matrix-v0.2.json @@ -0,0 +1,40 @@ +{ + "schema_version": "liminalqa-hi-rockits-public-rendered-v1", + "audit_id": "HI-ROCKITS-RENDERED-2026-07-25-V0.2", + "contract_path": "audits/hi-rockits/public-audit-v0.1/contract.json", + "profiles": [ + { + "id": "desktop", + "viewport": {"width": 1440, "height": 1000}, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/136 Safari/537.36" + }, + { + "id": "mobile", + "viewport": {"width": 412, "height": 915, "isMobile": true, "hasTouch": true}, + "user_agent": "Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 Chrome/136 Mobile Safari/537.36" + } + ], + "runtime": { + "navigation_timeout_ms": 60000, + "settle_ms": 5000, + "tab_steps": 16, + "max_console_items": 40, + "max_network_items": 80 + }, + "boundaries": { + "public_pages_only": true, + "passive_rendering_only": true, + "keyboard_tab_only": true, + "authentication": false, + "form_submission": false, + "button_clicks": false, + "resume_upload": false, + "external_contact": false, + "direct_api_testing": false, + "active_security_testing": false, + "load_testing": false, + "external_submission_authorized": false, + "deployment_authorized": false, + "merge_authorized": false + } +} diff --git a/audits/hi-rockits/public-audit-v0.1/contract.json b/audits/hi-rockits/public-audit-v0.1/contract.json new file mode 100644 index 00000000..3345ff6a --- /dev/null +++ b/audits/hi-rockits/public-audit-v0.1/contract.json @@ -0,0 +1,240 @@ +{ + "schema_version": "liminalqa-hi-rockits-public-audit-v1", + "audit_id": "HI-ROCKITS-OUTSIDE-IN-2026-07-25-V0.1", + "observation_date": "2026-07-25", + "name": "Hi, Rockits! public quality, system, and business audit", + "target": { + "organization": "Hi, Rockits!", + "canonical_origins": [ + "https://rockits.ru", + "https://hirockits.com" + ], + "engagement_kind": "independent_outside_in", + "authorization_basis": "public_pages_passive_observation_only" + }, + "coordinate_model": { + "O": "official public URL + unauthenticated state + observation time", + "N": "passive external observer", + "X": "domain -> route -> content block -> claim", + "Y": "current -> duplicated -> contradictory -> ambiguous -> unclear", + "Z": "raw public response first; rendered desktop/mobile confirmation second", + "T": "navigation -> settled response -> normalization -> human judgment" + }, + "boundaries": { + "public_pages_only": true, + "natural_get_navigation_only": true, + "authentication": false, + "form_submission": false, + "button_clicks": false, + "resume_upload": false, + "email_or_external_contact": false, + "direct_api_testing": false, + "active_security_testing": false, + "enumeration": false, + "fuzzing": false, + "load_testing": false, + "vulnerability_claim": false, + "external_submission_authorized": false, + "deployment_authorized": false, + "merge_authorized": false + }, + "runtime": { + "timeout_seconds": 35, + "max_response_bytes": 4000000, + "max_parallel": 1, + "user_agent": "Mozilla/5.0 (compatible; LiminalQA-Passive-Public-Audit/0.1; +https://github.com/safal207/LiminalQAengineer)" + }, + "allowed_paths": { + "https://rockits.ru": [ + "/", + "/candidate", + "/it-salary-analytics" + ], + "https://hirockits.com": [ + "/" + ] + }, + "targets": [ + { + "slug": "ru-home", + "url": "https://rockits.ru/", + "role": "current Russian buyer acquisition surface", + "assertions": [ + { + "id": "client_count_conflict", + "type": "all_of", + "markers": [ + "1742 компании, которые нам доверяют", + "Уже 1740 компаний являются нашими клиентами" + ] + }, + { + "id": "offer_acceptance_conflict", + "type": "all_of", + "markers": [ + "73% принимаемость наших офферов", + "72% офферов / предложений о работе принимают кандидаты" + ] + }, + { + "id": "decimal_agreement_defect", + "type": "all_of", + "markers": [ + "4,76 процент замен кандидатов" + ] + } + ] + }, + { + "slug": "candidate", + "url": "https://rockits.ru/candidate", + "role": "candidate acquisition and resume-submission surface", + "assertions": [ + { + "id": "duplicated_candidate_testimonial", + "type": "occurrence", + "marker": "Хотела написать, что я за 3 дня успешно оформилась в компании", + "min_occurrences": 2 + }, + { + "id": "candidate_copy_defects", + "type": "all_of", + "markers": [ + "не интересуютсяПриятно было взаимодействовать", + "Инфрастуктура" + ] + } + ] + }, + { + "slug": "salary-analytics", + "url": "https://rockits.ru/it-salary-analytics", + "role": "salary analytics commercial surface", + "assertions": [ + { + "id": "pricing_unit_signal", + "type": "all_of", + "markers": [ + "от 50 000 т.р.", + "от 530 000 т.р.", + "от 690 000 т.р." + ] + } + ] + }, + { + "slug": "en-home", + "url": "https://hirockits.com/", + "role": "international buyer acquisition surface", + "assertions": [ + { + "id": "english_editorial_defects", + "type": "all_of", + "markers": [ + "Person behind CV is more important, than the text in it.", + "pervious stages", + "we werelooking for" + ] + }, + { + "id": "english_language_mix", + "type": "all_of", + "markers": [ + "нужного специалиста" + ] + } + ] + } + ], + "findings": [ + { + "id": "ROCK-001", + "title": "Buyer-facing performance metrics are inconsistent in the public response", + "severity": "MEDIUM", + "evidence_refs": [ + "ru-home:client_count_conflict", + "ru-home:offer_acceptance_conflict" + ], + "quality_lens": "The public response contains different values for the same client-count and offer-acceptance concepts.", + "system_lens": "Marketing KPIs appear duplicated across page blocks without a shared source or freshness rule.", + "business_lens": "Conflicting proof metrics can weaken trust during agency comparison and procurement review.", + "initial_state": "PRODUCT_SIGNAL", + "promotion_rule": "Only promote if both value pairs are visible in settled desktop and mobile without opening hidden accordion content." + }, + { + "id": "ROCK-002", + "title": "Salary analytics prices use a potentially mis-scaled monetary unit", + "severity": "MEDIUM", + "evidence_refs": [ + "salary-analytics:pricing_unit_signal" + ], + "quality_lens": "Several prices are displayed with the suffix т.р., which conventionally denotes thousands of rubles and may multiply the intended amounts by 1,000.", + "system_lens": "Price amount and currency/unit formatting are not protected by a typed display contract.", + "business_lens": "Ambiguous pricing can interrupt purchase intent and create avoidable clarification work for sales.", + "initial_state": "PRODUCT_SIGNAL", + "promotion_rule": "Confirm the exact labels in desktop and mobile; do not claim the intended commercial amount without company context." + }, + { + "id": "ROCK-003", + "title": "The candidate source contains duplicated or defective content that is not confirmed in the settled UI", + "severity": "LOW", + "evidence_refs": [ + "candidate:duplicated_candidate_testimonial", + "candidate:candidate_copy_defects" + ], + "quality_lens": "The source-level candidate content contains duplicate and editorial signals, while the first settled render showed only one testimonial and omitted the selected defects.", + "system_lens": "Responsive or carousel content can diverge from source-level content, requiring rendered adjudication.", + "business_lens": "No user-visible impact should be claimed unless the content is reproduced in the active journey.", + "initial_state": "NEEDS_EVIDENCE", + "promotion_rule": "Confirm duplication and exact wording in settled desktop and mobile renders." + }, + { + "id": "ROCK-004", + "title": "The Russian buyer landing contains a visible decimal agreement defect", + "severity": "LOW", + "evidence_refs": [ + "ru-home:decimal_agreement_defect" + ], + "quality_lens": "The proof metric is published as 4,76 процент instead of a grammatically consistent unit form or percent symbol.", + "system_lens": "Metric values and unit labels are not validated as one typed presentation component.", + "business_lens": "A visible error in a proof metric weakens the polish of a page selling recruitment quality.", + "initial_state": "PRODUCT_SIGNAL", + "promotion_rule": "Confirm the exact wording in desktop and mobile." + }, + { + "id": "ROCK-005", + "title": "The English acquisition surface contains visible editorial defects", + "severity": "LOW", + "evidence_refs": [ + "en-home:english_editorial_defects" + ], + "quality_lens": "The English page contains an unnecessary comma, a wrong word, and concatenated words.", + "system_lens": "Localized content lacks a consistent proofreading and acceptance gate.", + "business_lens": "International buyers may infer lower delivery maturity from the English storefront.", + "initial_state": "PRODUCT_SIGNAL", + "promotion_rule": "Confirm every marker in desktop and mobile." + }, + { + "id": "ROCK-006", + "title": "The English acquisition surface contains Russian-language residue", + "severity": "LOW", + "evidence_refs": [ + "en-home:english_language_mix" + ], + "quality_lens": "A Russian phrase is visibly embedded in the English workflow description.", + "system_lens": "Localization completeness is not protected by a language-consistency check.", + "business_lens": "Mixed-language residue can interrupt comprehension and reduce international trust.", + "initial_state": "PRODUCT_SIGNAL", + "promotion_rule": "Confirm the Russian phrase in desktop and mobile." + } + ], + "lotus_policy": { + "search_snippet_only": "NEEDS_EVIDENCE", + "public_response_marker_reproduced": "PRODUCT_SIGNAL", + "desktop_and_mobile_rendered_reproduction": "CONFIRMED_PRODUCT_DEFECT_CANDIDATE", + "root_cause_without_counterfactual": "HYPOTHESIS_ONLY", + "business_impact_without_metrics": "PLAUSIBLE_NOT_MEASURED", + "legal_or_security_claim_without_scoped_impact": "BLOCK", + "external_reporting": "BLOCK_UNTIL_HUMAN_REVIEW" + } +} diff --git a/docs/audits/HI_ROCKITS_COLLABORATION_BRIEF.md b/docs/audits/HI_ROCKITS_COLLABORATION_BRIEF.md new file mode 100644 index 00000000..f013d5f7 --- /dev/null +++ b/docs/audits/HI_ROCKITS_COLLABORATION_BRIEF.md @@ -0,0 +1,79 @@ +# Hi, Rockits! · collaboration brief + +## Why this brief exists + +A bounded outside-in review of the public Hi, Rockits! Russian and English buyer journeys identified several reproducible quality signals that may affect trust and conversion. + +The purpose is not to assign blame or claim access to internal systems. It is to demonstrate how a compact independent QA, system-analysis, and business-analysis pilot could improve public product integrity. + +## Four confirmed rendered observations + +### 1. Salary analytics pricing uses an ambiguous monetary unit + +The analytics page visibly displays amounts such as `50 000 т.р.`, `530 000 т.р.`, and `690 000 т.р.` in desktop and mobile. + +In common Russian notation, `т.р.` means thousands of rubles. The intended commercial amount may be different, but the public display can be interpreted as tens or hundreds of millions of rubles. + +**Potential effect:** buyers may misread the offer by three orders of magnitude and delay or abandon an inquiry. + +### 2. A proof metric contains a visible unit-agreement defect + +The Russian buyer landing publishes the phrase `4,76 процент замен кандидатов`. + +**Potential effect:** a visible error inside a quality proof metric weakens the polish of a page selling recruitment accuracy and process quality. + +### 3. The English storefront contains editorial defects + +The international page visibly includes wording such as: + +- `Person behind CV is more important, than the text in it.`; +- `pervious stages`; +- `we werelooking for`. + +**Potential effect:** international clients may receive a weaker trust signal than Russian-language buyers. + +### 4. Russian-language content remains inside the English journey + +The phrase `нужного специалиста` is displayed inside the English workflow description. + +**Potential effect:** mixed-language residue interrupts comprehension and suggests incomplete localization acceptance. + +## Additional signals requiring context + +The technical evidence pack also records lower-level signals around KPI synchronization and source/UI divergence on the candidate page. They are deliberately excluded from the confirmed list until their visible state or business definitions are clarified. + +## Proposed pilot + +A compact pilot can cover: + +1. buyer acquisition and proof metrics; +2. salary analytics packaging and pricing clarity; +3. candidate and resume-submission journeys; +4. Russian/English content consistency; +5. desktop/mobile behavior and accessibility basics; +6. KPI, price, testimonial, and localization ownership controls. + +## Deliverables + +- prioritized QA findings with reproduction steps; +- a system map of duplicated metrics and localization sources; +- buyer and candidate conversion hypotheses separated from measured facts; +- desktop/mobile evidence pack with integrity hashes; +- remediation backlog with quick wins and structural controls; +- a 30-minute findings walkthrough. + +## Suggested pilot boundaries + +- public unauthenticated pages only; +- no form submission, resume upload, or contact request; +- no direct API, security, load, or intrusive testing; +- no claim about internal root cause or intended price without Hi, Rockits! context; +- no publication of findings without agreement. + +## Suggested next step + +A 15–20 minute introductory call to review the four rendered observations, validate the intended pricing unit, and agree on a small pilot scope. + +--- + +This brief is an independent outside-in assessment. It does not represent Hi, Rockits!, prove internal root cause, establish legal impact, or quantify commercial loss. diff --git a/docs/audits/HI_ROCKITS_OUTSIDE_IN_AUDIT_V0_1.md b/docs/audits/HI_ROCKITS_OUTSIDE_IN_AUDIT_V0_1.md new file mode 100644 index 00000000..a48284da --- /dev/null +++ b/docs/audits/HI_ROCKITS_OUTSIDE_IN_AUDIT_V0_1.md @@ -0,0 +1,135 @@ +# Hi, Rockits! outside-in audit v0.1 + +## Purpose + +This document defines a bounded, independent audit of the public unauthenticated Hi, Rockits! web surfaces through three lenses: + +- QA and content integrity; +- system and content-model consistency; +- business, buyer, and candidate-conversion impact. + +The repository is the evidence and configuration center. The audit does not authorize contact, form submission, resume upload, remediation, deployment, or merge. + +## Scope + +Official origins: + +```text +https://rockits.ru +https://hirockits.com +``` + +Allowlisted routes: + +```text +https://rockits.ru/ +https://rockits.ru/candidate +https://rockits.ru/it-salary-analytics +https://hirockits.com/ +``` + +The raw probe performs one sequential unauthenticated GET per route. The rendered slice observes each route in desktop and mobile Chromium, captures full-page screenshots, visible-text hashes, bounded console/network summaries, structure metrics, and keyboard Tab traces. + +## Source-to-rendered adjudication + +The exact runs demonstrated why raw source and settled browser evidence are kept separate: + +- KPI values `1740` and `72%` were present in the public response but not visible without opening a closed FAQ accordion; +- the salary analytics route initially returned Chromium `403`, then reproduced its price labels in the final exact run on both desktop and mobile; +- repeated testimonial and copy markers existed at source level, while the settled candidate UI displayed one tested testimonial and omitted the selected defects; +- the original English assertion was too broad because one block was desktop-only, so it was split into wording reproduced in both profiles. + +The final contract therefore confirms four user-visible candidates and preserves two lower-level signals. + +## Confirmed rendered candidates + +### ROCK-002 · Salary analytics monetary unit + +The salary analytics page visibly presents prices such as `от 50 000 т.р.`, `от 530 000 т.р.`, and `от 690 000 т.р.` in desktop and mobile. + +In common Russian notation, `т.р.` denotes thousands of rubles. The display therefore appears to scale the intended amounts by 1,000, although the intended commercial price must be confirmed with Hi, Rockits! before any final severity or loss claim. + +### ROCK-004 · Russian proof-metric grammar + +The buyer landing visibly publishes `4,76 процент замен кандидатов` in desktop and mobile. The metric value and unit label form an externally visible copy-quality signal. + +### ROCK-005 · English editorial defects + +The English acquisition page visibly contains in both profiles: + +- `Person behind CV is more important, than the text in it.`; +- `pervious stages`; +- `we werelooking for`. + +### ROCK-006 · English language-mix residue + +The Russian phrase `нужного специалиста` is embedded in the English workflow description in desktop and mobile. + +## Lower-level signals + +### ROCK-001 · KPI consistency — needs rendered evidence + +The Russian public response contains different values for client count (`1742` and `1740`) and offer acceptance (`73%` and `72%`). The closed FAQ values were not visible in the passive settled UI, so this remains below confirmed-candidate level. + +### ROCK-003 · Candidate source/UI divergence — needs evidence + +The source-level candidate content includes duplicate and editorial signals. The settled desktop/mobile page showed only one tested testimonial and did not expose the selected copy markers. No user-visible defect is claimed from source alone. + +## Evidence ladder + +```text +search result or cached snippet +→ NEEDS_EVIDENCE + +marker reproduced in a current public response +→ PRODUCT_SIGNAL + +same issue reproduced in settled desktop and mobile renders +→ CONFIRMED_PRODUCT_DEFECT_CANDIDATE + +human semantic and impact review +→ final severity and collaboration decision +``` + +## Authority boundary + +The audit explicitly does **not** authorize: + +- authentication; +- form submission or contact requests; +- resume upload; +- button or CTA activation; +- direct API testing; +- enumeration, fuzzing, or load testing; +- active security testing or vulnerability claims; +- publication or external reporting; +- remediation, deployment, delivery, or merge. + +## Expected artifacts + +Raw: + +```text +reports/hi-rockits/public-audit-v0.1/result.json +reports/hi-rockits/public-audit-v0.1/summary.md +reports/hi-rockits/public-audit-v0.1/exact-attempt.json +reports/hi-rockits/public-audit-v0.1/ARTIFACT_SHA256SUMS.txt +``` + +Rendered: + +```text +reports/hi-rockits/rendered-audit-v0.2/hi-rockits-rendered-result.json +reports/hi-rockits/rendered-audit-v0.2/hi-rockits-rendered-summary.md +reports/hi-rockits/rendered-audit-v0.2/*.png +reports/hi-rockits/rendered-audit-v0.2/exact-attempt.json +reports/hi-rockits/rendered-audit-v0.2/ARTIFACT_SHA256SUMS.txt +``` + +## Judgment limitations + +- Public copy does not reveal internal CMS ownership or release processes. +- Different KPI values may refer to different time windows or definitions. +- The intended salary-analytics price cannot be inferred conclusively without company context. +- Hidden source content is not equivalent to a user-visible product defect. +- Business impact remains plausible but unmeasured. diff --git a/scripts/hi_rockits_public_audit_probe.py b/scripts/hi_rockits_public_audit_probe.py new file mode 100644 index 00000000..08557e19 --- /dev/null +++ b/scripts/hi_rockits_public_audit_probe.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Bounded passive public-content probe for the Hi, Rockits! outside-in audit.""" +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from html.parser import HTMLParser +from pathlib import Path +from typing import Any + +SCHEMA = "liminalqa-hi-rockits-public-audit-v1" +RESULT_SCHEMA = "liminalqa-hi-rockits-public-audit-result-v1" +DEFAULT_CONTRACT = Path("audits/hi-rockits/public-audit-v0.1/contract.json") + + +class VisibleTextParser(HTMLParser): + EXCLUDED = {"script", "style", "noscript", "template", "svg"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._excluded_depth = 0 + self.parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag.lower() in self.EXCLUDED: + self._excluded_depth += 1 + + def handle_endtag(self, tag: str) -> None: + if tag.lower() in self.EXCLUDED and self._excluded_depth: + self._excluded_depth -= 1 + + def handle_data(self, data: str) -> None: + if self._excluded_depth == 0: + self.parts.append(data) + + +def normalize_text(value: str) -> str: + return re.sub(r"\s+", " ", html.unescape(value)).strip() + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def canonical_origin(raw_url: str) -> str: + parsed = urllib.parse.urlsplit(raw_url) + return f"{parsed.scheme}://{parsed.hostname}" + + +class BoundedRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, allowed_origins: set[str]) -> None: + super().__init__() + self.allowed_origins = allowed_origins + + def redirect_request(self, req: urllib.request.Request, fp: Any, code: int, msg: str, + headers: Any, newurl: str) -> urllib.request.Request | None: + resolved = urllib.parse.urljoin(req.full_url, newurl) + if canonical_origin(resolved) not in self.allowed_origins: + raise urllib.error.HTTPError(resolved, code, f"Redirect outside bounded origins: {resolved}", headers, fp) + return super().redirect_request(req, fp, code, msg, headers, resolved) + + +def load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def validate_contract(contract: dict[str, Any]) -> None: + if contract.get("schema_version") != SCHEMA: + raise ValueError(f"Unsupported schema: {contract.get('schema_version')!r}") + origins = set((contract.get("target") or {}).get("canonical_origins") or []) + expected = {"https://rockits.ru", "https://hirockits.com"} + if origins != expected: + raise ValueError(f"Canonical origins must be exactly {sorted(expected)}") + + boundaries = contract.get("boundaries") or {} + for key in ["public_pages_only", "natural_get_navigation_only"]: + if boundaries.get(key) is not True: + raise ValueError(f"Boundary {key} must be true") + for key in [ + "authentication", "form_submission", "button_clicks", "resume_upload", + "email_or_external_contact", "direct_api_testing", "active_security_testing", + "enumeration", "fuzzing", "load_testing", "vulnerability_claim", + "external_submission_authorized", "deployment_authorized", "merge_authorized", + ]: + if boundaries.get(key) is not False: + raise ValueError(f"Boundary {key} must be false") + + runtime = contract.get("runtime") or {} + if runtime.get("max_parallel") != 1: + raise ValueError("The audit must remain sequential") + allowed_paths = contract.get("allowed_paths") or {} + targets = contract.get("targets") or [] + if len(targets) != 4: + raise ValueError("Target count must be exactly 4") + + refs: set[str] = set() + for target in targets: + raw_url = target.get("url") + parsed = urllib.parse.urlsplit(raw_url) + origin = canonical_origin(raw_url) + if parsed.scheme != "https" or origin not in origins: + raise ValueError(f"Target outside bounded HTTPS origins: {raw_url}") + if parsed.query or parsed.fragment: + raise ValueError(f"Target must not include query or fragment: {raw_url}") + if (parsed.path or "/") not in set(allowed_paths.get(origin) or []): + raise ValueError(f"Target path is not allowlisted: {raw_url}") + for assertion in target.get("assertions") or []: + ref = f"{target['slug']}:{assertion.get('id')}" + if ref in refs: + raise ValueError(f"Duplicate assertion ref: {ref}") + refs.add(ref) + if assertion.get("type") not in {"all_of", "any_of", "occurrence"}: + raise ValueError(f"Unsupported assertion type: {ref}") + + for finding in contract.get("findings") or []: + evidence_refs = finding.get("evidence_refs") or [] + if not evidence_refs or any(ref not in refs for ref in evidence_refs): + raise ValueError(f"Finding has invalid evidence refs: {finding.get('id')}") + + +def extract_visible_text(raw_html: str) -> str: + parser = VisibleTextParser() + parser.feed(raw_html) + parser.close() + return normalize_text(" ".join(parser.parts)) + + +def marker_context(text: str, marker: str, radius: int = 150) -> str | None: + idx = text.casefold().find(marker.casefold()) + if idx < 0: + return None + return text[max(0, idx - radius): min(len(text), idx + len(marker) + radius)] + + +def evaluate_assertion(assertion: dict[str, Any], text: str) -> dict[str, Any]: + corpus = text if assertion.get("case_sensitive") else text.casefold() + if assertion["type"] in {"all_of", "any_of"}: + items = [] + for marker in assertion["markers"]: + needle = marker if assertion.get("case_sensitive") else marker.casefold() + items.append({"marker": marker, "present": needle in corpus, "context": marker_context(text, marker)}) + passed = all(item["present"] for item in items) if assertion["type"] == "all_of" else any(item["present"] for item in items) + return {"id": assertion["id"], "type": assertion["type"], "passed": passed, "markers": items} + marker = assertion["marker"] + needle = marker if assertion.get("case_sensitive") else marker.casefold() + count = corpus.count(needle) + return { + "id": assertion["id"], "type": "occurrence", "marker": marker, + "observed_occurrences": count, "min_occurrences": assertion["min_occurrences"], + "passed": count >= assertion["min_occurrences"], "context": marker_context(text, marker), + } + + +def observe(contract: dict[str, Any], target: dict[str, Any]) -> dict[str, Any]: + allowed_origins = set(contract["target"]["canonical_origins"]) + runtime = contract["runtime"] + opener = urllib.request.build_opener(BoundedRedirectHandler(allowed_origins)) + request = urllib.request.Request(target["url"], headers={ + "User-Agent": runtime["user_agent"], + "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1", + }) + try: + with opener.open(request, timeout=runtime["timeout_seconds"]) as response: + final_url = response.geturl() + body = response.read(runtime["max_response_bytes"] + 1) + if len(body) > runtime["max_response_bytes"]: + raise ValueError("Response exceeded max_response_bytes") + content_type = response.headers.get("Content-Type", "") + charset_match = re.search(r"charset=([\w.-]+)", content_type, re.I) + charset = charset_match.group(1) if charset_match else "utf-8" + raw_html = body.decode(charset, errors="replace") + text = extract_visible_text(raw_html) + return { + "slug": target["slug"], "requested_url": target["url"], "final_url": final_url, + "status": getattr(response, "status", None), "error": None, + "origin_stayed_bounded": canonical_origin(final_url) in allowed_origins, + "response_bytes": len(body), "body_sha256": sha256_bytes(body), + "visible_text_sha256": sha256_bytes(text.encode()), "visible_text_length": len(text), + "visible_text_sample": text[:5000], + "assertions": [evaluate_assertion(a, text) for a in target["assertions"]], + } + except Exception as exc: # bounded failure is evidence, not a crash + return { + "slug": target["slug"], "requested_url": target["url"], + "final_url": getattr(exc, "url", None), "status": getattr(exc, "code", None), + "error": str(exc), "origin_stayed_bounded": False, "response_bytes": 0, + "body_sha256": None, "visible_text_sha256": None, "visible_text_length": 0, + "visible_text_sample": "", "assertions": [ + {"id": a["id"], "type": a["type"], "passed": False, "error": "target_observation_failed"} + for a in target["assertions"] + ], + } + + +def aggregate(contract: dict[str, Any], observations: list[dict[str, Any]]) -> dict[str, Any]: + assertion_map = { + f"{obs['slug']}:{a['id']}": a for obs in observations for a in obs["assertions"] + } + findings = [] + for finding in contract["findings"]: + refs = finding["evidence_refs"] + passed = all(assertion_map.get(ref, {}).get("passed") is True for ref in refs) + findings.append({ + "id": finding["id"], "title": finding["title"], "severity": finding["severity"], + "state": "PRODUCT_SIGNAL" if passed else "NEEDS_EVIDENCE", + "evidence_refs": refs, "root_cause": "HYPOTHESIS_ONLY", + "business_impact": "PLAUSIBLE_NOT_MEASURED", + }) + return { + "expected_target_count": len(contract["targets"]), + "observed_target_count": len(observations), + "successful_http_2xx": sum(1 for o in observations if isinstance(o.get("status"), int) and 200 <= o["status"] < 300), + "bounded_observations": sum(1 for o in observations if o.get("origin_stayed_bounded")), + "findings": findings, + "decision": "PRODUCT_SIGNALS" if any(f["state"] == "PRODUCT_SIGNAL" for f in findings) else "NEEDS_EVIDENCE", + } + + +def render_summary(result: dict[str, Any]) -> str: + lines = ["# Hi, Rockits! outside-in audit v0.1", "", f"Generated: `{result['generated_at']}`", ""] + agg = result["aggregate"] + lines += [f"- Targets: `{agg['observed_target_count']}/{agg['expected_target_count']}`", f"- HTTP 2xx: `{agg['successful_http_2xx']}`", f"- Decision: `{agg['decision']}`", "", "## Findings", ""] + for finding in agg["findings"]: + lines.append(f"- `{finding['id']}` · **{finding['severity']}** · `{finding['state']}` — {finding['title']}") + lines += ["", "## Authority", "", "Evidence only. External submission, deployment, and merge remain blocked.", ""] + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--contract", type=Path, default=DEFAULT_CONTRACT) + parser.add_argument("--output-dir", type=Path, default=Path("reports/hi-rockits/public-audit-v0.1")) + parser.add_argument("--validate-only", action="store_true") + args = parser.parse_args() + contract = load_json(args.contract) + validate_contract(contract) + if args.validate_only: + return 0 + observations = [observe(contract, target) for target in contract["targets"]] + result = { + "schema_version": RESULT_SCHEMA, + "generated_at": datetime.now(timezone.utc).isoformat(), + "audit_id": contract["audit_id"], "target": contract["target"], + "boundaries": contract["boundaries"], "observations": observations, + "aggregate": aggregate(contract, observations), + "authority": {"mode": "evidence_only", "grants": {"external_submission": False, "deployment": False, "merge": False}}, + } + args.output_dir.mkdir(parents=True, exist_ok=True) + (args.output_dir / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + (args.output_dir / "summary.md").write_text(render_summary(result), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hi_rockits_rendered_audit_probe.mjs b/scripts/hi_rockits_rendered_audit_probe.mjs new file mode 100644 index 00000000..e2d91342 --- /dev/null +++ b/scripts/hi_rockits_rendered_audit_probe.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import puppeteer from 'puppeteer-core'; + +const CONFIG_SCHEMA = 'liminalqa-hi-rockits-public-rendered-v1'; +const RESULT_SCHEMA = 'liminalqa-hi-rockits-public-rendered-result-v1'; +const ALLOWED_ORIGINS = new Set(['https://rockits.ru', 'https://hirockits.com']); + +function parseArgs(argv) { + const args = {}; + for (let i = 2; i < argv.length; i += 1) { + const key = argv[i]; + if (!key.startsWith('--') || i + 1 >= argv.length) throw new Error(`Invalid argument: ${key}`); + args[key.slice(2)] = argv[++i]; + } + for (const key of ['config', 'contract', 'chrome', 'output-dir']) { + if (!args[key]) throw new Error(`Missing --${key}`); + } + return args; +} + +function normalizeText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function stripQuery(rawUrl) { + try { + const url = new URL(rawUrl); + url.search = ''; + url.hash = ''; + return url.toString(); + } catch { + return String(rawUrl); + } +} + +function context(text, marker, radius = 160) { + const index = text.toLocaleLowerCase().indexOf(marker.toLocaleLowerCase()); + if (index < 0) return null; + return text.slice(Math.max(0, index - radius), Math.min(text.length, index + marker.length + radius)); +} + +function evaluateAssertion(assertion, text) { + const corpus = assertion.case_sensitive ? text : text.toLocaleLowerCase(); + if (assertion.type === 'all_of' || assertion.type === 'any_of') { + const markers = assertion.markers.map((marker) => { + const needle = assertion.case_sensitive ? marker : marker.toLocaleLowerCase(); + return { marker, present: corpus.includes(needle), context: context(text, marker) }; + }); + const passed = assertion.type === 'all_of' ? markers.every((item) => item.present) : markers.some((item) => item.present); + return { id: assertion.id, type: assertion.type, passed, markers }; + } + const marker = assertion.marker; + const needle = assertion.case_sensitive ? marker : marker.toLocaleLowerCase(); + let count = 0; + let start = 0; + while ((start = corpus.indexOf(needle, start)) >= 0) { + count += 1; + start += Math.max(needle.length, 1); + } + return { + id: assertion.id, + type: 'occurrence', + marker, + observed_occurrences: count, + min_occurrences: assertion.min_occurrences, + passed: count >= assertion.min_occurrences, + context: context(text, marker), + }; +} + +function validate(config, contract) { + if (config.schema_version !== CONFIG_SCHEMA) throw new Error(`Unsupported config schema: ${config.schema_version}`); + const origins = new Set(contract?.target?.canonical_origins ?? []); + if (origins.size !== 2 || [...ALLOWED_ORIGINS].some((origin) => !origins.has(origin))) { + throw new Error('Contract origins are not exactly bounded to the two official Hi, Rockits! origins'); + } + if (!Array.isArray(config.profiles) || config.profiles.map((p) => p.id).sort().join(',') !== 'desktop,mobile') { + throw new Error('Exactly desktop and mobile profiles are required'); + } + if (!Array.isArray(contract.targets) || contract.targets.length !== 4) throw new Error('Exactly four targets are required'); + const boundaries = config.boundaries ?? {}; + for (const key of ['public_pages_only', 'passive_rendering_only', 'keyboard_tab_only']) { + if (boundaries[key] !== true) throw new Error(`Boundary ${key} must be true`); + } + for (const key of ['authentication', 'form_submission', 'button_clicks', 'resume_upload', 'external_contact', 'direct_api_testing', 'active_security_testing', 'load_testing', 'external_submission_authorized', 'deployment_authorized', 'merge_authorized']) { + if (boundaries[key] !== false) throw new Error(`Boundary ${key} must be false`); + } + const allowedPaths = contract.allowed_paths ?? {}; + for (const target of contract.targets) { + const url = new URL(target.url); + if (!ALLOWED_ORIGINS.has(url.origin)) throw new Error(`Target outside allowed origins: ${target.url}`); + if (url.search || url.hash) throw new Error(`Target includes query or fragment: ${target.url}`); + if (!(allowedPaths[url.origin] ?? []).includes(url.pathname)) throw new Error(`Target path not allowlisted: ${target.url}`); + } +} + +async function keyboardTrace(page, steps) { + const trace = []; + for (let index = 0; index < steps; index += 1) { + await page.keyboard.press('Tab'); + trace.push(await page.evaluate(() => { + const el = document.activeElement; + if (!el) return null; + return { + tag: el.tagName?.toLowerCase() ?? null, + text: (el.innerText || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || '').replace(/\s+/g, ' ').trim().slice(0, 180), + href: el instanceof HTMLAnchorElement ? el.href : null, + type: el.getAttribute?.('type') ?? null, + }; + })); + } + return trace; +} + +async function observe(browser, config, contract, target, profile, outputDir) { + const page = await browser.newPage(); + await page.setViewport(profile.viewport); + await page.setUserAgent(profile.user_agent); + page.setDefaultNavigationTimeout(config.runtime.navigation_timeout_ms); + + const consoleItems = []; + const failedRequests = []; + const badResponses = []; + page.on('console', (message) => { + if (['error', 'warning'].includes(message.type()) && consoleItems.length < config.runtime.max_console_items) { + consoleItems.push({ type: message.type(), text: message.text().slice(0, 800) }); + } + }); + page.on('requestfailed', (request) => { + if (failedRequests.length < config.runtime.max_network_items) { + failedRequests.push({ url: stripQuery(request.url()), error: request.failure()?.errorText ?? null }); + } + }); + page.on('response', (response) => { + if (response.status() >= 400 && badResponses.length < config.runtime.max_network_items) { + badResponses.push({ url: stripQuery(response.url()), status: response.status() }); + } + }); + + let status = null; + let navigationError = null; + try { + const response = await page.goto(target.url, { waitUntil: 'domcontentloaded' }); + status = response?.status() ?? null; + await new Promise((resolve) => setTimeout(resolve, config.runtime.settle_ms)); + } catch (error) { + navigationError = String(error?.message ?? error); + } + + const finalUrl = page.url(); + const finalOrigin = (() => { try { return new URL(finalUrl).origin; } catch { return null; } })(); + const data = await page.evaluate(() => { + const bodyText = (document.body?.innerText ?? '').replace(/\s+/g, ' ').trim(); + const named = (el) => (el.innerText || el.getAttribute('aria-label') || el.getAttribute('title') || '').replace(/\s+/g, ' ').trim(); + return { + title: document.title, + bodyText, + headings: [...document.querySelectorAll('h1,h2,h3')].slice(0, 100).map((el) => ({ level: el.tagName.toLowerCase(), text: named(el).slice(0, 300) })), + h1Count: document.querySelectorAll('h1').length, + formCount: document.forms.length, + inputCount: document.querySelectorAll('input,textarea,select').length, + buttonCount: document.querySelectorAll('button,[role="button"]').length, + linkCount: document.links.length, + imageCount: document.images.length, + missingAltCount: [...document.images].filter((img) => !img.hasAttribute('alt') || !img.alt.trim()).length, + unnamedLinkCount: [...document.querySelectorAll('a')].filter((el) => !named(el) && !el.querySelector('img[alt]')).length, + duplicateIds: Object.entries([...document.querySelectorAll('[id]')].reduce((acc, el) => { acc[el.id] = (acc[el.id] || 0) + 1; return acc; }, {})).filter(([, count]) => count > 1).slice(0, 50), + }; + }); + + const assertions = target.assertions.map((assertion) => evaluateAssertion(assertion, data.bodyText)); + const trace = await keyboardTrace(page, config.runtime.tab_steps); + const screenshotName = `${profile.id}-${target.slug}.png`; + const screenshotPath = path.join(outputDir, screenshotName); + await page.screenshot({ path: screenshotPath, fullPage: true }); + const screenshotBytes = await fs.readFile(screenshotPath); + await page.close(); + + return { + slug: target.slug, + profile: profile.id, + requested_url: target.url, + final_url: finalUrl, + final_origin: finalOrigin, + origin_stayed_bounded: ALLOWED_ORIGINS.has(finalOrigin), + status, + navigation_error: navigationError, + title: data.title, + visible_text_length: data.bodyText.length, + visible_text_sha256: sha256(Buffer.from(data.bodyText)), + visible_text_sample: data.bodyText.slice(0, 5000), + assertions, + structure: { + headings: data.headings, + h1_count: data.h1Count, + form_count: data.formCount, + input_count: data.inputCount, + button_count: data.buttonCount, + link_count: data.linkCount, + image_count: data.imageCount, + missing_alt_count: data.missingAltCount, + unnamed_link_count: data.unnamedLinkCount, + duplicate_ids: data.duplicateIds, + }, + keyboard_trace: trace, + console_items: consoleItems, + failed_requests: failedRequests, + bad_responses: badResponses, + screenshot: { file: screenshotName, sha256: sha256(screenshotBytes), bytes: screenshotBytes.length }, + }; +} + +function aggregate(contract, observations) { + const assertionMap = new Map(); + for (const observation of observations) { + for (const assertion of observation.assertions) { + assertionMap.set(`${observation.slug}:${observation.profile}:${assertion.id}`, assertion); + } + } + const profiles = ['desktop', 'mobile']; + const findings = contract.findings.map((finding) => { + const profilePassed = Object.fromEntries(profiles.map((profile) => [profile, finding.evidence_refs.every((ref) => { + const [slug, assertionId] = ref.split(':'); + return assertionMap.get(`${slug}:${profile}:${assertionId}`)?.passed === true; + })])); + const passedCount = Object.values(profilePassed).filter(Boolean).length; + const state = passedCount === 2 ? 'CONFIRMED_PRODUCT_DEFECT_CANDIDATE' : passedCount === 1 ? 'PRODUCT_SIGNAL' : 'NEEDS_EVIDENCE'; + return { + id: finding.id, + title: finding.title, + severity: finding.severity, + state, + profile_passed: profilePassed, + evidence_refs: finding.evidence_refs, + root_cause: 'HYPOTHESIS_ONLY', + business_impact: 'PLAUSIBLE_NOT_MEASURED', + }; + }); + return { + expected_observation_count: contract.targets.length * profiles.length, + observed_route_profile_count: observations.length, + bounded_observation_count: observations.filter((o) => o.origin_stayed_bounded).length, + http_2xx_count: observations.filter((o) => Number.isInteger(o.status) && o.status >= 200 && o.status < 300).length, + findings, + decision: findings.some((f) => f.state === 'CONFIRMED_PRODUCT_DEFECT_CANDIDATE') ? 'RENDERED_PRODUCT_DEFECT_CANDIDATES' : 'NEEDS_EVIDENCE', + }; +} + +function renderSummary(result) { + const lines = [ + '# Hi, Rockits! rendered audit v0.2', '', + `Generated: \`${result.generated_at}\``, + `- Observations: \`${result.aggregate.observed_route_profile_count}/${result.aggregate.expected_observation_count}\``, + `- Bounded: \`${result.aggregate.bounded_observation_count}\``, + `- HTTP 2xx: \`${result.aggregate.http_2xx_count}\``, + `- Decision: \`${result.aggregate.decision}\``, '', '## Findings', '', + ]; + for (const finding of result.aggregate.findings) { + lines.push(`- \`${finding.id}\` · **${finding.severity}** · \`${finding.state}\` — ${finding.title}`); + } + lines.push('', '## Authority', '', 'Evidence only. External submission, deployment, and merge remain blocked.', ''); + return lines.join('\n'); +} + +async function main() { + const args = parseArgs(process.argv); + const config = JSON.parse(await fs.readFile(args.config, 'utf8')); + const contract = JSON.parse(await fs.readFile(args.contract, 'utf8')); + validate(config, contract); + await fs.mkdir(args['output-dir'], { recursive: true }); + const browser = await puppeteer.launch({ executablePath: args.chrome, headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); + const observations = []; + try { + for (const target of contract.targets) { + for (const profile of config.profiles) { + observations.push(await observe(browser, config, contract, target, profile, args['output-dir'])); + } + } + } finally { + await browser.close(); + } + const result = { + schema_version: RESULT_SCHEMA, + generated_at: new Date().toISOString(), + audit_id: config.audit_id, + target: contract.target, + boundaries: config.boundaries, + observations, + aggregate: aggregate(contract, observations), + authority: { mode: 'evidence_only', grants: { external_submission: false, deployment: false, merge: false } }, + }; + await fs.writeFile(path.join(args['output-dir'], 'hi-rockits-rendered-result.json'), JSON.stringify(result, null, 2)); + await fs.writeFile(path.join(args['output-dir'], 'hi-rockits-rendered-summary.md'), renderSummary(result)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_hi_rockits_public_audit_probe.py b/tests/test_hi_rockits_public_audit_probe.py new file mode 100644 index 00000000..1f168a72 --- /dev/null +++ b/tests/test_hi_rockits_public_audit_probe.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +import unittest +from pathlib import Path + +MODULE_PATH = Path("scripts/hi_rockits_public_audit_probe.py") +SPEC = importlib.util.spec_from_file_location("hi_rockits_probe", MODULE_PATH) +assert SPEC and SPEC.loader +probe = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(probe) + +CONTRACT_PATH = Path("audits/hi-rockits/public-audit-v0.1/contract.json") + + +class HiRockitsAuditContractTests(unittest.TestCase): + def setUp(self) -> None: + self.contract = json.loads(CONTRACT_PATH.read_text(encoding="utf-8")) + + def test_repository_contract_is_valid(self) -> None: + probe.validate_contract(self.contract) + + def test_occurrence_assertion_requires_minimum_count(self) -> None: + assertion = {"id": "dup", "type": "occurrence", "marker": "same", "min_occurrences": 2} + self.assertTrue(probe.evaluate_assertion(assertion, "same / same")["passed"]) + self.assertFalse(probe.evaluate_assertion(assertion, "same once")["passed"]) + + def test_all_of_is_case_insensitive(self) -> None: + assertion = {"id": "all", "type": "all_of", "markers": ["Rockits", "QA"]} + self.assertTrue(probe.evaluate_assertion(assertion, "ROCKITS qa")["passed"]) + + def test_external_submission_must_remain_disabled(self) -> None: + changed = copy.deepcopy(self.contract) + changed["boundaries"]["external_submission_authorized"] = True + with self.assertRaisesRegex(ValueError, "external_submission_authorized"): + probe.validate_contract(changed) + + def test_resume_upload_must_remain_disabled(self) -> None: + changed = copy.deepcopy(self.contract) + changed["boundaries"]["resume_upload"] = True + with self.assertRaisesRegex(ValueError, "resume_upload"): + probe.validate_contract(changed) + + def test_form_submission_must_remain_disabled(self) -> None: + changed = copy.deepcopy(self.contract) + changed["boundaries"]["form_submission"] = True + with self.assertRaisesRegex(ValueError, "form_submission"): + probe.validate_contract(changed) + + def test_outside_origin_target_is_rejected(self) -> None: + changed = copy.deepcopy(self.contract) + changed["targets"][0]["url"] = "https://example.com/" + with self.assertRaisesRegex(ValueError, "outside bounded HTTPS origins"): + probe.validate_contract(changed) + + def test_query_string_target_is_rejected(self) -> None: + changed = copy.deepcopy(self.contract) + changed["targets"][0]["url"] = "https://rockits.ru/?debug=true" + with self.assertRaisesRegex(ValueError, "query or fragment"): + probe.validate_contract(changed) + + +if __name__ == "__main__": + unittest.main()